[BUG] Forking reuses the parent's plan-file slug — all forks collide on the same ~/.claude/plans/<slug>.md (regression of #31677)

Status Closed — not planned
Reported on v2.1.186
Maintainer reply ✓ Yes — bcherny
Activity 6 comments · opened Jul 10, 2026 · closed Aug 24, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

Severity: P0 — silent data loss that nullifies the core guarantee of /fork

Forking exists to let a user branch a conversation and explore multiple approaches in parallel without the branches affecting one another. The documented contract (how-claude-code-works.md: "The original session remains unchanged"; #31677: "each fork gets its own independent plan file") is isolation. That isolation is the entire value of the feature.

This bug breaks that guarantee at the plan-file layer: every fork of a session resolves to the same ~/.claude/plans/<slug>.md, so forks silently overwrite each other's plan. It is not cosmetic — it is silent data loss plus a correctness hazard (a fork can execute against a different fork's plan), and it fails with no error and no warning. It scales with the exact usage forking is meant to enable: the more branches a power user runs in parallel, the more they clobber each other.

This is also the failure mode #31677 states was already fixed ("Before the fix, forks shared the same plan file, causing plan edits in one fork to silently overwrite the other's plan"). It has regressed, or the fix never covered the Desktop /fork path.

User story

As a developer, I ask Claude Code to help me evaluate two competing implementation strategies for a module. At the planning point I fork the session into Fork A — "refactor in place" and Fork B — "rewrite the module", so I can develop both plans in parallel and compare them. Both forks are in Plan Mode. While Fork B refines its plan, its write to ~/.claude/plans/<slug>.md silently overwrites Fork A's plan file (same slug, same path). When I switch back to Fork A to execute, its plan now contains Fork B's rewrite plan. Fork A either executes the wrong plan against my repo, or my refactor plan is simply gone — with no error, no warning, no indication anything crossed over. I explicitly told the model in Fork A to "use a different plan file" — but that does nothing, because the model doesn't choose the plan path; the harness derives it from the inherited slug. The one workflow forking exists to enable — isolated parallel exploration — is the workflow it silently corrupts.

The failure is worse the more the feature is used as intended: N parallel forks all race on a single file, last-writer-wins, and any fork that later re-reads its plan gets whichever sibling wrote last.

Impact summary

  • Silent data loss — a fork's plan is overwritten with no error/warning.
  • Correctness hazard — a fork can execute (edit a real repo) against another fork's plan.
  • Breaks the feature's core invariant — fork isolation is the documented, load-bearing guarantee; this voids it.
  • Scales with intended usage — parallel/branching workflows (incl. multi-agent orchestration) are exactly where forks are load-bearing, and exactly where the collision compounds.
  • Regression — previously reported fixed (#31677).

Environment

  • Claude Code CLI: 2.1.186
  • Claude Desktop: 1.20186.0
  • Platform: macOS (Darwin arm64)
  • Reproduced via Desktop /fork and claude --continue --fork-session.

Repro

  1. Start a session and enter Plan Mode; a plan is written to ~/.claude/plans/<slug>.md (e.g. so-i-think-the-wiggly-newt.md).
  2. Fork the conversation one or more times (Desktop /fork, or claude --continue --fork-session).
  3. Enter/continue Plan Mode in each fork and edit the plan.

Expected: each fork gets its own isolated plan file (per #31677's documented post-fix behavior). Edits in one fork do not affect siblings or the original.

Actual: all forks resolve to the same <slug>.md; the last writer wins and the others' plans are silently overwritten. Instructing the model to use a different filename has no effect — the harness, not the model, derives the path from the inherited slug.

Evidence that the slug is re-read, not regenerated

The plan slug is stamped onto every message in the transcript ("slug":"…" on each row). On my machine a single slug so-i-think-the-wiggly-newt is carried across 193 distinct transcript files, and the on-disk plan files share the identical random suffix:

so-i-think-the-wiggly-newt.md
so-i-think-the-wiggly-newt-agent-a23f34039f0b36fdd.md   # sub-agents DO get unique ids (correct)
so-i-think-the-wiggly-newt-agent-a6cc2c8e0bba29bb1.md
...

The random component (wiggly-newt) being identical across every fork is the tell: regeneration would pick a new random word each time. Sub-agents (-agent-<id>) get unique suffixes and are unaffected — only the fork base slug collides.

Root cause (from the 2.1.186 binary)

The slug is generated once, then persisted on every message and restored from the transcript on resume/fork:

// slug extracted directly from inherited messages:
function i3l(e){ return e.messages.find(t => t.slug)?.slug }

// "copyPlanForResume": pins planSlugCache[sessionId] to the transcript slug:
function L2n(e,t){ let n = i3l(e); if(!n) return false; let r = t ?? xt(); aOo(r,n); /* … */ }

// resume path — L2n runs UNCONDITIONALLY; forkSession only gates the *next* call:
if (s) await L2n(r, NT(s)),
       I2n(r, !n.forkSession && s ? NT(s) : void 0),   // <- forkSession guard is here, not on L2n
       o = r.messages;

Because L2n (transcript slug-restore) has no forkSession guard, a fork inherits the parent's exact slug. The collision-avoidance loop in the slug generator (Vxe, which checks existsSync before minting a name) is bypassed for forks, since the cache is pre-populated from the transcript. Fork message processing (V7t) only neutralizes model_refusal_fallback markers and never touches the slug, and a search for forkSession interacting with the slug / planSlugCache finds nothing.

Suggested fix

When forkSession === true, skip the transcript slug-restore (L2n) and force a fresh slug so Vxe's existsSync loop mints a non-colliding <slug>.md for the fork (mirroring how sub-agents already get unique -agent-<id> files). Optionally strip the stale slug field from inherited messages on fork so re-derivation can't re-pin it.

Note

The only plan-related config (plansDirectory) relocates the directory but does not change the per-fork filename, so it is not a workaround for forks sharing a project root. Cross-reference: #31677 (docs issue describing the intended isolated behavior).

View original on GitHub ↗

5 Comments

100yenadmin · 1 month ago

Concrete fix proposal (for whoever picks this up)

The correct behavior already exists for sub-agents — they get a unique -agent-<id> file and never collide. Forks should follow the same "mint-fresh, never inherit" rule. The bug is that the fork path inherits the slug via the transcript instead.

There are two clean places to fix it; either alone closes the collision, doing both is belt-and-suspenders:

Option A — don't restore the slug on a fork (minimal, targeted).
In the resume path, gate the transcript slug-restore on !forkSession, exactly like the adjacent I2n(...) call already is:

// before:
if (s) await L2n(r, NT(s)),
       I2n(r, !n.forkSession && s ? NT(s) : void 0),
       o = r.messages;

// after: skip copyPlanForResume when forking, so no parent slug is pinned
if (s) {
  if (!n.forkSession) await L2n(r, NT(s));   // only inherit the plan on a true resume
  I2n(r, !n.forkSession && s ? NT(s) : void 0);
  o = r.messages;
}

With the cache left unpopulated, the next UD()/Vxe() for the fork runs the existsSync loop and mints a fresh, non-colliding <slug>.md.

Option B — strip the stale slug from inherited messages on fork (defense in depth).
V7t(messages) already runs on fork (it neutralizes model_refusal_fallback markers). Have it also clear the persisted slug field so i3l(messages) can't re-pin the parent slug through any code path:

function V7t(e){
  for (let t = e.length - 1; t >= 0; t--) {
    let n = e[t];
    if (n?.slug) delete n.slug;                                  // <- fork gets a fresh plan slug
    if (n?.type === "system" && n.subtype === "model_refusal_fallback") n.neutralizedByFork = true;
  }
}

Notes for the fix

  • Isolation should hold for a custom plansDirectory too (the slug logic is directory-agnostic, so both options cover it).
  • Please add a regression test: fork a session with an active plan, assert UD(null) for the fork resolves to a different path than the parent, and that writing the fork's plan does not mutate the parent's file. This is the guarantee #31677 documents and that has regressed.
guillaume-paradise · 1 month ago

Corroborating data point: hit this on v2.1.193 (macOS, Claude Code desktop app), 2026-07-11.

Ran /fork from a plan-mode session. Both parent and child received the identical "Plan File Info" path (~/.claude/plans/<slug>.md, slug derived from the parent's original prompt). The fork's plan was then written into the parent's plan file; an overwrite by either session would have silently clobbered the other's pending plan. Workaround: had the fork append under a "PLAN 2" separator.

Minimal headless repro on the same version confirms it is not desktop-only:

claude -p --permission-mode plan --model haiku --output-format json \
  "Reply with ONLY the plan file path from your Plan File Info section."
# session_id: 7ea0c7b6-...  result: ~/.claude/plans/reply-with-only-the-transient-scone.md

claude -p --resume 7ea0c7b6-... --fork-session --permission-mode plan --model haiku --output-format json \
  "Reply with ONLY the plan file path from your Plan File Info section."
# session_id: d58201d0-... (new)  result: ~/.claude/plans/reply-with-only-the-transient-scone.md (same file)

New session id, identical plan path. Agree with the proposed fix: mint a fresh path per fork (e.g. suffix the fork's session id, matching the existing -agent-<id> pattern already used for subagent plan files).

lewistv · 1 month ago

Corroborating data point: this bug isn't fork-specific.

Reproduced on a SINGLE, non-forked session (Claude Code, izzypy_xcq/tf-workflow window, v2.1.x, 2026-07-14–19):

  1. EnterPlanMode fires at 14:29:57Z → plan approved → Write to

~/.claude/plans/moonlit-moseying-bunny.md (7899 chars).

  1. Same session, no fork — EnterPlanMode fires again at 19:06:18Z

(session resumed same day).

  1. Second plan approved → Write to the IDENTICAL path

moonlit-moseying-bunny.md, this time 7694 chars of different
content (confirmed on a further resume, 2026-07-19T15:26:42Z).

The first plan is gone — no error, no warning, silently overwritten —
with zero forking involved. This matches the root cause in the report
above (transcript slug-restore via i3l/L2n finding any message with a
slug field on resume) but shows the collision window is broader than
/fork: any session that re-enters plan mode a second time, including on
a plain --resume, hits the same bug the fork case exposes more visibly.

Workaround we've since adopted: save the approved plan to a
fleet-tracked file immediately on ExitPlanMode approval, before any
second EnterPlanMode entry in the session can clobber the scratch file.

bcherny collaborator · 14 days ago

Tried to reproduce this on 2.1.233 on Linux: started a plan-mode session (plan file appeared under ~/.claude/plans/), then forked it twice and had each fork modify its plan in plan mode.

Could not reproduce with CLI forks: each fork got its own plan-file identity, attempts to write the original session's plan file were blocked by plan-mode protection, and the original plan file was unchanged after both forks. The /fork plan-file sharing fix shipped in 2.1.71 and is still working in 2.1.233 (changelog).

Your second hypothesis looks like the right lead though: forks created through the Desktop app can still carry the original session's plan-file identity, so Desktop-created forks may indeed collide on one plan file. We're looking at that path.

To confirm you're hitting that case, could you share:

  • How each fork was created (Desktop app fork button, CLI /fork, or --fork-session), and on which app/CLI version
  • ls -la ~/.claude/plans/ after the forks, and whether the forks' session .jsonl files contain the same "slug" value as the original

🤖 Generated with Claude Code

github-actions[bot] · 14 days ago

We weren't able to reproduce this. Could you provide steps to trigger the issue — what you ran, what happened, and what you expected? This issue will be closed automatically if there's no activity within 7 days.

Showing cached comments. Read the full discussion on GitHub ↗