[BUG] --resume while the original process is still alive → two live processes of one session act on the repo concurrently (orphaned twin keeps working headless)
Disclosure: this report was investigated and written by Claude Code itself (the affected, resumed session), together with the user, who reviewed and filed it from his account. Happy to provide sanitized transcript excerpts on request.
Preflight
- [x] Searched existing issues — closest match is #26123 (closed,
/resumehistory inaccessible), which is a different bug. - [x] Single bug report.
- [x] Latest extension version at time of occurrence (VS Code extension 2.1.201;
claude --versionCLI reports 2.1.195).
What's Wrong?
Resuming a session (--resume <session-id>) while the original process for that same session is still alive results in two live processes of one session, both acting on the same workspace independently.
Observed sequence (macOS, VS Code native extension):
- A long-running session (a code-review agent) had armed a persistent background
Monitor(a git-HEAD commit watcher) and was running with permission modeauto. - The VS Code extension restarted its tabs. The tab came back as a new process launched with
--resume <same-session-id>. - The original process never exited. It survived headless under the same extension host — full context still in memory, its persistent Monitor still armed.
- From then on, both processes acted on the repo concurrently:
- The orphaned twin woke on every git commit (via its Monitor), wrote review blocks, committed, and pushed — with no visible tab anywhere.
- The resumed process (the visible tab) collided with it (
File has been modified since readon files the twin edited seconds earlier).
- Diagnosis was exceptionally hard because both processes append to the same transcript jsonl (same session id → same file). The resumed process saw the twin's tool calls interleaved into "its own" history and concluded it had done the work itself and forgotten (post-compaction amnesia). All transcript-based forensics self-attributed.
- The user finally identified the twin by committing a bait file asking "reviewer, who are you?" — the orphan's watcher fired, and it replied in a commit, self-identifying with the same session ID as the visible tab.
ps at the time (paths shortened; note the original with no --resume from a day earlier, plus the resumed twin):
PID 11462 started Jul 7 20:26 .../native-binary/claude --output-format stream-json ... (no --resume; ORIGINAL, orphaned)
PID 41523 started Jul 8 20:10 .../native-binary/claude ... --resume f075f82b-... (RESUMED, visible tab)
Both wrote to ~/.claude/projects/<project>/f075f82b-....jsonl.
Impact: autonomous duplicate work (duplicate commits/pushes to a shared repo), file-edit collisions, and near-impossible attribution. With permission-mode auto + a persistent Monitor, the orphan is effectively a self-driving headless agent the user cannot see or stop from any UI (they had "3 tabs open" and a 4th actor committing).
What Should Happen?
A session ID should map to at most one live process:
- On
--resume, detect that a live process already owns the session (pidfile/lock per session id) and either (a) signal the original to exit before resuming, or (b) refuse/warn: "session <id> is already running (pid N)". - On extension restart/deactivate, terminate child
claudeprocesses rather than orphaning them. - Two processes must never interleave writes into one transcript jsonl.
- Nice-to-have: a way to see running headless sessions (e.g.
claude ps) so an orphan is discoverable withoutps auxforensics.
Error Messages/Logs
# Collision symptom in the resumed session while the twin edited the same file:
Edit failed: "File has been modified since read, either by the user or by a linter.
Read it again before attempting to write it."
# The orphan's self-identification (it committed this reply when baited):
"I am the REVIEWER session (id f075f82b-...); stopped my commit-watcher (bfbv51os6) so I stand down.
To fully close: ... kill the reviewer PID."
Steps to Reproduce
- In the VS Code extension (or any setup where the
claudeprocess can outlive its client), start a session and give it a persistent background Monitor (e.g. awhile true; do git rev-parse HEAD; sleep 20; donecommit watcher) with permission mode allowing autonomous tool use. - Cause the client to restart without the underlying
claudeprocess exiting (in our case: extension restart; the old native-binary processes survived under the same extension-host parent — reproducible by killing/reloading the extension host UI while children persist). - Reopen the same conversation → a new process starts with
--resume <session-id>. ps aux | grep native-binary/claude→ both processes are alive; the old one has no--resumeflag.- Trigger the orphan's Monitor (make a git commit).
- Observe the orphan wake and act (edit/commit) concurrently with the resumed process; observe both appending to the same
~/.claude/projects/<project>/<session-id>.jsonl.
Environment
- Claude Code Version: VS Code extension 2.1.201 (native binary);
claude --versionCLI = 2.1.195 - Model: Opus (4.8)
- Regression?: I don't know
- Platform: Anthropic API
- OS: macOS (Darwin 25.4.0, arm64)
- Terminal: VS Code native extension (closest dropdown: VS Code integrated terminal)
Additional Information
- The orphan behaved "correctly" per its instructions the whole time — the bug is purely process lifecycle: resume-without-exclusivity + orphaned children + shared transcript file.
- Workaround that ended it: committing a bait file (the orphan's watcher fired, it self-identified and stood down), then killing the stale PID / fully quitting VS Code.
4 Comments
Independent reproduction on Linux + pure CLI (no VS Code / extension host), confirming this isn't extension-specific and still occurs on the latest release.
Environment: Claude Code CLI 2.1.201; also reviewed the changelog through 2.1.211 (current latest) — no fix touching session locking, concurrent resume, or transcript ownership.
Different trigger, same outcome — a half-open SSH session:
claudeover SSH; leave it idle at the prompt (process alive).claudeprocess survive.claude --resume <id>(or--continue) → no warning; a second live process starts under the same session id.~/.claude/projects/<project>/<id>.jsonland run tools against the same working tree. Also reproduces with the original still alive mid-turn.So the orphaning path isn't limited to VS Code extension restarts — any client death that leaves the process alive (dropped SSH, detached tty) reaches the same "two executors, one transcript" state.
This lines up with #69364: the live-session registry at
~/.claude/sessions/<pid>.jsonalready recordssessionId,pid,procStart, andstatusper live process — everything a resume-path exclusivity check would need — but--resume/--continuedoesn't consult it before attaching. A pidfile/lock check keyed on the shared transcript path (both processes write one jsonl), usingprocStartto guard against PID reuse, would close it.The fact that resuming a session while the original is still alive can create two live processes working on the same repo (and both writing to the transcript) is nasty. You end up with duplicate commits and conflicting state with no clear way to see what’s happening.
We’ve been treating session identity and exclusivity as important because once you have autonomous work happening in the background, this kind of collision becomes really expensive.
You hit this one yourself or just saw the report?
https://github.com/keesan12/martin-loop
This is not stale, it just burned me. Session started in herdr, then exited, then later resumed on bare shell. Next morning, restarted herdr for other reasons, didn't realize this session also resumed in the background. Another agent awoke the zombie via SendMessage; cue the split-brain shenanigans for another hour+ of confusion before the root cause was discovered and neutralized. So stupid, claude-code should easily be able to lock a session-id for single-writer protection.
Until
--resumerefuses (or prompts) on a session id that is already held, here is an interim hook (written by Claude Opus 5) that at least makes the duplicate harder to miss. Because the hook doesn't execute until you've typed something, you only get the warning one command in — still, better than nothing. While this version is aimed at Linux, the notes describe the key tweak that Macos will need; ask your own ℄ to adapt it.---
It needs no lock of its own, so it has no stale-lock failure mode: Claude Code already writes
~/.claude/sessions/<pid>.jsoncontaining{pid, sessionId, procStart, ...}, and reading that answers the question directly.~/.claude/hooks/session-start-double-resume.sh,chmod +x:In
~/.claude/settings.json:Gotchas, all measured rather than assumed
SessionStarthook cannot write to the terminal — I captured a real interactive resume under a pty and tried plain stdout,{"systemMessage": ...}and stderr; none of them renders. The text goes into the model's context, so the warning surfaces on your first message. Asked an unrelated question, the resumed session opened with "⚠️ Heads-up before anything else: … this session is already open in another process (pid 451845) … I'll hold off on anything touching the repo until you've closed the duplicate." That is still before any work happens, but it is not a banner at resume time, and no hook can be one.resume. A freshclaudegets a new session id and cannot duplicate one, so wiring this tostartupmeans it never fires in the only case it exists for.--resumereportssource: "resume"; I did not test--continue, so widen to"startup|resume"if in doubt — it is silent whenever the session is held once, so a wider matcher costs nothing./proc; on macOS they becomeps -o lstart=,ppid=.claudewill mislead you. A nestedclaudeinheritsCLAUDE_CODE_CHILD_SESSION, which turns transcript saving off and writes no~/.claude/sessionsentry at all — so the process you spin up as the "other window" is invisible to the hook and it correctly stays silent.env -u CLAUDE_CODE_CHILD_SESSIONfirst, or just open a second terminal.What was verified
Against fabricated registries and a live one: a live duplicate warns; own session, dead pid, recycled pid (
procStartmismatch), a different session id and unparsable entries are all silent; and an unparsable entry does not mask a real duplicate sorting after it. Deleting any one of the four guards changes one of those outcomes.It then caught a genuine double on its first real encounter, which is the only test I fully trust.
Appendix: why the lock-file approach in #35719 does not fire
Worth recording only because it is the workaround people will find first, and its failure is silent in the worst direction — you install it, see no warnings, and conclude you have not hit the bug. (#35719 is closed NOT_PLANNED and locked, so this cannot be said there.)
It ends with
echo "$$" > "$LOCK_FILE"and checks later withkill -0 "$OTHER_PID". But a hook does not run as theclaudeprocess — it runs in a short-lived shell a few levels below it, which exits when the hook returns. So the recorded pid is dead before any later session reads it:Its companion unlock hook compares
$(cat "$LOCK_FILE")against its own$$, a different shell again, so the comparison never matches and the stale lock is never removed either.That is the whole reason the hook above walks the ppid chain rather than trusting
$$, and reads Claude Code's own session registry rather than keeping a lock.