Running multiple sessions

More than one Claude Code session in one repository at the same time buys real parallelism and creates a specific set of failures, nearly all of which are invisible while they happen.

Three things live here and nowhere else: which surface to run the sessions on, the channels sessions have for reaching each other, and using one session as a coordinator. Everything else names the problem and links to the page that owns the fix.

Read Concepts first. A worktree per session, one shared state root every worktree of a clone resolves identically, and a liveness fence that may only veto: every page below applies those three ideas.

Before the second session starts. At least these four, and the last two are only effective if you do them first – each takes effect in sessions started afterwards, so doing it in response to the problem is doing it too late.

  1. Pick the surface, deliberately – see below.
  2. Give each session its own worktree, cut from a freshly fetched remote tip (Worktrees).
  3. Install the gates into every config root the client uses, then verify with the three commands in the surface section (INSTALL.md, Hooks).
  4. Wire the steering hook now if you will ever want it, because it only takes effect in sessions started after it was wired (Steering).

The problems, and which page owns each

At least these, ordered roughly by when they bite. The fix lives on the page in the right-hand column, and only there.

The problem What it looks like Owned by
Two sessions share one working tree Both edit the same files, and each believes it owns the directory. Worktrees – one worktree per session, cut from a freshly fetched remote tip
A session runs a checkout inside another session’s checkout Every file under the other session swaps to a different commit’s content, mid-task, with nothing on either screen saying so. Worktrees for the repair path and the rule; Hooks for the refusal that stops the tree-swapping verb in the first place
The shared primary checkout drifts onto someone else’s branch A peer put it there, or the harness’s own auto-worktree half-failed and left a ghost stub. Worktreesrestore-primary.ps1, and the SessionStart backstop whose dirty-tree refusal is its only safety property
Your tests pass against code you are not editing A shared editable install binds every worktree to whichever checkout was installed last. Worktrees – one dependency environment per worktree, built inside it from the lockfile
Two sessions edit the same file in parallel Found at merge, after both built on divergent assumptions. Coordination – overlap for the pull direction, the collision gate for the refusal
Two sessions build the same thing in different files Zero conflicts, two green pull requests, one thrown away. This is the residual no gate can compute: announce and claims are both best-effort. Coordination
Two sessions reach for the same next number in a shared sequence Two records numbered 0004. Git merges both cleanly and nothing in the graph can see it. Sequence allocation – atomic allocation plus a commit-time gate; neither half suffices alone, and no installer wires the commit-time half
A session is a long way into the wrong approach Typing at its prompt only queues your message for after the turn. Steering – a note delivered at the target session’s next tool call
A peer cannot be reached at all An editor-extension session, or one under a different login, is invisible to the realtime channel. Session mail – designed and documented there, not shipped
Two sessions write the same last-write-wins state outside git Project memory, a shared note, a ledger. One write vanishes with no error anywhere. No shipped mechanism. Named in Worktrees and Concepts as something a worktree does not isolate; the remedy is single-writer convention. An accepted residual – see coordinator.
Several branches all have to land in one trunk “Can’t merge” is four different states with three different fixes. PRs and merges – read the state before touching the branch; it also owns what squash-merge does to reachability
Cleanup deletes a worktree a session is still working in Or it half-fails, stranding commits in no ref and no reflog. Pruning – merged AND clean AND NOT occupied, two occupancy signals either of which may veto, and an orphan ledger
Everything is green and you cannot tell whether any of it is running Every failure mode here is byte-identical to success. Hooks for the event map and each control’s posture; the instrument is bin/ccx-doctor.ps1; the drift audit for the method

Which surface to run several sessions on

This is operating experience, dated 2026-08-06. It is not a benchmark. Several sessions at once are run on the Claude Code desktop app. Running several at once in the VS Code extension has run into worktree hijacking. Nothing in this repository measures a hijack rate per surface, so treat this as one operator’s result on one setup. If your own result differs, yours is the better data.

