Skip to content

fix(adopt): no more phantom "Continue previous conversation?" after cs -adopt - #12

Merged
hex merged 3 commits into
hex:mainfrom
Gherghi67:fix/adopt-first-launch
Oct 4, 2026
Merged

hex merged 3 commits into
hex:mainfrom
Gherghi67:fix/adopt-first-launch

Conversation

@Gherghi67

Copy link
Copy Markdown
Contributor

Problem

After cs -adopt <name> in a project, the first cs <name> asks "Continue previous conversation? [Y/n]" about a conversation that never existed. Answering Y runs claude --resume <id>, which fails at once, and cs falls back to "No previous conversation found. Starting fresh..." with a resume-failed rotation. Re-adopting the records a removed session left behind (the "Found existing session records (.cs/) with no session link" prompt) also replaced their claude_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 --resume exits 1).

Cause

An adopted directory already exists, so its first open is a reopen (is_new=false). Three things combined:

  1. adopt_session runs create_session_structure, which stages a claude_session_id for a brand-new session's --session-id. On re-adopt it overwrote the existing one.
  2. migrate_session Phase 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.
  3. The launch asked the resume question for every is_new=false session, bound or not.

Fix

  • adopt_session reads the prior binding before create_session_structure, then puts it back, or removes the staged id on a first adoption (new _unset_local_state in lib/40-state.sh).
  • Phase 8 binds only a transcript it discovers. It no longer allocates an id that names no conversation.
  • launch_claude_code: an existing session with no binding starts the way a new session starts. It records an id and execs claude --name <session> --session-id <id>: no prompt, no rotated timeline event, no CS_FRESH_REBIND. The card says + new.
  • With every prompted session now bound, the three --continue fallbacks 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 a rotated event from an empty id would be misleading. CS_FRESH_REBIND would 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>, no rotated event), 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.
  • Putting back the old Phase 8 allocation fails three of these.
  • tests/test_encrypt.sh and tests/test_worktrees.sh: four resume-prompt fixtures had no binding (test_encrypt.sh writes claude_session_id=x, which is not key: value form). They saw the prompt only because Phase 8 allocated an id, so they now record one. New test_first_open_leaves_the_detach_to_the_waiter checks that the new exec path leaves the vault detach to the SessionEnd waiter, as the n path 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.
  • The other two fail the same way on an unmodified 2026.10.1 checkout on this machine, and this PR does not touch cs-secrets:
    • test_cs_secrets.sh 79/84 (the encrypted-file store tests)
    • test_cs_secrets_concurrency.sh 4/6
  • tests/lint_shell.sh with shellcheck 0.11.0: no errors, 129 warnings (baseline 129).
  • bin/cs is rebuilt and matches sh build.sh.

Not changed here (noticed while in there)

  • _exec_fresh_rebind names the conversation basename "$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_session tests [ ! -d "$target_dir/.git" ]. A linked worktree or a submodule has a .git file, so adopting one runs the new-repo path (template .gitignore, git branch -M main, core.autocrlf written to the shared config).
  • An unbound session that holds an unconsumed rotation handoff (for example, one from another checkout) no longer shows the handoff menu on its first open. It offers the handoff on the next open, once a conversation is bound.

🤖 Generated with Claude Code

…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>
@Gherghi67 Gherghi67 changed the title adopt: the first cs <name> after cs -adopt starts a new conversation instead of offering to resume one that never existed fix(adopt): no more phantom "Continue previous conversation?" after cs -adopt Oct 2, 2026
@Gherghi67

Copy link
Copy Markdown
Contributor Author

@hex heads-up: reviewing this PR turned up an argument injection into the claude command line. This PR makes it reachable through cs -adopt. The underlying flaw is already in 2026.10.1.

What happens

cs keeps the conversation id in .cs/local/state, which is gitignored and never travels with a clone. For sessions made by older cs versions, Phase 12 of migrate_session copies a claude_session_id: line from the committed .cs/README.md frontmatter into local state whenever local state has no id. It strips only " and \r and never checks that the value is a UUID. The launch then builds continue_flag="--resume $claude_session_id" and runs it unquoted, so bash splits the stored value into separate arguments to claude.

A repo that commits this README frontmatter:

---
claude_session_id: --dangerously-skip-permissions
---

gets those words onto the claude command line when the victim opens the session and presses Enter at "Continue previous conversation?":

claude --name victim --resume --dangerously-skip-permissions /color blue

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

2026.10.1, before this PR With this PR
What the victim does Clones a shared cs session repo into the sessions folder and runs cs <name> Runs cs -adopt <name> in a project that carries a committed .cs/, answers y to "Re-adopt", then runs cs <name>
Who the repo usually comes from Your own machines or colleagues, per "Sharing a session between machines" Any project, including strangers'

