Conversation
A CLI that launches with `--session-id <id>` refuses an id that is already in use (claude: `Error: Session ID ... is already in use.`), and every session whose agent has been prompted owns a transcript under that id. The dead-pane respawn in `_setupOrAttachMuxSession()` passed the bare launch line, so recovering such a session relaunched a CLI that died on startup, the pane went dead again at once, and the conversation was stranded behind a tab that looked merely idle. `restartCli()` has pinned a resume id against this since the custom-model work, and its comment states the assumption that made the other path look safe: "Unlike the dead-pane respawn, this one kills a WORKING pane whose conversation already has a transcript". A pane whose agent exited has a transcript too. Both relaunch paths now build options through `_buildRespawnPaneOptionsWithResumePin()`, and so does the create-path fallback after a failed respawn, which otherwise met the same refusal that made it the fallback. Four gates guard the pin, each standing for a way of resuming the WRONG conversation or of making a working relaunch fail. A remote or docker session is never pinned. Unlike `restartCli()`, whose route refuses both, the dead-pane respawn is reached by every session shape. Their pane commands already render a self-healing `--session-id || --resume`, and both flip to resume-first once the resume id differs; the conversation lives on the far side, so a local id resolves to nothing there and the `--session-id` fallback then collides with the transcript the far side does hold. The id comes from the conversation CHAIN rather than `_claudeSessionId`, which also holds history-correlated guesses keyed on the working directory. `_recordClaudeSessionInChain()` refuses those so they cannot "write a foreign conversation into this pane's permanent record", and launching from one is worse than the display bug that rule prevents. The chain tail also outranks the launch seed, which is written once at construction and never moves off a `/clear`. A pin no transcript backs is dropped, because the fallback branch keeps `--session-id <this.id>` and would collide. A synthetic `restored-<fragment>` id from socket discovery is dropped too, and logged: it fails claude's `uuid` token pattern, so the renderer would emit the unpinned command while the caller believed otherwise. Tests cover each gate and the rendered command. Four of them fail against the unfixed source; the remote and docker ones were separately checked against a build with only that guard removed, since they pass on master for the wrong reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks for this, and for the reproduction and the before/after table: a dead-pane relaunch that collides with its own transcript is a real bug, and pinning the conversation so the relaunch renders One thing to fix before I merge, plus a few small ones that live in the same few lines. 1. A dropped pin falls back to the colliding command, not to the self-healing one (src/session.ts:2022, same shape at :2015).
It is also a regression against Your own doc comment already has the answer two paragraphs up ("Pinning the session's own id renders the self-healing ... which needs no transcript to be correct"). Please degrade to const pattern = getCli(this.mode)?.launch.params?.resumeId;
const configDir = this._claudeConfigDir();
const candidates = [chainTail, options.resumeSessionId, this.id].filter((v): v is string => !!v);
for (const candidate of candidates) {
if (pattern?.type === 'token' && !matchesPattern(pattern.pattern, candidate)) continue;
if (!(await claudeTranscriptExists(candidate, configDir))) continue;
options.resumeSessionId = candidate;
return options;
}
return options; // nothing on disk to collide with, so the bare --session-id is correctTwo tests would pin it: a chain tail with no transcript while 2. The transcript lookup ignores the server's own
Related: the header at src/utils/claude-transcript.ts:37 says skipping the resume "is the safe direction". At this call site it is the unsafe one, so that sentence wants updating with the fix. 3. "The command shape is unchanged" is not quite true (src/session.ts:1775). For a genuinely new session the pin resolves to 4. The create-path pin leaves On that path Smaller things I am happy to take at merge time if you would rather not touch them:
On the two problems you listed as out of scope: I agree on both. The parser replay deserves its own issue rather than a patch, and the One more for your awareness rather than this PR: #446 proposes closing cleanly exited dead panes, and this one resumes them. They can coexist (close on status 0, resume otherwise), but it is worth deciding which lands first. Fix point 1 and fold in 2 to 4 while you are in there, and I will merge. The rest I can take at merge time. |
A single pin that failed its transcript gate returned the options untouched, so `resumeSessionId` fell back to `_resumeSessionId` — undefined for an ordinary session — and the renderer emitted the bare `claude --dangerously-skip-permissions --session-id "<this.id>"`. Every session prompted before its first `/clear` owns a transcript under that id, so the dropped pin handed back exactly the refusal this branch removes, with no `||` branch to catch it. It was also a regression against master on the `restartCli()` path, which pinned `_claudeSessionId ?? this.id` and, since the constructor seeds that field, could never land unpinned. The pin now walks three candidates in priority order — the conversation chain's tail, the launch seed, then the session's own id — and takes the first one a transcript backs. A candidate that misses is passed over rather than ending the walk. Falling off the end pins nothing, which also settles the second half of the problem: the old code skipped the transcript check whenever the pin was the session's own id, so a genuinely new pane rendered the two-branch form after all. That costs a brand-new session claude's "No conversation found" line in its scrollback, and `wrapWithNice()` prefixes only the first branch of the rendered `a || b`, so the branch that actually runs loses its priority for the life of the session. With no transcript anywhere the bare `--session-id` is the correct command, so the comment claiming an unchanged shape is now true. The transcript lookup reads the server process's own `CLAUDE_CONFIG_DIR` when a session declares none. A pane inherits the server environment through tmux, so on an install that exports it the CLI writes its transcripts there and every lookup under `~/.claude` was a false negative — which under the old code meant the colliding command. `claudeCredentialsPath()` and `realClaudeConfigDir()` resolve the same directory the same way. The header sentence calling a skipped resume "the safe direction" described the opposite of what happens at this call site, and says so now. The create-path fallback writes `_resumeSessionId` alongside the create options. That branch leaves `isRestored` false, so `_claudeSessionId` is recomputed from the launch fields and settled on `this.id` while the CLI resumed the chain tail; the response viewer, Read My Mind and the unified-list alias map read that field until the next first-hand hook. Four new tests: a chain tail with no transcript while the session id has one, no transcript anywhere, the create path's alias, and the process-env lookup. All four fail against the previous commit. Two existing tests move with the gate — the guess-refusal test now backs the session's own id, and the custom-model restart test gives its working pane the transcript that makes `--session-id` collide in the first place, alongside a new one pinning the no-transcript case. CLAUDE.md described the pin as a `restartCli()`-only thing sourced from the live conversation id. All three halves of that moved here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude:
|
- test/setup.ts strips CLAUDE_CONFIG_DIR (pinned in test-env-isolation), so transcript-fixture tests such as session-custom-model-restart no longer go red on a machine that exports it for a separate Claude account (#255). - The vanished-tmux-session branch of _setupOrAttachMuxSession() relaunches the CLI through createSession() just like a failed respawn, so it now takes the same resume pin. A genuinely new session is unaffected. - After a dead-pane respawn of a fallback-chain CLI, _claudeSessionId names the conversation the walk actually pinned instead of the chain tail, which the walk may have passed over for lack of a transcript. - _claudeConfigDir() trims the override like claudeProjectsDir() does. - The remote-reattach test is labelled as documentation, since the pin builder's own remote guard would make it pass either way. - CLAUDE.md: the create-path pin persists through toState() as resumeSessionId, and the end of the walk adds no pin rather than clearing the launch seed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Merged, thanks @irisitymichaelgrundberg! This ships in 1.32.1. The I finished the rest at merge:
|
Resume the conversation when respawning a dead pane
Recovering a session whose pane is dead relaunches the CLI with a bare
--session-id <id>. Claude refuses an id that is already in use, so therelaunch dies on startup, the pane goes dead again immediately, and the user's
conversation is stranded behind a tab that looks merely idle.
How to reproduce
The dead pane is the precondition, and a crashed, OOM-killed or otherwise
terminated agent leaves one on its own — that is the state #446
is about.
/exitis simply the quickest way to stage it deliberately.important; being prompted is what writes a transcript under the session id.
/exitin that session's pane.remain-on-exit onkeeps the pane, sotmux reports
pane_dead=1and Codeman's tab still reads idle.dead-pane branch and relaunches the CLI.
tmux -L codeman capture-pane -p -S - -t codeman-<id fragment>On
masterstep 4 prints the error above followed byPane is dead (status 1, ...). On this branch the pane is alive, claude isrunning in it, and the conversation from step 1 is back in the scrollback.
Who it hits
Every session whose agent has actually been prompted, because being prompted is
what creates a transcript under the session id. A session nobody typed into
owns no transcript and recovers fine, which is what makes this look like an
edge case rather than the rule.
Why it has been quiet
The dead-pane branch of
_setupOrAttachMuxSession()runs only for a pane whoseagent has exited. Until #446, Codeman could not report that state
at all:
remain-on-exit onkeeps the pane, thetmux attach-sessionprocessthat Codeman records as the pid keeps running, and the session reads as live
and idle. I found this while testing #446, whose branch makes the state
visible. The two changes are independent, and this one applies to
masteronits own.
The fix
restartCli()has pinned a resume id against this since the custom-model work,and its own comment states the assumption that made the other path look safe:
A pane whose agent exited has a transcript too. Both relaunch paths now build
their options through
_buildRespawnPaneOptionsWithResumePin(), and so doesthe create-path fallback after a failed respawn, which otherwise met the same
refusal that made it the fallback. The launch then renders
--resume <id> || --session-id <id>, the shape the docker and remote panecommands already use, so the relaunch resumes the conversation and falls back
to a fresh one only when resuming is impossible.
Four conditions gate the pin. Each one stands for a way of resuming the wrong
conversation, or of making a working relaunch fail.
fallbacklaunch chain. The registry shape isthe gate rather than the CLI's name, exactly as
restartCli()had it. Anentry whose resume id the CLI mints itself (codex, pi, omp, grok) declares
no such chain and reads its resume field from its own
<Mode>Config.restartCli()is local-onlybecause its routes refuse those two, not because the method checks anything;
the dead-pane branch has no such route in front of it. Both
buildRemoteLaunchCommand()and
claudeDockerPaneCommand()flip from the self-healing--session-id || --resumeto resume-first once the resume id differs fromthe session id. The conversation lives on the far side, so a local id
resolves to nothing there and the
--session-idfallback then collides withthe transcript the far side does hold.
_claudeSessionId. Thatfield also holds history-correlated guesses keyed on the working directory.
_recordClaudeSessionInChain()refuses those so they cannot "write a foreignconversation into this pane's permanent record", and launching a CLI from a
guess is worse than the display bug that rule prevents. The chain tail also
outranks the launch seed, which is written once at construction and never
moves off a
/clear.restored-<fragment>id from socket discovery. The fallback branch keeps--session-id <this.id>, so a pin that cannot resume would collide thereinstead. The
restored-*id fails claude'suuidtoken pattern, which meansthe renderer would emit the unpinned command while the caller believed
otherwise; that case is logged rather than dropped silently.
reattachRemote()is the thirdrespawnPane()caller and deliberately keepsthe unpinned options. It re-runs the remote session command, which attaches to
the durable remote tmux with the agent still running inside it, and renders no
local
--session-idto collide.Tests
test/respawn-session-id-collision.test.tsadds 14 tests. They drivestartInteractive()and assert the rendered launch command rather than readingthe source, so a refactor that preserves behaviour keeps passing and a gutted
pin does not. Four of them fail against the unfixed source, including the core
bug. The remote and docker tests were separately checked against a build with
only that guard removed, because they pass on
masterfor the wrong reason.npm run typecheck,npm run lint,npm run format:check,npm run check:frontend-syntaxandnpm testall pass (415 test files, 7842tests).
Manual verification
On tmux 3.2a, I ran the same fixture against both builds: one claude session in
a scratch directory, prompted once so a transcript exists, then
/exit, leavinga dead pane. The Codeman data directory was snapshotted before the first run and
restored before the second, so both builds saw identical state.
masterError: Session ID … is already in use.No
already in useappears in the server log on this branch.Two pre-existing problems this does not fix
Review turned up two issues that this change makes more prominent without
introducing. Each deserves its own change rather than riding along here.
A resumed transcript replays into the expensive parsers.
claude --resumereprints the conversation into the pane, and every chunk reaches
_processExpensiveParsers(), which feeds the Ralph tracker's completion-phrasecounter. The replayed transcript contains the completion phrase from the
previous run, so Ralph can report completion, or meet its exit gate, before the
agent has done anything. This already affects every remote and docker resume
today; this change makes local resume routine as well. A fix needs a
suppression window and a rule for when replay ends, which is a design decision
rather than a patch.
nicewraps only the first branch of the chain.wrapWithNice()atsrc/utils/nice-wrapper.ts:14prefixes the whole rendered string, so thecommand becomes
nice -n 10 claude --resume A || claude --session-id Aand thefallback branch inherits no priority. The two-branch shape is already normal for
docker and remote; this change makes it normal for local claude relaunches too.
The function is shared by every caller, so fixing it means either string surgery
on a rendered command or nesting another
bash -cinside the one tmux alreadyuses.
🤖 Generated with Claude Code