When two or more Claude Code sessions are working in the same repo at the same time, they can produce overlapping or wasted work without ever colliding at the git level. This doc names the failure modes, explains why the cheap ones are caught and the costly ones aren't, and lists the signals that would actually shrink the costly category.
When two sessions touch the same surface, their work ends up in one of two buckets. Only one of them is currently caught.
Two sessions independently write the same fix.
- Git detects identical patches at merge/push time.
- "Already up-to-date" or merge conflict surfaces the collision.
- Worst case: a
chore(cleanup)commit lands twice on the same branch under the same name (e.g.#65/#73both titledfix(nav): … remove unused GroupMode, same branch, identical 1806-byte diff, 9h apart). Even then the damage is bounded — wasted review cycles, no broken behavior.
In practice this is rare in this repo. When the historical search turned up
near-duplicate commits, most were rebase-replay artifacts: the same logical
commit (same author_date, different commit_date and parent) lands on main
twice via two merge resolutions. That isn't duplicate authorship; it's the
same patch counted twice.
Session A polishes a surface; Session B replaces or removes that surface upstream without knowing. A's work lands, builds clean, ships, and dies within the hour.
The canonical example (2026-06-08 / 09 evening):
23:20 #305 lands — polishes sessions-view.tsx (+286 lines incl. tests)
23:58 ced714c — ChatSurface drops the SessionsView fallback
00:30 14466de — sessions-view.tsx, .css, polish test all deleted (-2132)
PR #305 lived for ~70 minutes. The polish ideas may have informed the
ChatList direction, but the actual code is gone from main. The author of the
deletion knew — they explicitly named "the chat-polish test that targeted only
SessionsView internals" as something to drop, meaning they were aware they
were reverting just-merged work.
Both sessions did locally-valid work. Neither did the other's work. The waste isn't redundancy; it's misallocation.
Duplicate work lives in the codebase. Two diffs against the same lines collide at commit/push time, and git refuses to silently lose data.
Orphaned work lives in intent. "Session B is about to delete this surface"
exists only in Session B's running context — never in the file tree, never in
the index, never in git status. Session A's diff against the soon-to-be-
dead file passes typecheck, passes the suite, builds clean, and ships. There
is no point in the standard developer loop where the fact of impending
removal becomes visible.
This means orphaned-work prevention can't be retrofitted via git or CI alone. The signal has to live somewhere both sessions check before committing to a direction.
Listed cheapest first.
Status: Built as an automatic PreToolUse hook —
scripts/surface-claim-guard.mjs, wired in .claude/settings.json (matcher
Edit|Write|NotebookEdit). It removes the "nothing forces sessions to write
claims" fragility below: claims are now a byproduct of editing, requiring zero
discipline.
On every Edit/Write to the shared primary checkout, the hook:
- records this session's claim on the target file (keyed by
session_id); - prunes claims with no activity in the last ~2h;
- if another live session already claimed that exact file, surfaces a collision
warning to both the user (
systemMessage) and the model (hookSpecificOutput.additionalContext).
It is advisory only — it never blocks or fails an edit (always exits 0, even
on corrupt input). Edits inside .worktrees/ are skipped: those sessions are
already isolated, and their divergence surfaces at PR/merge time (see §2). The
warning fires for the second session to touch a file — exactly the moment a
clobber would otherwise happen silently. Covered by
scripts/surface-claim-guard.test.mjs.
A flat file recording "Session X has begun work touching surfaces Y and Z." Updated when a session starts a non-trivial task on a surface; cleared on session exit or when the task lands.
Before starting work on a surface, a session greps claims for collisions. Before committing, a session re-checks for surface overlap and surfaces the collision to the user.
Fragile because: nothing forces sessions to write claims, claims go stale, and the file becomes another source of merge conflict if checked in. Mitigations: keep it gitignored (so it doesn't conflict), expire entries older than ~2 hours, and treat the absence of a claim as "no claim made" rather than "no work in progress."
This is the cheapest first move and the most likely to actually get adopted.
CLAUDE.md already documents the worktree convention. The orphaned-work case
above happened on the primary checkout with 5+ sessions sharing it. If each
session had its own .worktrees/<branch> from the start, they'd still have
been writing to the same main eventually — but the divergence would have
surfaced at the merge/PR moment, not at the silent deletion moment.
Worktrees don't prevent orphaning; they make it visible earlier. A gh pr create for a worktree's branch surfaces the diff against current main
before merging, which is the first moment another session's deletion would
show up as a conflict or a stale-context warning.
The sidebar-minimal.test.ts update in PR #298 is a good pattern: when
removing a surface, leave a negative-import sentinel test that asserts the
file isn't reintroduced. Future sessions running the suite see the assertion
break the moment they recreate the surface — even if they don't read commit
history. This works after the orphaning has happened but at least prevents
re-orphaning the same surface twice.
Companion to this: when adding a new surface that's likely to be touched by parallel sessions (large component, IA-load-bearing), drop a positive-import sentinel in the same test that says "X must still be the consumer of Y" so that someone removing Y notices Y has a known consumer.
A pre-commit hook that pings a shared file/socket announcing "session X is about to commit Y" and waits N seconds for objections. Catches the most adversarial cases but adds latency to every commit and forces network/IPC plumbing. Mention here for completeness; don't build it unless the cheaper options have demonstrably failed.
Duplicate and orphaned work are the slow failure modes. The fast one is
destroyed work: on 2026-07-03 an actor found another session's in-progress
worktree, pushed its (unpushed) commit, merged it as PR #2290, and ran the
standard post-merge cleanup — git worktree remove + git branch -D — while
the owning session was mid-edit. Every uncommitted change was lost. The same
gutted-worktree "husk" pattern (only a .next/ or tsconfig.tsbuildinfo
recreated by a process still running inside) had already hit two other
worktrees on prior days. A sibling incident (#2286) chained
git push origin --delete after a merge that had actually failed, which
auto-closed the still-open PR.
The guard is a PreToolUse hook on Bash (wired next to the surface-claim
guard in .claude/settings.json) and — unlike the claim guard — it blocks
(exit 2), because these ops destroy unrecoverable state:
git worktree remove <path>/rm -rf .worktrees/<name>(the worktree root; deeper paths are the owner's business) is blocked when the worktree is dirty or its HEAD exists on no remote ref. Husks (no.gitlink) and clean+pushed worktrees pass silently, so normal post-merge cleanup and husk GC stay frictionless.rm -rf .worktrees(the whole container) checks every child.git branch -D <name>is blocked when the branch tip is contained in no remote-tracking ref (deletion would orphan unpushed commits).git push <remote> --delete <branch>(orpush <remote> :<branch>) is blocked while an open PR still has that head branch. Needsgh; fails open offline.
Deliberate destruction: prefix the command with WT_GUARD_BYPASS=1 — the
guard only ensures it can't happen by accident. The remote is the only store
a local actor can't destroy, so unpushed commits are the work most at risk.
scripts/worktree-retention-push.mjs (§6) now pushes them automatically, as
the branch itself or as a retention/<slug>-<sha> tag. That hook doesn't grant
push authority: whether a session may push otherwise is set by AGENTS.md and
the current request.
Known hole: the guard only covers actors that run through Claude Code hooks. The 2026-07-03 actor left no session transcript (likely a familiar/automation running outside the hook system) — for those, the pushed-branch discipline and the branch-protection rules are the only backstops.
Audit log (cave-boor8). On 2026-07-29 .worktrees/probe-timing was
destroyed holding 3 uncommitted files, and the post-mortem could not tell a
deliberate WT_GUARD_BYPASS=1 from a hole in the guard — the bypass path left
no trace. The guard now appends one JSON line per bypass and per block
to .claude/worktree-guard-bypass.log (gitignored): at, verdict,
session, cwd, command, and for blocks the reason. Read it as evidence,
in three tiers: a bypass entry names a deliberate override; a block entry
followed by the worktree's disappearance names an actor that retried outside
the hook; and no entry at all means the destruction never routed through
the Bash tool — an external terminal, editor, or automation, which no
PreToolUse hook can see. That last tier is the one the pushed-branch
discipline exists for.
Audits record paths, not counts (cave-boor8's other lesson). probe-timing
was unrecoverable even in principle because the sweep that flagged it logged
only dirty=3 — once the directory was gone there was no filename or content
signature to match git fsck debris against. Any sweep or audit that touches
a dirty worktree must capture the full git status --porcelain output (paths,
not counts) in its notes or issue. Three lines of porcelain is the difference
between "recovery impossible" and a targeted lost-found search — and for
UNSTAGED edits (which never enter the object store) it is the only record that
the work existed at all.
.claude/settings.json wires two more advisory hooks next to the surface-claim
and destructive-op guards. scripts/worktree-autolock.mjs (PreToolUse) locks
worktrees that hold work a removal would destroy, so an actor outside Claude
Code, such as GitHub Desktop, can't remove them with a single --force.
scripts/worktree-retention-push.mjs (PostToolUse) pushes commits that exist on
no remote: as the branch itself, or as a retention/<slug>-<sha> tag when the
branch's upstream was deleted. Both exit 0. Neither merges, opens a PR, deletes
a branch, or grants ownership.
Every guard above assumes the collision happens locally — worktree-guard blocks destruction, surface-claim-guard warns on co-edited files, and the sections above cover duplicate and orphaned work. A fourth failure mode happens entirely on GitHub, where none of them can see (observed 2026-07-29, cave-pimgr):
- Session A commits on
feat/grant-audit-consoleand pushes. - Nine minutes later a different actor opens a PR on that same branch — same commit, same title — and merges it. The repo's auto-delete setting removes the head branch.
- Session A's own
gh pr createnow fails withNo commits between main and feat/grant-audit-console.
Nothing was destroyed and no file was co-edited, so no hook fired. The cost
came from misreading the aftermath: session A interpreted the (correct)
gh error as branch corruption, "recovered" a branch that needed no recovery,
and opened a duplicate PR; a second session doing unrelated GC found the
branch gone and independently wrote "another session deleted work" into its
summary — which session A then treated as corroboration. Two agents inferring
destruction from identical ambiguous evidence is not confirmation. Net: a
false destructive-actor incident report, a duplicate PR, and an hour of
investigation. Across the preceding 200 PRs, four head refs carried more
than one PR — rare, not unique.
Two habits kill this failure mode outright:
- Before
gh pr create, check whether the branch already has one:gh pr list --state all --head <branch>. If a PR exists — open or merged — report it instead of creating a second. One command ends duplicate publication entirely. - Treat
No commits between main and <branch>as a SIGNAL, not an error. It usually means the work is already ON main. Before concluding anything was lost, verify in two steps:git merge-base --is-ancestor <your-sha> origin/mainanswers yes for merge/fast-forward landings, but a SQUASH merge — this repo's default — rewrites the sha, so on "no" continue withgit cherry origin/main <your-sha>(a leading-means a patch-equivalent commit is already upstream) andgh pr list --state merged --head <branch>(a merged PR on the branch IS the landing record, whatever the merge style). Any one of the three answering "landed" means the branch deletion was routine post-merge cleanup. That check would have ended the 2026-07-29 incident in seconds.
The corollary for the reading side: a vanished branch plus a merged PR containing your commit is the signature of completed publication, not destruction. Check the PR history for the branch before writing "another session deleted my work" into any summary — the next session will believe you.
A heavier option — a branch-claim mechanism mirroring surface-claim-guard so a session can see another owns a pushed branch — remains unbuilt; the two habits above have so far been sufficient.
Signals 1, 5 and 6 are built. Alongside them, sessions should:
-
Check live sessions before non-trivial work.
ps -ef | grep ' claude --'pluslsof -p <pid> | awk '$4=="cwd"'shows who's where. Five sessions in one cwd is the smell. -
Diagnose before risky git operations. Already in CLAUDE.md, but worth re-emphasising: rebases, cherry-picks, and
reset --hardon a shared checkout race with concurrent work. The 2026-06-08 incident where another session'sreset --hardwiped uncommitted edits is the canonical example. -
When polishing a surface, briefly check
git log -pon its consumers. If the consumer's recent history shows it's being decoupled from your surface, your polish may be about to be orphaned. A 30-second check, not a full audit. -
Surface intent to the user when starting structural work. "I'm about to delete SessionsView" is the kind of statement that, if surfaced to the user, gives them a chance to say "wait, another session is polishing that." The user is the only entity with cross-session visibility today.
-
Prefer worktrees for any task that involves more than three file edits or any structural change. The worktree itself is a cheap signal —
git worktree listis one of the only places where "Session B is working on branch C" is visible to Session A. -
Check the PR layer before publishing and before crying destruction.
gh pr list --state all --head <branch>beforegh pr create; and onNo commits between main and <branch>, verify the work landed (merge-base --is-ancestor, thengit cherryfor squash merges, thengh pr list --state merged --head) before concluding anything was lost. See "The PR layer" section above for the incident that made both habits policy.
- Does the advisory claim hook (§1) catch enough, or is the unbuilt branch-claim mechanism still needed?
- Does any of this generalise beyond Claude Code? The same failure mode could exist for human pair-programming sessions sharing one checkout, but humans usually have a Slack channel.
- The user is currently the cross-session bridge by hand ("session A is doing X, session B is doing Y"). Does any of this make their job easier or just relocate the bookkeeping?
Patterns observed via git log on origin/main over the 2026-05-30 →
2026-06-09 window. The orphaning case (PR #305 → 14466de) is reconstructable
from commits alone; the duplicate-work false positives required diffing
author_date vs commit_date on near-duplicate subjects. No prior cross-
session coordination doc existed in this repo when this was written.
{ "ttys029": { "started": "2026-06-08T23:24Z", "surfaces": ["src/components/inbox-escalations-view.tsx"], "intent": "feat: severity rails + filter chips + bulk actions" }, "ttys044": { "started": "2026-06-08T23:55Z", "surfaces": ["src/components/chat-surface.tsx", "src/components/sessions-view.tsx"], "intent": "refactor: dedupe to single chat list, then delete SessionsView" } }