Cross-session coordination for independently-launched Claude Code sessions
Summary
Heavy users who run many independently-launched Claude Code sessions against one repo with one shared working tree have no first-party coordination story. The only real primitive is a PreToolUse deny hook — a build-it-yourself kit — and that kit has silent holes (below). I spent a day measuring what actually collides between sessions on my own machine and building guards for it; sharing the findings because one of them would change how a first-party version should be built.
Setup: solo dev, Windows 11, one Python repo, ~20 git worktrees, routinely 15–20 concurrent sessions (VS Code extension + Desktop app).
Caveat, stated up front: this is one machine, one repo, one unusually parallel developer. The measurements are real but n=1. Offered as evidence, not a general claim.
---
The finding I'd most want you to see
If you ship a "don't build in the shared checkout" guard, key it on the write's TARGET PATH, not on the session's cwd. The obvious design is wrong.
Measured over 30 days of my own transcripts: 166 sessions ran with cwd = the shared primary checkout. Of their 13,782 Edit/Write calls:
| Writes | Share | What it is |
|-------:|:-----:|------------|
| 6,075 | 44% | wrote into the primary's tree — the actual problem |
| 4,010 | 29% | wrote into a worktree by absolute path — already correct |
The second row is the trap. Nearly a third of writes come from a session sitting in the primary that correctly writes into a worktree by absolute path. A cwd-keyed gate can't tell those apart and would have denied all 4,010 of them. Where a session sits is irrelevant; only where it writes matters. I got this wrong on my first design — and so did every design I generated before measuring.
Same theme, secondary: a deny that just refuses causes the model to thrash or route around it (e.g. shell redirect). A deny that names the exact command to run causes self-correction — in a live test under bypassPermissions, a blocked session ran the worktree-creation script itself and landed its edit in the new worktree without being told to.
---
Bugs / silent failures (these cost me the most time)
These are why the DIY PreToolUse approach is unsafe today — they share a theme, enforcement that can be off without saying so. The first two are filed as a companion bug report with full repro: #76726.
- A subagent's permission denials don't surface to the parent — parent result comes back with
permission_denials: []while the fan-out wrote nothing. Fails silent, not safe. (#76726) - A subagent's hook payload carries the PARENT's
session_id— session-keyed locks/claim registries are silently broken for subagents. (#76726) - Malformed hook output silently no-ops. A bare
{"permissionDecision":"deny"}without thehookSpecificOutputwrapper does nothing — the tool runs, no warning. (Filed as #4669 / #37210, closed as not planned.) "Your guard is off and nothing tells you" is a bad default. Related: a hook whose script path is missing exits non-zero-but-not-2, which also lets the tool run silently. - Docs gap, load-bearing: the docs don't state whether hooks fire for subagent tool calls, or what
session_idthey carry. That single fact determines whether any hook-based enforcement has a hole in it. I had to determine it empirically.
---
The gap itself
Your docs are the evidence, and they're clear: worktrees isolate file edits; subagents and agent teams coordinate work. But agent teams are one-team-per-session, can't be joined by independently-launched sessions, and give teammates no worktree isolation (they share the lead's cwd — the guidance is "partition the work so each teammate owns a different set of files").
So the case heavy users actually hit — many independently-started sessions, one repo, one shared working tree — has no first-party story. The only real primitive is PreToolUse deny.
Collisions that bit me, none of which any current feature sees:
- Sessions building in the shared primary checkout instead of a worktree (the 44% above).
- A session running
git checkout <its-branch>in the shared primary and detaching HEAD — swapping the entire working tree out from under every other session mid-task. Hooks can't see this at all: it's a shell command, so there are no tool arguments to inspect. Happened to me twice in one day. - Two sessions independently allocating the same "next free" ID in a shared ledger file: different filenames, merges clean, silently corrupt. No lock, no worktree, and no merge-conflict prediction catches this class.
---
What I'd ask for, in priority order
- Fix the silent failures above (esp. #1 and #3). Enforcement that can be off without saying so is worse than none.
- Document the subagent hook semantics (do hooks fire for subagent tool calls? what
session_iddo they carry?). - A first-party notion of "this checkout is shared; sessions should not build here" — keyed on target path, with a deny message that tells the model how to proceed.
- Longer term: let independently-launched sessions see each other. Even a read-only registry ("these 6 sessions are live, here's their cwd and branch") would let users build the rest.
Showing cached comments. Read the full discussion on GitHub ↗
8 Comments
The target-path vs. cwd insight is important and undersold. The 44%/29% split you measured - where nearly a third of writes go to worktree paths from sessions sitting in primary - means any cwd-keyed gate is wrong by design, not just buggy implementation.
The silent failure modes you list in #76726 are the harder part. A hook that silently no-ops on malformed output means you cannot trust the guard is actually running without instrumenting it externally. That's a rough spot to be in when you're running 15-20 concurrent sessions.
On the "live session discovery" idea: I've been working in this space and the minimum viable version of that turns out to be surprisingly useful even before you build any first-party support. A simple file at
.claude/sessions.jsonthat each session writes its pid, cwd, and branch to on startup (and removes on clean exit) gives you enough for a coordinator to know what's running. Orphaned entries from unclean exits are discoverable by checking if the pid is still alive.The harder problem is the one you flagged: independently-launched sessions vs. agent teams are different coordination surfaces with no bridge between them. Worth filing that as a separate issue if it isn't already tracked somewhere.
The target-path-not-cwd finding is right, and the 44/29 split is the cleanest evidence I've seen for it. Where a session sits is irrelevant. What it writes to is the only thing that matters. A coordinator should key on the write target the way you'd key a cache line on the address, not on which core issued the write.
The collision your target-path guard still won't catch is the shared-ledger ID case, and it's worth separating out. Both sessions read the ledger's "next free" value, both allocate, both write to different filenames. Nothing collides on the path, so a path-keyed gate has nothing to catch, and the merge is clean. The real issue is temporal: two writers derived a value from the same version of the ledger and neither saw the other's increment. Catching it means tracking the ledger's version and denying a write computed from a version that has since moved, which is a different axis from "who is allowed to write here." Path-keying catches the spatial collisions. The ledger case is a temporal one.
Your silent-failure #2 is the load-bearing one for any coordination layer, first-party or not. If a subagent's hook payload carries the parent's session_id, then every session-keyed lock, claim registry, or MESI-style ownership map is silently wrong for subagent writes. It's a correctness floor, not a tuning knob. Any coordinator built on session identity, including the sessions.json registry kcarriedo described above, inherits that hole until the subagent payload carries its own id.
For what it's worth, I've been building exactly this coordinator: a local process that tracks per-artifact version and per-session ownership across independently-launched sessions and denies a stale write at the hook boundary with a message that names the reacquire step. It hits the same two walls you found. It can't see a raw git checkout that swaps the tree, since there are no tool args to inspect, and it's subject to the subagent-session_id issue until that's fixed upstream. It does catch the stale-ledger case, because it keys on artifact version rather than path. Repo is github.com/hipvlady/agent-coherence if it's a useful reference for what a first-party version would need to handle. The read-only registry kcarriedo sketched is the discovery half, this is the enforcement half, and they compose.
This issue maps closely to something I have been measuring while building a multi-session coordination layer on top of Claude Code. The write-target vs. cwd distinction you identified is the exact design flaw that caused silent corruption in my own setup before I switched to path-keyed locks.
A few things that matched your data:
The 44% primary-tree write rate is consistent with what I saw. Sessions sitting in the primary checkout do legitimately need to write into worktrees by absolute path, so any gate that keys on cwd will either block valid writes or miss invalid ones. You need the actual target path at hook evaluation time.
On silent subagent hook failures: I can confirm the session_id inheritance issue. When a subagent fires a tool, the PreToolUse hook receives the parent session's session_id, which breaks any session-keyed lock map. The safe approach is to key your lock on a combination of the process group ID plus the resolved write target -- that survives subagent spawning without needing Claude to expose a subagent-specific identifier.
One mitigation that helped me while waiting for first-party fixes: instead of blocking at the hook level (where silent no-ops are a real risk), write a lightweight file-based "active region" registry. Each session claims its worktree at startup by writing a lock file with PID + worktree path. A pre-commit hook (not PreToolUse) then checks for conflicts before any write lands in git. It catches less than a path-aware PreToolUse hook would, but it has zero silent-failure modes because git itself surfaces the conflict.
On the session registry request (item 4): even a read-only file that each session writes on startup and deletes on exit would cover 80% of the use case. The hard part is making it reliable across crashes -- a PID-based liveness check on startup cleans up stale entries.
Interested to see what Anthropic does here. The 30-day measurement data you have is the right kind of evidence to drive this forward.
The four failure modes you documented (shared-checkout writes, silent enforcement, HEAD-swap mid-task, ledger ID collisions) are the same class of problem we keep seeing in production multi-session setups. The common thread is that Claude Code has no runtime model of "other sessions exist" -- it can only see what is on the filesystem right now, and it has no way to distinguish its own prior writes from another session's concurrent writes.
The point about enforcement needing to be informative rather than just blocking is worth emphasizing. A hook that denies a write with "blocked" causes the model to retry or route around it. A hook that says "write to worktree-session-42 instead, run this command to enter it" causes self-correction. The difference in agent behavior is significant in practice.
The independently-launched sessions case (many sessions, one repo, no shared parent) is the gap that keeps coming up. Agent Teams solves a different shape of the problem (one lead, known teammates). For the power-user case -- 15-20 concurrent sessions started at different times, against one shared tree -- there is no first-party story.
I'm building something in this space (coordinator layer for multi-session Claude Code, out-of-process state tracking). The pattern we settled on: a lightweight process that owns a session registry and a task ledger, each Claude session phones home via a hook, and the coordinator is the only thing that can hand out IDs and worktree assignments. It does not solve the hook semantics gaps you describe (those need Anthropic changes) but it eliminates the ID collision and multiple-sessions-building-in-shared-checkout problems. Happy to share what we found if useful.
The gap you're describing -- independently-launched sessions on a shared working tree with no first-party coordination primitive -- is exactly what heavy users keep hitting and duct-taping around. The PreToolUse deny hook approach works until you have more than two sessions and the interleaving gets subtle: detached HEAD from session A mid-task in session B, or ID allocation races that merge clean but corrupt silently.
Agent Teams solving a different problem (one lead spawns its own teammates) means users who naturally evolved toward your pattern -- "I'll start a session for each thing I'm working on today" -- have no upgrade path.
What coordination mechanism are you currently using? Watching for worktrees + file-based locks, or something else? We're building orchestration around this exact scenario and the design choices at this layer matter a lot.
Solid writeup. The target-path vs. cwd keying insight is the key one -- a session sitting in the primary but writing to a worktree by absolute path is doing exactly the right thing, and a cwd-keyed gate punishes it.
The silent failure modes you listed are the part that will hurt teams the most in practice. When enforcement fails without signaling, the model has no feedback loop -- it keeps doing the thing that looks like it worked, and the damage compounds before anyone notices.
A few things I've run into that might be relevant to your hook work:
The subagent hook payload carrying the parent's session_id is a real trap. If you are building any kind of per-session lock or registry, you have to decide up front whether you trust the session_id field or derive identity from something else (PID tree, a token you inject at spawn, etc.).
For the "first-party shared checkout guard" you're asking for in priority 3 -- one pattern that works today without first-party support: a pre-tool hook that reads a small coordination file (e.g. .claude-sessions/registry.json) and enforces write-target ownership. The hook can deny with a message that tells the model which session owns the path and to coordinate through a handoff file instead. It's not bulletproof against the session_id bug you found, but it surfaces the collision explicitly rather than silently.
The broader pattern here -- agents need a read-only view of sibling sessions, with ownership semantics on working paths -- is something we're thinking about too. Happy to compare notes if useful.
Follow-up from a second setup, on a different repo (static site, PowerShell hooks) rather than the Python one above. I rebuilt the coordination layer from scratch there and hit a set of primitives this issue doesn't mention — most usefully, a cross-surface session registry already ships, so the
.claude/sessions.jsonidea in the comment above may not need inventing.Same caveat as the original: one machine, one day,
2.1.219(a sibling session on the same box already reports2.1.220).There is already a session registry
~/.claude/sessions/<pid>.json, one record per live session. Every field I've observed across five records:Two things worth documenting:
procStartis .NET ticks in local time, and it answers "is this pid still the process that registered it?" On my sample it matched the live processStartTimeto within 4 ticks (400 ns) — close enough that a sub-second comparison detects pid recycling reliably, instead of the coarse "did the process start after the session registered" heuristic I started with. Whether that 4-tick residual is a rounding artefact or real skew is exactly what documentation would settle; I'm currently guessing, and depending on the guess.peerProtocol: 1suggests a peer concept already exists or is planned. I couldn't find anything describing it.This registry is also the only place I could find that spans surfaces, which matters because:
list_sessionsdoesn't see every surfaceThe session-management MCP
list_sessionsreturned 4 sessions while the registry held 6. One of those two is the calling session, which is documented and expected — but the other was a liveclaude-vscodesession, simply absent. One missing surface is enough: "nobody else is here" is the wrong conclusion to reach confidently, and it's the conclusion a gate reaches silently.Two ID namespaces for the same session
The registry
sessionIdand theccd_session_mgmtid are different UUIDs for the same session. Two pairs from one machine:| registry
sessionId| MCP id || --- | --- |
|
93a1b2c3-…|local_93f4e5d6-…||
7d0e1f22-…|local_4c8b9a01-…|(Values illustrative; the first pair really did share a leading
93on my box, which is how I noticed.) Anything a hook can read is a registry id, so any hook-derived id handed tosend_messageorlist_eventsis well-formed and wrong. I haven't characterised what those tools actually do with one — I avoided the experiment rather than deliver a stray message — so I'll only claim the namespaces differ and that nothing in the tool surface distinguishes them. Returning the registry id alongside the MCP id, or rejecting a wrong-namespace id outright, would remove the class.Hooks can't call MCP tools, which caps what this approach can do
Detection works fine from a hook. Announcing does not. Telling peers "I just joined and I'm about to work on X" needs
send_message, which is MCP-only, so the best a hook can do is inject an instruction and hope the model follows it. That leaves the one direction that prevents duplicated work — telling others your intent before you start — advisory, while everything else is enforced. A structured "notify session X" result that hooks could emit would close it.Corroborating the target-path finding, from a different angle
The 44%/29% split above convinced me to key the gate on the write's target path, and that was right. Worth adding that cwd is treacherous for identifying the acting session too, not just the target. My first version inferred "which session am I?" from the working directory with a prefix test — and it broke because harness worktrees live under the primary checkout, so "am I inside that worktree?" is true for the primary from anywhere. Two consequences: a session working in the shared checkout could never trigger a denial, and self-identification failed outright, so the session listed itself as a peer and then refused itself every file it had already touched. The deny message named the victim as the culprit.
The fix is the hook payload's
session_id, which is the same UUID as the registry'ssessionId— undocumented, but it's the only exact self-identifier available. Which makes #76726's second point load-bearing well beyond subagent locks: if a subagent's payload carries the parent'ssession_id, then that is also the only reliable way to identify the acting session, and it's wrong in exactly the case where fan-out makes collisions most likely.One more, small but sharp: a liveness result you can't verify must not block. If a pid is alive but its start time is unreadable — a recycled pid now owned by a process in another security context — that state never clears, so blocking on it blocks those files permanently. Report it, don't act on it.
Also worth a doc line
Harness worktrees are created at
.claude/worktrees/<name>. That pushes repos to gitignore.claude/wholesale, which collaterally discards the committable.claude/settings.jsonthat would otherwise let a repo carry its own hooks — so everything ends up wired per-machine at user level instead. Either putting harness worktrees outside.claude/, or documenting the interaction, would help.<details>
<summary>Implementation pitfalls (not product issues — bugs in my own guards, listed in case they save someone a day)</summary>
All found by running the thing, not reading it.
mainadvanced. In-flight is the intersection: authored on the branch and still differing frommain. Self-clears on squash, rebase and merge-commit alike. A second setup confirmed the same bug and found a worktree claiming 16 phantom files.String.GetHashCode()is randomized per process on .NET Core. As a cache key that means a different filename every invocation: 0% hit rate, one orphan temp file per edit. I collected 169 in ~25 minutes before noticing.git status --porcelaincollapses an untracked directory into one entry, hiding every file inside a peer's brand-new directory — exactly when they're building something from scratch. Use-uall.</details>
Happy to share the implementation if it's useful — a few hundred lines with a regression suite that drives the real hooks against a fixture registry, including a fabricated session in the shared primary.
One more, narrower than the last and pointing at the same principle from a layer lower down.
**The target-path argument applies to hook resolution, not just to the deny decision.**
The finding at the top of this issue is that a guard must key on the write's target path rather than the session's cwd. That's about what the hook decides. But which hook runs at all is also resolved from cwd, and that's a trap for any hook installed as a repo-relative shim.
Concretely, from a session whose cwd is in repo A, writing an absolute path into repo B:
pwsh -File ~/.claude/hooks/my_gate.ps1) executes normally and sees the real target. Verified: a shared-checkout guard wired this way did correctly deny a cross-repo write into another repo's primary — reported by a second session on this machine, which is what narrowed this finding from the wrong, broader version I first wrote.git rev-parse --git-common-dir→ run<repo>/scripts/hook.ps1) resolves against cwd, so it loads repo A's copy. Repo B's hook never executes, and repo A's hook correctly concludes the target is outside its scope and allows.Measured on this machine, from a website-repo worktree writing to
…/MessageFoundry/docs/ARCHITECTURE.md:The engine's own collision gate was never consulted. Neither hook is buggy: one wasn't loaded, the other answered correctly about a file it has no business judging. The gap is between them, and it is silent in exactly the way the rest of this issue is about — no error, no log line, just an allow.
Of the four
PreToolUseentries in my~/.claude/settings.json, three are absolute-path installs and one is a git-resolving shim, so on this machine the same write is simultaneously covered and uncovered depending on which guard you ask. Worth knowing that the shim pattern has this property, since "no installed copy, sogit pullupdates the hook everywhere" is otherwise a good reason to prefer it — it's the pattern I'd have recommended before measuring this.Not asking for much here: a line in the hooks docs noting that a repo-relative hook command resolves against cwd and therefore won't fire for writes outside that repo. If tool-call routing ever gains a notion of "which project does this target belong to", that would close it properly.
Small correction while I'm here: my earlier comment said the harness's session registry omitted a VS Code session from
list_sessionswhile the on-disk registry had it. That still holds — but I should have been clearer thatlist_sessionsalso excludes the calling session by design, so the honest gap is one session, not two.