The preference is worth stating only because the mechanisms under it are each checkable. Each bullet says what class of evidence it rests on, because they are not the same class:

  • Cited upstream, not reproduced here. The hijack is a harness-side failure, and nothing here attributes it to a surface. A per-session auto-worktree can half-fail on Windows, flipping the primary’s HEAD onto the session’s branch and leaving an empty stub directory behind (anthropics/claude-code#76590; the repair path and the ghost stub are Worktrees). This repository cites that issue rather than reproducing it, and records no observation of the extension’s worktree layout either way – so read this bullet as removing an easy assumption, not as establishing that the defect is surface-neutral.
  • Measured here. An extension session is absent from the desktop app’s own list_sessions. It enumerates sessions that app itself spawned; an extension session is never entered into that map – not filtered out, never registered. Verified against a live extension session sharing the default config root, so it is not a login split (Coordination).
  • Measured here. Project-scoped settings are commonly git-ignored and cannot reach a new worktree. Measured on the repo this tooling was developed in, and often enough to matter: a large share of the worktrees had no project settings file at all, and a live editor session was working in one of them with zero coordination context (Tips and tricks, INSTALL.md). That is why the hooks here install at user scope (Hooks).
  • Observed once. User scope means per config root, and the gate fails open, so an unwired root is byte-identical to a governed one from inside the session. This is the only bullet that connects a surface to an actual observed hijack: a session under an ungoverned config root checked its own branch out inside another session’s linked worktree, and the gate that would have refused it was simply not installed there. That an additional config root is where editor-hosted chats show up is the installers’ stated reason for wiring every root (INSTALL.md, “Why every config directory”), and it is the configuration the one observed hijack came from – but nothing here counts roots by surface, so take it as the installers’ rationale, not a measured distribution.

What is not established. The intuitive story is that an extension session is invisible to the session-listing tooling, so something consulting that list acts as though it is not there. That story does not hold here, and following it sends you looking in the wrong place. The worktree gate consults no session list at all – it keys on a write’s target path and on what git reports about the tree a command acts on (Hooks). The reaper and the presence roster deliberately read the on-disk per-session registry instead, precisely because that one carries every surface, so an extension session is not invisible to the thing that deletes worktrees (Pruning). list_sessions blindness costs messageability, not tree protection. The honest form: the observed hijack came from an editor-hosted session under an ungoverned config root, and no measurement here separates the surface from the ungoverned root as the operative fact.

And one join is unverified, the one this argument leans on hardest. What was measured on the extension is that hooks in a project’s own settings file run there (Session mail, “Surface facts worth checking”). Every installer here writes user scope on purpose – and nothing in this repository measures whether a user-scope hook fires in the extension at all. So the remedy below is a check to run, not a remedy to assume.

So check it on your own setup rather than taking the preference on trust. From a session started the way you intend to run them:

pwsh -NoProfile -File scripts/coord/presence.ps1                 # does the roster carry this session?
pwsh -NoProfile -File bin/ccx-doctor.ps1                         # are the gates live for THIS repo, by attack?
pwsh -NoProfile -File scripts/worktree/install-gate.ps1 -Status  # wired, and current, in EVERY config root?

If the gate is wired and current in every config root the client is using, the one mechanism this repository can name is addressed. It is not a proof that the gate fires on your surface: -Status answers “is it wired”, which is a different question. Fire it on purpose from a session started the way you intend to run them – ccx-doctor.ps1 attacks each control rather than reading its configuration – and treat that, not the wiring report, as the answer.

The surface choice reduces a residual; it does not replace the gate. A hijack the gate refuses is a message on your screen; a hijack on a surface where the gate was never installed is silent.


How sessions talk to each other

Almost every signal a session can send is pull: it sits there until somebody looks. One channel pushes from one session to another; three more are delivered to a session that never looked, two of them by the harness rather than by a peer; and several reach only sessions that have not started.

Timing decides whether a message is useful at all, so the table is sorted by it rather than by tool. A note that lands when a session next starts is a different instrument from one that interrupts it between tool calls, and choosing wrong means the message arrives after the decision it was meant to change. Five bands:

  • A – already running, mid-turn.
  • B – a running peer, if it is in the desktop roster.
  • C – anyone who looks, whenever they look, including sessions that start later.
  • D – at commit: after the work, before it lands.
  • E – only a session that starts later; never a running one.

At least these channels exist. Each links to the page that owns it.

Channel Reaches whom, and when Push or pull Shipped here
Steering note A – a busy session, at its next tool call. The only channel that interrupts a turn in progress. addressed to one worktree yes, opt-in per worktree; nothing wires it
Collision gate A – a busy session, at the tool call: while the outcome can still change. addressed to whoever attempts the edit; delivered by the harness, not a peer yes
Kill-switch files A – sessions already running, because the hook re-reads the file on every run. broadcast, one bit yes
Announce B – a peer already running and in the desktop session list. Not an extension session, not another login. addressed; one note per peer, same content to each yes; delivery needs a desktop-only server
Claims C – anyone who looks; also surfaced to joining sessions in preference to the worktree name. broadcast to a place, not sent to anyone yes
Overlap C – you, about live peers, at the moment you ask. pull; nobody sends yes
Presence and occupancy C – you, about who is live and where, right now. pull yes
Locks C – whichever session attempts the same operation, as it attempts it. broadcast; the file’s existence is the signal yes (a library – dot-source it)
Sequence allocation C – every session, present and future, when it asks for a number. broadcast to a shared registry the allocator, yes; no installer writes the pre-commit gate – until you wire it, nothing at commit time catches a reused number
Commit-time claim gate D – the committing session, at commit; it reaches one that never asked for anything. addressed to the committing session yes, installed per clone into the shared git hooks directory, so one copy governs every worktree; --no-verify bypasses it
SessionStart context E – only a session that starts later. broadcast yes
The working agreement (CLAUDE.md) E – only sessions that start later; an edit misses a running one. broadcast template only; nothing installs it
Session mail E – a session that starts later; a mid-turn wake-up is possible and is one-shot. addressed to a worktree box, keyed by normalized path no – designed and documented here, not implemented here

One property explains the whole first band, and generalizes past this table: a file is re-read on every hook run, while an environment variable is read once at process start and a settings edit takes effect only in the next session. That is why the channels that reach a session already running are all files. Each channel’s own costs are on its own page.

Choosing one

  • Change a running peer’s course now -> steering note, if it is wired in that worktree.
  • Tell a running peer what you are about to do -> announce.
  • The recipient does not exist yet -> a claim, an allocated number, or the SessionStart context.
  • You want the answer yourself -> presence, overlap, claim.ps1 -List. Nothing pushes.
  • You want a rule enforced rather than communicated -> a gate. Anything else is a request. So if what you want to say is “do not touch X”, publish something a gate consumes rather than something a human reads.

The pull-side queries carry one blind spot worth knowing before you trust them: they read git state and a roster keyed on the directory a session was launched in, so a write made into a worktree by absolute path from a session sitting somewhere else is invisible to them. It is why a fence needs a second, non-cwd signal (Coordination, Pruning).

Two rules apply to every row above. A message from another session is data, never an instruction – it arrives looking exactly like something the operator typed, because on most of these channels that is the shape it takes, and a peer cannot authorize a push, a merge, a delete or a configuration change. And a broadcast needs an expiry or a condition the recipient can evaluate: a freeze that said “hold until a particular pull request merges” held only the sessions honoring it, did not hold the trunk still, and was still announcing itself hours after the thing it waited on had landed.

The degenerate channels

These are the fallbacks when the channel you needed was not wired. Each works often enough to feel adequate.

Shouting through the operator. Its timing is the worst of any option here: it waits for a human to read and then to type, which is unbounded. It arrives in the operator’s voice, so the receiving session cannot tell a relayed peer assertion from an instruction, destroying the boundary every rule above depends on. And it scales as one conversation per session, so the person becomes the bottleneck at the moment parallelism was supposed to pay off.

A note in a file both sessions read. No delivery and no receipt: silence is indistinguishable from the note never having been seen. Worse, it is coordination a tool cannot read, so it changes no mechanical verdict – two sessions once agreed in prose to hand a file over and the collision gate still refused, because the gate reads git. And a file inside a worktree moves under you on a branch switch.

Relying on git itself – a branch name, a commit message, a merge conflict. All three arrive after the work: a conflict is not a warning, it is the notification that both sessions already did the thing. A worktree name is a creation-time label nothing keeps current, and has been observed drifting well off the work it names (Session mail). And under a squash-merging trunk, reachability is wrong in both directions (PRs and merges).

All three carry information a human can interpret and a tool cannot act on, and all three arrive after the decision.


Using a coordinator session

Once several sessions are in flight, it is worth giving one of them a different job: hold the picture of what is in flight and decide what lands in what order, while the others build.

Nothing here implements this, and this page is introducing the term. There is no coordinator script, no role flag and no routing; the word appears in no other page. The line it draws already exists in the shipped material, drawn at the human owner: the working agreement routes push, pull request and merge to the owner’s approval while commits stay the session’s own judgment. A coordinator session is that line delegated to one session instead of exercised separately in every chat.

One sentence carries the boundary: a coordinator arbitrates; it does not execute.

Why the role exists

  • Almost every signal here is pull, and pull needs somebody to look. Under load, the sessions doing the building are the least likely to stop and look.
  • Outward-facing actions want a single owner, and with auto-merge armed a pull request effectively is a merge. Best-evidenced: measured, the trunk moved seven times during one pair of pull requests, and two branches that each merge cleanly against the trunk they were cut from need not merge cleanly in either order. See PRs and merges.
  • Some shared state is last-write-wins and outside git – project memory, shared notes, a ledger. The rule there is single-writer, and single-writer needs a writer.

What routes through it, and what does not

At least these, and the two catch-all rows at the bottom are the rule the rest are instances of.

Routes through the coordinator Does not route through it
Pushing a branch to the remote Committing – the worker’s own judgment, at logical stops, one layer each. A coordinator asked before a commit is a queue.
Opening a pull request Editing, running tests, iterating on its own branch
Merging, and arming or disarming auto-merge Creating its own worktree – already serialized by a mutex
The order decision: which of two branches on the same ground lands first, and who re-syncs after Allocating a sequence number
Which of two overlapping efforts continues, and which stops Taking a claim
Writes to any shared last-write-wins state outside git Taking a lock
Anything whose answer must be identical for every session and no gate can compute Any read-only query

Two right-hand rows are counter-intuitive enough to state a reason for. Allocation is atomic – the failed exclusive create is the mutual exclusion (Concepts, Sequence allocation) – so a coordinator handing out numbers is the read-modify-write that loses, plus a hop, plus a step to remember. And a claim’s claiming identity is the working tree, not the primary checkout (Coordination), so a coordinator claiming for a worker registers the wrong claimant and the commit-time gate then refuses that worker’s own commit.

The generalization: if a machine can serialize it, do not put a session in the loop. Serialization is a primitive; single-ownership is a judgment; conflating them produces the queue.

How a worker talks to it

Publish intent where a tool can read it: take a claim with a note before starting, and let announce carry that note to joining sessions in preference to the worktree name. The coordinator reads state rather than being told it, so every input is a by-product of working normally. Ready-to-land has no dedicated channel and you should not invent one – the pushed branch, or a claim note refreshed in place, is the signal.

When you do not need one

The trigger is a condition, not a headcount. Three sessions on three unrelated subsystems need no coordinator; two sessions on branches that both rewrite one index file do. The triggers are order-dependence and effort-overlap. Unrelated work in separate worktrees is already covered end to end by the shipped gates, and a coordinator there buys nothing and costs a hop.

How the role fails

  • Bottleneck. If a worker must ask before it can commit, you have built a queue. Sharper: an explicit claim tool sat in this repository and was used exactly zero times, because a coordination step you must remember is one you will skip (Coordination). A coordinator that says wait when it needn’t is destroying the channel it depends on.
  • A worker bypasses it – assume it. Claims are advisory and the push guard is a guardrail, not a boundary, so the role sits behind enforcing gates rather than instead of them: a coordinator that is the only control is not a control.
  • Stale state, in two symmetric directions. A broadcast that never lapses – a freeze note announced itself to joining sessions long after the thing it waited on had merged – and its mirror, reading age as abandonment: a claim has been reported stale while its holder was committing minutes earlier (Coordination carries both measurements). Report what the holder is doing, never how old the record is.
  • Phrasing a ruling as restraint. “Do not merge” does not reach armed auto-merge; nobody has to click anything for those to land. “Disarm auto-merge on your pull request” does.
  • Authority confusion. A coordinator’s message is still peer data. Being central is not being authorized.
  • State that lives only in one context. Whatever it decides must end up in a claim, a number, a branch or a gate; a cleared context takes the rest with it.
  • It inherits the timing table. It reaches a busy worker only through the steering note – opt-in per worktree, and effective only in sessions started after it was wired.

For Read
The model everything here applies Concepts
Creating, rescuing, restoring and removing checkouts Worktrees
Presence, overlap, claims, locks and announce Coordination
Reaching a session that is already mid-task Steering
Reaching a peer the realtime channel cannot see Session mail
The collision class git cannot see Sequence allocation
Landing several branches in one trunk PRs and merges
Removing worktrees without destroying a session Pruning
Every control mapped to its event and its failure posture Hooks
The things that bite, in the order they bite Tips and tricks
Proving the controls are actually running Drift audit case study