Repository navigation
fix(adopt): no more phantom "Continue previous conversation?" after cs -adopt - #12
Conversation
…instead of offering to resume one that never existed; re-adopt keeps the recorded conversation An adopted directory already exists, so its first open is a reopen (is_new=false), and three things combined into a phantom resume prompt: - adopt_session ran create_session_structure, which stages a claude_session_id for a brand-new session's --session-id. On re-adopt of orphaned records it also replaced the id they named. - migrate_session Phase 8, finding no binding and no transcript, allocated an id. Dropping the id in adopt alone would not help: Phase 8 wrote a new one on the first open. - The launch asked "Continue previous conversation?" for any is_new=false session. The default answer's --resume failed at once and the quick-failure fallback rebound with "No previous conversation found. Starting fresh...". Now adopt keeps a prior binding and drops the staged one, Phase 8 binds only a transcript it discovers, and the launch starts an existing session that has no binding the way a new session starts: it records an id and execs claude --name <session> --session-id <id>, with no prompt, no rotated timeline event and no CS_FRESH_REBIND. The card says "+ new". A project Claude Code already ran in still binds its newest conversation on the first open and asks, as before. With every prompted session now bound, the three --continue fallbacks were unreachable; they are plain --resume <id>. Tests: five new in test_adopt.sh (first launch, project with history, second launch, re-adopt keeps the binding, re-adopt without local state) and a tighter backfill assertion in test_uuid.sh. Restoring the Phase 8 allocation fails three of them. The resume-prompt fixtures in test_encrypt.sh and test_worktrees.sh had no binding and saw the prompt only because Phase 8 allocated one; they now record a conversation, and test_encrypt.sh gains a check that the first-open exec leaves the vault detach to the SessionEnd waiter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@hex heads-up: reviewing this PR turned up an argument injection into the What happens cs keeps the conversation id in A repo that commits this README frontmatter: gets those words onto the claude command line when the victim opens the session and presses Enter at "Continue previous conversation?": I reproduced this in a sandbox with a stub claude that logs its argv, on both this PR's head and 2026.10.1. I did not test it against the real claude binary, so what claude does with those words is unverified. That the words reach its argv is verified. Two routes in
Before this PR, adopt's call to There is also a side effect that needs no attacker. An old README with a real UUID but no transcript on this machine imports a non-empty id. That skips the new first-conversation path and shows the same phantom "Continue previous conversation?" this PR sets out to remove. Suggested fix, closing both routes
The first two belong in this PR before it merges, since the PR is what opens the adopt route. |
…UID; the README import, re-adopt's kept binding and the launch read all check it (review T1) A claude_session_id: line in a committed .cs/README.md was copied into .cs/local/state by migrate_session Phase 12 whenever local state had no id, with only quotes and CRs stripped. The resume prompt then passed it to claude unquoted, so a cloned session or an adopted project could put words such as --dangerously-skip-permissions on the launch command. Before this branch adopt pre-filled a random id and Phase 12 skipped the README; leaving the slot empty on adopt opened that route. _is_uuid (lib/40-state.sh, the pattern hooks/session-start.sh already uses) now gates the three places an id enters or leaves local state: Phase 12's import, adopt's restore of a prior binding, and the launch's read. Anything else counts as no id, so the open takes the first- conversation path and records a real id over it. Tests: six new. Reverting each check alone fails exactly the test aimed at it (launch read, Phase 12 import, adopt restore: one each); all six fail on the previous head. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… quoted argument instead of an unquoted --resume string (review T2) continue_flag held "--resume <id>" and expanded unquoted under the SC2086 disable, so bash split the stored id into separate claude arguments. The answer arms now set resume_id, and the launch passes --resume "$resume_id". Since the previous commit only a UUID gets this far, so the change has no observable effect today; it keeps a future writer of local state from reopening the split. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round-upFor the argument injection comment above.
The comment's repro script on 598441a: On 00126c9 the same script still gives Gates on 598441a: |
… a new conversation without asking, only a UUID id or a known colour reaches claude, an unbound open still offers a pending handoff Claude-Session: https://claude.ai/code/session_0116zUTZjBUiPZogPvQztknf
|
Merged as b747099, with your three commits as they are and four of mine on top. The review turned up a few things, so I fixed them before merging instead of sending it back:
Suites: adopt 32/32, local_state 20/20, uuid 42/42, worktrees 116/116, rotation 129/129, encrypt 39/39, doctor 76/76, migrate 27/27, docs 6/6, shellcheck at baseline. Full run from a clean clone 69/71, the two misses are timing tests in One thing though, the |
Problem
After
cs -adopt <name>in a project, the firstcs <name>asks "Continue previous conversation? [Y/n]" about a conversation that never existed. Answering Y runsclaude --resume <id>, which fails at once, and cs falls back to "No previous conversation found. Starting fresh..." with aresume-failedrotation. Re-adopting the records a removed session left behind (the "Found existing session records (.cs/) with no session link" prompt) also replaced theirclaude_session_id, so the next open no longer resumed the conversation they named.Reproduced on 2026.10.1 in a sandbox (
CS_SESSIONS_ROOT=<tmp>, a stub claude whose--resumeexits 1).Cause
An adopted directory already exists, so its first open is a reopen (
is_new=false). Three things combined:adopt_sessionrunscreate_session_structure, which stages aclaude_session_idfor a brand-new session's--session-id. On re-adopt it overwrote the existing one.migrate_sessionPhase 8 allocated an id when it found no binding and no transcript, so removing the id in adopt alone would not help: Phase 8 wrote a new one on the first open.is_new=falsesession, bound or not.Fix
adopt_sessionreads the prior binding beforecreate_session_structure, then puts it back, or removes the staged id on a first adoption (new_unset_local_stateinlib/40-state.sh).launch_claude_code: an existing session with no binding starts the way a new session starts. It records an id and execsclaude --name <session> --session-id <id>: no prompt, norotatedtimeline event, noCS_FRESH_REBIND. The card says+ new.--continuefallbacks in the answer arms could no longer run. They are plain--resume <id>.Unchanged: a project Claude Code already ran in still binds its newest conversation (teammate and headless transcripts skipped) on the first open, and asks.
I used the session-start path rather than
_exec_fresh_rebind. Nothing rotates here, and arotatedevent from an empty id would be misleading.CS_FRESH_REBINDwould also tell the model that "the user explicitly started a fresh conversation", which isn't true on a first launch.Tests
tests/test_adopt.sh: five new tests. They cover the first launch (no prompt, one launch with--session-id <recorded>,--name <link name>, norotatedevent), a project with an existing conversation, the second launch, re-adopt keeping the binding, and re-adopt without local state.tests/test_uuid.sh: the backfill test now also asserts that the first open does not ask and starts the id it records.tests/test_encrypt.shandtests/test_worktrees.sh: four resume-prompt fixtures had no binding (test_encrypt.shwritesclaude_session_id=x, which is notkey: valueform). They saw the prompt only because Phase 8 allocated an id, so they now record one. Newtest_first_open_leaves_the_detach_to_the_waiterchecks that the new exec path leaves the vault detach to the SessionEnd waiter, as thenpath does.Results on macOS (Darwin 25.6.0):
bash tests/run_all.sh: four suites failed on the first run. Two of them (test_encrypt.sh,test_worktrees.sh) were the fixtures above, fixed in this PR and rerun: encrypt 39/39, worktrees 115/115, adopt 30/30, uuid 40/40, rotation 125/125.cs-secrets:test_cs_secrets.sh79/84 (the encrypted-file store tests)test_cs_secrets_concurrency.sh4/6tests/lint_shell.shwith shellcheck 0.11.0: no errors, 129 warnings (baseline 129).bin/csis rebuilt and matchessh build.sh.Not changed here (noticed while in there)
_exec_fresh_rebindnames the conversationbasename "$session_dir"(lib/40-state.sh), so an adopted session's declined-resume and resume-failed paths name it after its folder rather than its session name. The new first-launch path uses the session name.adopt_sessiontests[ ! -d "$target_dir/.git" ]. A linked worktree or a submodule has a.gitfile, so adopting one runs the new-repo path (template.gitignore,git branch -M main,core.autocrlfwritten to the shared config).🤖 Generated with Claude Code