Before this PR, adopt's call to create_session_structure wrote a fresh UUID into local state, so Phase 12 never imported the README value on an adopted project. This PR leaves the slot empty after adopt on purpose, and that is what lets the import run.

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

  • Accept only a UUID wherever the id enters local state or is read from it: the launch read in launch_claude_code, the Phase 12 import, and adopt's restore of the prior binding. Treat anything else as empty, so it takes the new first-conversation path. hooks/session-start.sh already has a UUID_RE to reuse.
  • Pass the id as its own quoted argument, --resume "$claude_session_id", instead of inside the unquoted $continue_flag.
  • Optionally, skip a README-imported id that has no transcript on this machine. That also removes the side effect.

The first two belong in this PR before it merges, since the PR is what opens the adopt route.

Gherghi67 and others added 2 commits October 3, 2026 17:16
…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>
@Gherghi67

Copy link
Copy Markdown
Contributor Author

Review round-up

For the argument injection comment above.

id verdict where
T1 fixed 5d5e192. _is_uuid (lib/40-state.sh, the pattern hooks/session-start.sh already uses) gates the Phase 12 README import, adopt's restore of a prior binding and the launch's read. Anything that is not a UUID counts as no id, so the open takes the first-conversation path and records a real id over it. Six new tests, all failing on 00126c9. Reverting each check on its own fails the one test aimed at it: launch read 16/17 in test_local_state.sh, Phase 12 import 16/17 in test_local_state.sh, adopt restore 31/32 in test_adopt.sh.
T2 fixed 598441a. The answer arms set resume_id, and the launch passes --resume "$resume_id" instead of the unquoted $continue_flag. After T1 only a UUID reaches that line, so quoted and unquoted behave the same and no test can tell them apart. It guards against a future writer of local state.
T3 not changed Kept out of this PR. Skipping a README id that has no transcript here changes what Phase 12 is tested to do: four tests (test_local_state.sh ×2, test_uuid.sh, test_migrate_claude_md.sh) expect such an id to land in local state. Until a follow-up, an old README with a real UUID and no transcript on this machine still shows one "Continue previous conversation?" whose resume fails and starts fresh.

The comment's repro script on 598441a:

route 1, adopt:
  claude --name victim --session-id 08a76e6a-ac99-4eb1-b416-436bf98b98c7 /color cyan
route 2, shared-session clone:
  claude --name shared --session-id 5438d744-9c97-4ff9-88b7-4167cd3348f1 /color pink

On 00126c9 the same script still gives claude --name victim --resume --dangerously-skip-permissions /color pink and claude --name shared --resume --dangerously-skip-permissions /color red.

Gates on 598441a: build.sh leaves bin/cs, hooks/cs-shared.sh and install.sh unchanged. tests/lint_shell.sh with shellcheck 0.11.0 reports no errors and 129 warnings (baseline 129). tests/run_all.sh passes 69/71 suites. The two that fail are test_cs_secrets.sh 79/84 and test_cs_secrets_concurrency.sh 4/6, the same counts as an unmodified 2026.10.1 checkout on this machine.

hex added a commit that referenced this pull request Oct 4, 2026
… 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
@hex
hex merged commit 598441a into hex:main Oct 4, 2026
@hex

hex commented Oct 4, 2026

Copy link
Copy Markdown
Owner

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:

  • claude_session_color had the same hole as the id. The README import copied it unchecked and the launch turned it into /color <value>, claude's first prompt. _is_session_color now gates every reader (import, launch, _exec_fresh_rebind, re-adopt), and a dropped id or colour prints a line saying which file it came from.
  • The new exec ran above the handoff scan. A clone with an unconsumed handoff and no local state never got the offer, and SessionStart then marked the handoff consumed under the fresh id. The unbound case now goes through the resume menu: no handoff means the fresh answer with no question, a pending one shows r/n/d with n as default, a spawned open starts fresh and leaves the handoff. _exec_fresh_rebind writes no rotated event when there is nothing to rotate from, and names an adopted session from state instead of its folder (your "not changed here" item 1, it surfaced on the new path).
  • A worktree open skips migrate_session, so with the id missing it would have started a new conversation over the real one. The launch now looks at the folder's transcripts once before calling the session unbound.
  • The live-duplicate guard only ran with a recorded id, so an unbound session could open beside a running claude --name. The --name half runs without one now.
  • Re-adopt kept the id but re-rolled the colour.
  • _set_local_state / _unset_local_state end with Error: could not write <path> instead of a bare shell error.
  • Comments in lib describe what the code does now, no "used to". README still said claude --continue in two places, the changelog said the first open "opens on" the newest conversation where it asks, doctor said migrate backfills the id.
  • Test stubs record each argument in its own brackets so quoting is visible, and the re-adopt fixture writes its state key instead of appending a second line.

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 hooks/ (the iTerm tab helper and the vault detach waiter), which this PR does not touch; both pass alone. The cs-secrets suites passed here.

One thing though, the --resume quoting has no test that can see it, since only a UUID reaches that line. Left it alone.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants