From 00126c9cc197d26ed472f1e54dc8513cf7670fbf Mon Sep 17 00:00:00 2001 From: "bogdan.gherghina" Date: Fri, 2 Oct 2026 23:50:26 +0300 Subject: [PATCH 1/3] adopt: the first cs after cs -adopt starts a new 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-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 . 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 --- CHANGELOG.md | 5 ++ README.md | 2 +- bin/cs | 97 +++++++++++++++--------- lib/40-state.sh | 9 +++ lib/45-migrate.sh | 12 +-- lib/75-launch.sh | 62 +++++++++------- lib/85-adopt-uninstall.sh | 14 ++++ tests/test_adopt.sh | 151 ++++++++++++++++++++++++++++++++++++++ tests/test_encrypt.sh | 23 ++++++ tests/test_uuid.sh | 12 ++- tests/test_worktrees.sh | 5 ++ 11 files changed, 323 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d580dcc7..738f70fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to cs are documented here. Release notes are also available +## Unreleased + +### Fixes +- The first `cs ` after `cs -adopt` no longer asks "Continue previous conversation?" in a project with no Claude Code conversation to resume. `cs -adopt` recorded an id for a conversation that did not exist yet, so the first open offered to resume it, the resume failed, and cs started fresh with "No previous conversation found". That open now starts a new conversation without asking, records it, and the launch card says `new`. A project Claude Code already ran in still opens on its newest conversation. Re-adopting the records a removed session left behind keeps the conversation they name; it used to be replaced with a new id, so the next open no longer resumed it. + ## 2026.10.1 ### Added diff --git a/README.md b/README.md index 2c72a2b6..67d7c411 100644 --- a/README.md +++ b/README.md @@ -234,7 +234,7 @@ This converts the current directory into a cs session in place: - Symlinks `~/.claude-sessions/` to the current directory - Writes the session protocol to `CLAUDE.local.md` (machine-local, gitignored, regenerated per machine); a project's existing `CLAUDE.md` is never touched - Initializes a git repo if one doesn't exist (preserves existing repos) -- Since the working directory doesn't change, `claude --continue` picks up previous conversations +- Since the working directory doesn't change, `claude --continue` picks up previous conversations, and the first `cs ` offers to resume the newest one; a project with none starts a new conversation without asking ## Session Structure diff --git a/bin/cs b/bin/cs index c7221f4d..274dc883 100755 --- a/bin/cs +++ b/bin/cs @@ -2921,6 +2921,15 @@ _set_local_state() { } > "$tmp" && mv "$tmp" "$state" } +# Remove a key's line from a machine-local state file. A missing file or key is +# a no-op. Atomic (tmp+mv), like _set_local_state. +_unset_local_state() { + local state="$1" key="$2" + [ -f "$state" ] || return 0 + local tmp="$state.tmp" + awk -v key="$key" 'index($0, key ":") != 1' "$state" > "$tmp" && mv "$tmp" "$state" +} + # Return the path to claude's per-cwd transcript directory. Symlinks in the # input are resolved via `pwd -P` so the encoding matches claude's own — # macOS mktemp returns /var/folders/... which is a symlink to @@ -3858,14 +3867,14 @@ migrate_session() { else local _discovered _discovered=$(_discover_session_uuid_in "$_proj") + # No transcripts: a recorded UUID is left alone (claude hasn't + # written the jsonl yet, eg. the session was just created with + # --session-id but hasn't talked to the user), and so is an empty + # slot. An id allocated here would name no conversation, and the + # launch would offer to resume it; with none, the launch starts + # the first conversation and records its id. if [ -n "$_discovered" ]; then _bind_uuid="$_discovered" - elif [ -z "$_existing" ]; then - # No transcripts and no recorded UUID — allocate fresh. - # A recorded UUID without transcripts is left alone: claude - # hasn't written the jsonl yet (eg. session was just created - # with --session-id but hasn't talked to the user). - _bind_uuid=$(_alloc_uuid) fi fi @@ -8346,10 +8355,11 @@ launch_claude_code() { fi # Read the session's recorded UUID (allocated by create_session_structure - # on new sessions or backfilled by migrate_session Phase 8 on legacy ones). - # Used for both the CS_CLAUDE_SESSION_ID env export below and for the - # spawn args at exec time. Empty only if the state file is somehow - # missing — exec paths fall back gracefully. + # on new sessions or backfilled by migrate_session Phase 8 from a + # transcript on disk). Used for both the CS_CLAUDE_SESSION_ID env export + # below and for the spawn args at exec time. Empty on an existing session + # that has never had a conversation, such as the first open after + # cs -adopt; the launch below starts one and records it. local claude_session_id claude_session_color claude_session_id=$(_read_local_state "$session_dir/.cs/local/state" claude_session_id) claude_session_color=$(_read_local_state "$session_dir/.cs/local/state" claude_session_color) @@ -8584,9 +8594,10 @@ launch_claude_code() { # the /color re-apply for this one launch (color returns next open). local launch_prompt="${merge_kick:-${spawn_kick:-$color_arg}}" - # Status indicator + # Status indicator. An existing session with no recorded conversation starts + # its first one below, so it is new, not resuming. local status_icon status_text - if [ "$is_new" = "true" ]; then + if [ "$is_new" = "true" ] || [ -z "$claude_session_id" ]; then status_icon="+" status_text="new" else @@ -8717,6 +8728,21 @@ EOF cd "$session_dir" + # An existing session with no recorded conversation has nothing to resume: + # the first open after cs -adopt, or adopted records whose machine-local + # state did not travel. Asking offered a conversation that never existed, + # and with no id to resume the answer fell back to --continue, which picks + # up whatever claude last ran in this folder. Start it the way a new + # session starts: record an id and hand it to claude. Not a rotation, so + # no timeline event and no CS_FRESH_REBIND. + if [ "$is_new" = "false" ] && [ -z "$claude_session_id" ]; then + claude_session_id=$(_alloc_uuid) + _set_local_state "$session_dir/.cs/local/state" claude_session_id "$claude_session_id" + export CS_CLAUDE_SESSION_ID="$claude_session_id" + # shellcheck disable=SC2086 + exec $CLAUDE_CODE_BIN --name "$session_name" --session-id "$claude_session_id" ${launch_prompt:+"$launch_prompt"} + fi + # For existing sessions, ask if user wants to continue previous conversation local continue_flag="" if [ "$is_new" = "false" ]; then @@ -8836,11 +8862,7 @@ EOF # r without a pending handoff was never offered: treat as the # default resume answer, disarm included. _disarm_rotation_marker "$session_dir" - if [ -n "$claude_session_id" ]; then - continue_flag="--resume $claude_session_id" - else - continue_flag="--continue" - fi + continue_flag="--resume $claude_session_id" ;; [dD]) # Nothing survives d: it retires the handoff it was offered, and @@ -8863,25 +8885,18 @@ EOF fi # d without a pending handoff was never offered: treat as the # default resume answer. - if [ -n "$claude_session_id" ]; then - continue_flag="--resume $claude_session_id" - else - continue_flag="--continue" - fi + continue_flag="--resume $claude_session_id" ;; *) # Also the unattended spawn path, which takes this default # without asking. _disarm_rotation_marker "$session_dir" "$pending_handoff" - # Prefer --resume when the session has a recorded UUID: - # it names the exact conversation, vs --continue which means - # "most recent" and may resolve to a sibling Claude session - # the user ran in a different terminal between cs launches. - if [ -n "$claude_session_id" ]; then - continue_flag="--resume $claude_session_id" - else - continue_flag="--continue" - fi + # --resume , never --continue: the uuid names the exact + # conversation, while --continue means "most recent" and may + # resolve to a sibling Claude session the user ran in a + # different terminal between cs launches. A session reaching + # this prompt always has one (the unbound case started above). + continue_flag="--resume $claude_session_id" ;; esac echo "" @@ -8912,8 +8927,10 @@ EOF # and pass --session-id so cs stays bound to the new # conversation. Without rebind, next launch resumes the OLD # conversation while the fresh one becomes orphaned. - # - is_new=false with no claude_session_id (shouldn't happen - # post-Phase-8 but handled defensively): naked exec. + # - is_new=true with no claude_session_id (create_session_structure + # always writes one; handled defensively): naked exec. An + # is_new=false session with none never gets here: it started + # its first conversation before the resume prompt. if [ "$is_new" = "true" ] && [ -n "$claude_session_id" ]; then # shellcheck disable=SC2086 exec $CLAUDE_CODE_BIN --name "$session_name" --session-id "$claude_session_id" ${launch_prompt:+"$launch_prompt"} @@ -9072,7 +9089,21 @@ adopt_session() { # create_session_structure writes CLAUDE.local.md, never CLAUDE.md — a # project's own CLAUDE.md is left untouched. + # + # It also stages a conversation id for a brand-new session's first launch. + # An adopted directory already exists, so its first open is a reopen, and a + # staged id made that open ask to continue a conversation that never + # existed. A first adoption keeps no id (the launch records one when it + # starts the first conversation); re-adopted records keep the conversation + # they name, which the staged id used to replace. + local prior_binding + prior_binding=$(_read_local_state "$target_dir/.cs/local/state" claude_session_id) create_session_structure "$target_dir" + if [ -n "$prior_binding" ]; then + _set_local_state "$target_dir/.cs/local/state" claude_session_id "$prior_binding" + else + _unset_local_state "$target_dir/.cs/local/state" claude_session_id + fi # An adopted session's name is the link's, not the directory's, and the link # is the only place it lives — so a hook that resolves this project by diff --git a/lib/40-state.sh b/lib/40-state.sh index bb623596..6821c281 100644 --- a/lib/40-state.sh +++ b/lib/40-state.sh @@ -65,6 +65,15 @@ _set_local_state() { } > "$tmp" && mv "$tmp" "$state" } +# Remove a key's line from a machine-local state file. A missing file or key is +# a no-op. Atomic (tmp+mv), like _set_local_state. +_unset_local_state() { + local state="$1" key="$2" + [ -f "$state" ] || return 0 + local tmp="$state.tmp" + awk -v key="$key" 'index($0, key ":") != 1' "$state" > "$tmp" && mv "$tmp" "$state" +} + # Return the path to claude's per-cwd transcript directory. Symlinks in the # input are resolved via `pwd -P` so the encoding matches claude's own — # macOS mktemp returns /var/folders/... which is a symlink to diff --git a/lib/45-migrate.sh b/lib/45-migrate.sh index 7879710e..0afc1033 100644 --- a/lib/45-migrate.sh +++ b/lib/45-migrate.sh @@ -705,14 +705,14 @@ migrate_session() { else local _discovered _discovered=$(_discover_session_uuid_in "$_proj") + # No transcripts: a recorded UUID is left alone (claude hasn't + # written the jsonl yet, eg. the session was just created with + # --session-id but hasn't talked to the user), and so is an empty + # slot. An id allocated here would name no conversation, and the + # launch would offer to resume it; with none, the launch starts + # the first conversation and records its id. if [ -n "$_discovered" ]; then _bind_uuid="$_discovered" - elif [ -z "$_existing" ]; then - # No transcripts and no recorded UUID — allocate fresh. - # A recorded UUID without transcripts is left alone: claude - # hasn't written the jsonl yet (eg. session was just created - # with --session-id but hasn't talked to the user). - _bind_uuid=$(_alloc_uuid) fi fi diff --git a/lib/75-launch.sh b/lib/75-launch.sh index b7189da8..14558800 100644 --- a/lib/75-launch.sh +++ b/lib/75-launch.sh @@ -226,10 +226,11 @@ launch_claude_code() { fi # Read the session's recorded UUID (allocated by create_session_structure - # on new sessions or backfilled by migrate_session Phase 8 on legacy ones). - # Used for both the CS_CLAUDE_SESSION_ID env export below and for the - # spawn args at exec time. Empty only if the state file is somehow - # missing — exec paths fall back gracefully. + # on new sessions or backfilled by migrate_session Phase 8 from a + # transcript on disk). Used for both the CS_CLAUDE_SESSION_ID env export + # below and for the spawn args at exec time. Empty on an existing session + # that has never had a conversation, such as the first open after + # cs -adopt; the launch below starts one and records it. local claude_session_id claude_session_color claude_session_id=$(_read_local_state "$session_dir/.cs/local/state" claude_session_id) claude_session_color=$(_read_local_state "$session_dir/.cs/local/state" claude_session_color) @@ -464,9 +465,10 @@ launch_claude_code() { # the /color re-apply for this one launch (color returns next open). local launch_prompt="${merge_kick:-${spawn_kick:-$color_arg}}" - # Status indicator + # Status indicator. An existing session with no recorded conversation starts + # its first one below, so it is new, not resuming. local status_icon status_text - if [ "$is_new" = "true" ]; then + if [ "$is_new" = "true" ] || [ -z "$claude_session_id" ]; then status_icon="+" status_text="new" else @@ -597,6 +599,21 @@ EOF cd "$session_dir" + # An existing session with no recorded conversation has nothing to resume: + # the first open after cs -adopt, or adopted records whose machine-local + # state did not travel. Asking offered a conversation that never existed, + # and with no id to resume the answer fell back to --continue, which picks + # up whatever claude last ran in this folder. Start it the way a new + # session starts: record an id and hand it to claude. Not a rotation, so + # no timeline event and no CS_FRESH_REBIND. + if [ "$is_new" = "false" ] && [ -z "$claude_session_id" ]; then + claude_session_id=$(_alloc_uuid) + _set_local_state "$session_dir/.cs/local/state" claude_session_id "$claude_session_id" + export CS_CLAUDE_SESSION_ID="$claude_session_id" + # shellcheck disable=SC2086 + exec $CLAUDE_CODE_BIN --name "$session_name" --session-id "$claude_session_id" ${launch_prompt:+"$launch_prompt"} + fi + # For existing sessions, ask if user wants to continue previous conversation local continue_flag="" if [ "$is_new" = "false" ]; then @@ -716,11 +733,7 @@ EOF # r without a pending handoff was never offered: treat as the # default resume answer, disarm included. _disarm_rotation_marker "$session_dir" - if [ -n "$claude_session_id" ]; then - continue_flag="--resume $claude_session_id" - else - continue_flag="--continue" - fi + continue_flag="--resume $claude_session_id" ;; [dD]) # Nothing survives d: it retires the handoff it was offered, and @@ -743,25 +756,18 @@ EOF fi # d without a pending handoff was never offered: treat as the # default resume answer. - if [ -n "$claude_session_id" ]; then - continue_flag="--resume $claude_session_id" - else - continue_flag="--continue" - fi + continue_flag="--resume $claude_session_id" ;; *) # Also the unattended spawn path, which takes this default # without asking. _disarm_rotation_marker "$session_dir" "$pending_handoff" - # Prefer --resume when the session has a recorded UUID: - # it names the exact conversation, vs --continue which means - # "most recent" and may resolve to a sibling Claude session - # the user ran in a different terminal between cs launches. - if [ -n "$claude_session_id" ]; then - continue_flag="--resume $claude_session_id" - else - continue_flag="--continue" - fi + # --resume , never --continue: the uuid names the exact + # conversation, while --continue means "most recent" and may + # resolve to a sibling Claude session the user ran in a + # different terminal between cs launches. A session reaching + # this prompt always has one (the unbound case started above). + continue_flag="--resume $claude_session_id" ;; esac echo "" @@ -792,8 +798,10 @@ EOF # and pass --session-id so cs stays bound to the new # conversation. Without rebind, next launch resumes the OLD # conversation while the fresh one becomes orphaned. - # - is_new=false with no claude_session_id (shouldn't happen - # post-Phase-8 but handled defensively): naked exec. + # - is_new=true with no claude_session_id (create_session_structure + # always writes one; handled defensively): naked exec. An + # is_new=false session with none never gets here: it started + # its first conversation before the resume prompt. if [ "$is_new" = "true" ] && [ -n "$claude_session_id" ]; then # shellcheck disable=SC2086 exec $CLAUDE_CODE_BIN --name "$session_name" --session-id "$claude_session_id" ${launch_prompt:+"$launch_prompt"} diff --git a/lib/85-adopt-uninstall.sh b/lib/85-adopt-uninstall.sh index 78d4ad82..59e1766b 100644 --- a/lib/85-adopt-uninstall.sh +++ b/lib/85-adopt-uninstall.sh @@ -121,7 +121,21 @@ adopt_session() { # create_session_structure writes CLAUDE.local.md, never CLAUDE.md — a # project's own CLAUDE.md is left untouched. + # + # It also stages a conversation id for a brand-new session's first launch. + # An adopted directory already exists, so its first open is a reopen, and a + # staged id made that open ask to continue a conversation that never + # existed. A first adoption keeps no id (the launch records one when it + # starts the first conversation); re-adopted records keep the conversation + # they name, which the staged id used to replace. + local prior_binding + prior_binding=$(_read_local_state "$target_dir/.cs/local/state" claude_session_id) create_session_structure "$target_dir" + if [ -n "$prior_binding" ]; then + _set_local_state "$target_dir/.cs/local/state" claude_session_id "$prior_binding" + else + _unset_local_state "$target_dir/.cs/local/state" claude_session_id + fi # An adopted session's name is the link's, not the directory's, and the link # is the only place it lives — so a hook that resolves this project by diff --git a/tests/test_adopt.sh b/tests/test_adopt.sh index 757a2d5d..4e2cbd44 100755 --- a/tests/test_adopt.sh +++ b/tests/test_adopt.sh @@ -380,6 +380,150 @@ test_adopt_gitignores_the_vault_mount() { assert_file_contains "$owned/.gitignore" "node_modules/" "the project's own entry stays" || return 1 } +# ============================================================================ +# First launch after adopt +# ============================================================================ + +UUID_PRIOR="33333333-3333-4333-8333-333333333333" + +# A claude stub that records each launch's argv, one line per launch, and fails +# a --resume at once, as claude does for an id that names no conversation. +_adopt_claude_stub() { + cat > "$TEST_TMPDIR/claude-stub" << SCRIPT +#!/bin/bash +printf '%s\n' "\$*" >> "$TEST_TMPDIR/claude-args" +case "\$*" in *--resume*) exit 1 ;; esac +exit 0 +SCRIPT + chmod +x "$TEST_TMPDIR/claude-stub" + export CLAUDE_CODE_BIN="$TEST_TMPDIR/claude-stub" +} + +_adopt_state_id() { # project_dir + awk '/^claude_session_id:/ { print $2; exit }' "$1/.cs/local/state" 2>/dev/null +} + +# An adopted directory already exists, so its first open is a reopen. It used to +# ask "Continue previous conversation?" for a conversation that never existed; +# the default answer's --resume then failed and a fallback started fresh. +test_first_launch_after_adopt_starts_fresh_without_asking() { + local project_dir="$TEST_TMPDIR/my-project" + mkdir -p "$project_dir" + (cd "$project_dir" && "$CS_BIN" -adopt probe >/dev/null 2>&1) + _adopt_claude_stub + + local output + output=$("$CS_BIN" probe <<< "" 2>&1) || true + + if grep -q "Continue previous conversation" <<< "$output"; then + echo " FAIL: the first launch must not offer to resume: $output"; return 1 + fi + if grep -q "No previous conversation found" <<< "$output"; then + echo " FAIL: the first launch must not go through the resume-failed fallback: $output"; return 1 + fi + assert_output_contains "$output" "(+ new)" "the card calls the first launch new" || return 1 + + local launches recorded + launches=$(cat "$TEST_TMPDIR/claude-args" 2>/dev/null) + assert_eq "1" "$(printf '%s\n' "$launches" | grep -c .)" "claude launches exactly once" || return 1 + recorded=$(_adopt_state_id "$project_dir") + [ -n "$recorded" ] || { echo " FAIL: the launch must record the conversation it starts"; return 1; } + assert_output_contains "$launches" "--session-id $recorded" "claude starts the recorded conversation" || return 1 + assert_output_contains "$launches" "--name probe" "the conversation is named after the session" || return 1 + if grep -qE -- '--resume|--continue' <<< "$launches"; then + echo " FAIL: nothing to resume, so no --resume or --continue: $launches"; return 1 + fi + if grep -q '"event":"rotated"' "$project_dir/.cs/timeline.jsonl" 2>/dev/null; then + echo " FAIL: the first conversation rotates from nothing"; return 1 + fi +} + +# A project Claude Code already ran in has a conversation to resume: the first +# open binds the newest one and asks, as before. +test_first_launch_after_adopt_offers_the_projects_newest_conversation() { + local project_dir="$TEST_TMPDIR/my-project" + mkdir -p "$project_dir" + local proj + proj="$CS_TRANSCRIPTS_DIR/$(cd "$project_dir" && pwd -P | tr '/.' '--')" + mkdir -p "$proj" + printf '{"type":"user","sessionId":"%s"}\n' "$UUID_PRIOR" > "$proj/$UUID_PRIOR.jsonl" + (cd "$project_dir" && "$CS_BIN" -adopt probe >/dev/null 2>&1) + _adopt_claude_stub + + local output + output=$("$CS_BIN" probe <<< "y" 2>&1) || true + assert_output_contains "$output" "Continue previous conversation?" "an existing conversation is offered" || return 1 + assert_output_contains "$(head -1 "$TEST_TMPDIR/claude-args")" "--resume $UUID_PRIOR" \ + "the answer resumes the project's conversation" || return 1 +} + +# The conversation the first launch recorded is the one the second resumes. +test_second_launch_after_adopt_asks_and_resumes() { + local project_dir="$TEST_TMPDIR/my-project" + mkdir -p "$project_dir" + (cd "$project_dir" && "$CS_BIN" -adopt probe >/dev/null 2>&1) + _adopt_claude_stub + "$CS_BIN" probe <<< "" >/dev/null 2>&1 || true + local first + first=$(_adopt_state_id "$project_dir") + # Claude writes the transcript once the conversation talks; without it + # migrate would treat the id as an orphan. + local proj + proj="$CS_TRANSCRIPTS_DIR/$(cd "$project_dir" && pwd -P | tr '/.' '--')" + mkdir -p "$proj" + printf '{"type":"user","sessionId":"%s"}\n' "$first" > "$proj/$first.jsonl" + : > "$TEST_TMPDIR/claude-args" + + local output + output=$("$CS_BIN" probe <<< "y" 2>&1) || true + assert_output_contains "$output" "Continue previous conversation?" "a bound session still asks" || return 1 + assert_output_contains "$(cat "$TEST_TMPDIR/claude-args")" "--resume $first" \ + "the answer resumes the conversation the first launch recorded" || return 1 +} + +# Re-adopting orphaned records keeps the conversation they name; it used to be +# replaced with a fresh id, losing the binding (the transcript stayed on disk). +test_readopt_keeps_the_prior_conversation_binding() { + local project_dir="$TEST_TMPDIR/my-project" + mkdir -p "$project_dir" + (cd "$project_dir" && "$CS_BIN" -adopt old-name >/dev/null 2>&1) + printf 'claude_session_id: %s\n' "$UUID_PRIOR" >> "$project_dir/.cs/local/state" + rm "$CS_SESSIONS_ROOT/old-name" + + (cd "$project_dir" && printf 'y\n' | CS_ASSUME_TTY=1 "$CS_BIN" -adopt new-name >/dev/null 2>&1) \ + || { echo " FAIL: re-adopt should succeed"; return 1; } + assert_eq "$UUID_PRIOR" "$(_adopt_state_id "$project_dir")" \ + "re-adopt keeps the recorded conversation" || return 1 + + _adopt_claude_stub + local output + output=$("$CS_BIN" new-name <<< "y" 2>&1) || true + assert_output_contains "$output" "Continue previous conversation?" "the kept binding still asks" || return 1 + assert_output_contains "$(head -1 "$TEST_TMPDIR/claude-args")" "--resume $UUID_PRIOR" \ + "the answer resumes the kept conversation" || return 1 +} + +# Records whose machine-local state did not travel (a clone of a project that +# tracks .cs/) name no conversation, so they open the way a first adoption does. +test_readopt_without_local_state_starts_fresh_without_asking() { + local project_dir="$TEST_TMPDIR/my-project" + mkdir -p "$project_dir" + (cd "$project_dir" && "$CS_BIN" -adopt old-name >/dev/null 2>&1) + rm "$CS_SESSIONS_ROOT/old-name" + rm -f "$project_dir/.cs/local/state" + + (cd "$project_dir" && printf 'y\n' | CS_ASSUME_TTY=1 "$CS_BIN" -adopt new-name >/dev/null 2>&1) \ + || { echo " FAIL: re-adopt should succeed"; return 1; } + _adopt_claude_stub + local output + output=$("$CS_BIN" new-name <<< "" 2>&1) || true + if grep -q "Continue previous conversation" <<< "$output"; then + echo " FAIL: records with no binding must not offer to resume: $output"; return 1 + fi + assert_output_contains "$(cat "$TEST_TMPDIR/claude-args")" "--session-id $(_adopt_state_id "$project_dir")" \ + "claude starts the recorded conversation" || return 1 +} + # ============================================================================ # Runner # ============================================================================ @@ -410,6 +554,13 @@ run_test test_adopt_inits_git_when_none_exists run_test test_adopt_into_git_repo_without_claude_md_stages_bookkeeping run_test test_adopt_commits_only_its_own_bookkeeping +# First launch after adopt +run_test test_first_launch_after_adopt_starts_fresh_without_asking +run_test test_first_launch_after_adopt_offers_the_projects_newest_conversation +run_test test_second_launch_after_adopt_asks_and_resumes +run_test test_readopt_keeps_the_prior_conversation_binding +run_test test_readopt_without_local_state_starts_fresh_without_asking + # README frontmatter run_test test_readme_has_yaml_frontmatter run_test test_readme_frontmatter_has_status diff --git a/tests/test_encrypt.sh b/tests/test_encrypt.sh index 8bd17a96..58a16cb4 100755 --- a/tests/test_encrypt.sh +++ b/tests/test_encrypt.sh @@ -463,8 +463,17 @@ test_open_that_stops_leaves_a_running_session_vault_mounted() { assert_eq "" "$(cat "$FAKE_HDIUTIL_LOG")" "joined the mount, never detached it" || return 1 } +# The resume prompt belongs to a session with a conversation to resume; one +# with none starts its first without asking. The fixture's state line is not in +# state-file form, so it records no conversation. +_bind_conversation() { # name + printf 'claude_session_id: %s\n' "33333333-3333-4333-8333-333333333333" \ + >> "$CS_SESSIONS_ROOT/$1/.cs/local/state" +} + test_open_cancelled_at_a_prompt_detaches_the_vault_it_mounted() { _encrypted_session enc || return 1 + _bind_conversation enc local out rc=0 out=$(_open enc 2>&1) || rc=$? assert_eq "130" "$rc" "no answer to 'Continue previous conversation?' cancels" || return 1 @@ -517,6 +526,7 @@ _claude_snapshot() { # runs, and cs, its last holder, detaches it once claude exits. test_open_resuming_keeps_the_vault_while_claude_runs() { _encrypted_session enc || return 1 + _bind_conversation enc local out rc=0 out=$(CLAUDE_CODE_BIN="$(_claude_snapshot)" _open enc "y " 2>&1) || rc=$? @@ -529,6 +539,7 @@ detach $FAKE_MNT" "$(cat "$FAKE_HDIUTIL_LOG")" "detached after claude exits" || # Fresh, cs execs claude: cs is gone, and the SessionEnd waiter owns the detach. test_open_fresh_leaves_the_detach_to_the_waiter() { _encrypted_session enc || return 1 + _bind_conversation enc local out rc=0 out=$(CLAUDE_CODE_BIN="$(_claude_snapshot)" _open enc "n " 2>&1) || rc=$? @@ -536,6 +547,17 @@ test_open_fresh_leaves_the_detach_to_the_waiter() { assert_eq "attach -nobrowse -mountpoint $FAKE_MNT $(_vault_path enc)" "$(cat "$FAKE_HDIUTIL_LOG")" "no detach from cs" || return 1 } +# A first open with no conversation to resume execs claude as a fresh start +# does, so the detach is the waiter's there too. +test_first_open_leaves_the_detach_to_the_waiter() { + _encrypted_session enc || return 1 + local out rc=0 + out=$(CLAUDE_CODE_BIN="$(_claude_snapshot)" _open enc 2>&1) || rc=$? + assert_eq "0" "$rc" "the open reaches claude without asking: $out" || return 1 + assert_output_not_contains "$out" "Continue previous conversation?" "nothing to resume, so no prompt" || return 1 + assert_eq "attach -nobrowse -mountpoint $FAKE_MNT $(_vault_path enc)" "$(cat "$FAKE_HDIUTIL_LOG")" "no detach from cs" || return 1 +} + # SessionEnd leaves a waiter that detaches the vault once the lead claude # exits. The "claude" here is a sleep the test ends; the vault reads mounted. HOOK_SESSION_END="$SCRIPT_DIR/../hooks/session-end.sh" @@ -678,6 +700,7 @@ run_test test_open_that_stops_leaves_a_vault_another_holder_keeps run_test test_open_handing_off_to_the_session_manager_leaves_the_holders run_test test_open_resuming_keeps_the_vault_while_claude_runs run_test test_open_fresh_leaves_the_detach_to_the_waiter +run_test test_first_open_leaves_the_detach_to_the_waiter run_test test_session_end_detaches_after_the_lead_exits run_test test_session_end_leaves_the_vault_for_clear_resume_and_non_leads run_test test_session_end_ignores_a_session_cs_did_not_encrypt diff --git a/tests/test_uuid.sh b/tests/test_uuid.sh index 781769c1..7c06847a 100755 --- a/tests/test_uuid.sh +++ b/tests/test_uuid.sh @@ -127,8 +127,10 @@ EOF assert_file_not_contains "$session_dir/.cs/local/state" "^claude_session_id:" \ "precondition: legacy session must lack claude_session_id" || return 1 - # First resume backfills. - "$CS_BIN" legacy-session <<< "" >/dev/null 2>&1 || true + # First open backfills. With no transcript there is nothing to resume, so + # it does not ask: it starts the first conversation and records its id. + local output + output=$("$CS_BIN" legacy-session <<< "" 2>&1) || true assert_file_contains "$session_dir/.cs/local/state" "^claude_session_id:" \ "lazy migration should backfill claude_session_id" || return 1 @@ -140,6 +142,12 @@ EOF echo " FAIL: backfilled value is not a valid v4 UUID: '$backfilled'" return 1 fi + if grep -q "Continue previous conversation" <<< "$output"; then + echo " FAIL: a session with no conversation must not offer to resume: $output" + return 1 + fi + assert_output_contains "$output" "--session-id $backfilled" \ + "the first open starts the conversation it records" || return 1 # Second resume must be idempotent: same value, no duplication. "$CS_BIN" legacy-session <<< "" >/dev/null 2>&1 || true diff --git a/tests/test_worktrees.sh b/tests/test_worktrees.sh index ea953ca3..e1cbf830 100755 --- a/tests/test_worktrees.sh +++ b/tests/test_worktrees.sh @@ -1997,6 +1997,11 @@ test_finish_yields_to_an_explicit_rotation_choice() { local base_dir base_dir=$(create_test_session_with_git "myproj") cs_launch "myproj@fix-auth" + # The prompt belongs to a session with a conversation to resume; one with + # none starts its first without asking. Record the one the handoff's + # parent names. + mkdir -p "$base_dir/.cs/local" + printf 'claude_session_id: 00000000-0000-4000-8000-000000000000\n' >> "$base_dir/.cs/local/state" mkdir -p "$base_dir/.cs/handoffs" cat > "$base_dir/.cs/handoffs/2026-07-16-test.md" << 'EOF' --- From 5d5e192d2f5e8d3497156de2f6615fc1bb8487e0 Mon Sep 17 00:00:00 2001 From: "bogdan.gherghina" Date: Sat, 3 Oct 2026 17:16:55 +0300 Subject: [PATCH 2/3] launch: a recorded conversation id reaches claude only when it is a UUID; 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 --- CHANGELOG.md | 1 + bin/cs | 18 +++++- lib/40-state.sh | 7 +++ lib/45-migrate.sh | 4 +- lib/75-launch.sh | 5 +- lib/85-adopt-uninstall.sh | 2 +- tests/test_adopt.sh | 48 ++++++++++++++++ tests/test_local_state.sh | 113 ++++++++++++++++++++++++++++++++++++++ tests/test_uuid.sh | 23 ++++++++ 9 files changed, 215 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 738f70fa..74b558fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to cs are documented here. Release notes are also available ### Fixes - The first `cs ` after `cs -adopt` no longer asks "Continue previous conversation?" in a project with no Claude Code conversation to resume. `cs -adopt` recorded an id for a conversation that did not exist yet, so the first open offered to resume it, the resume failed, and cs started fresh with "No previous conversation found". That open now starts a new conversation without asking, records it, and the launch card says `new`. A project Claude Code already ran in still opens on its newest conversation. Re-adopting the records a removed session left behind keeps the conversation they name; it used to be replaced with a new id, so the next open no longer resumed it. +- cs hands `claude` a recorded conversation id only when it is a UUID. On the first open of a cloned session or an adopted project, a `claude_session_id:` line in the committed `.cs/README.md` was copied into machine-local state without a check, and the resume prompt passed it to `claude` word by word, so the repo could put options such as `--dangerously-skip-permissions` on the launch command. The README import, re-adopt's kept binding and the launch now take only a UUID; anything else counts as no conversation, and the open starts a new one. ## 2026.10.1 diff --git a/bin/cs b/bin/cs index 274dc883..a3d7720d 100755 --- a/bin/cs +++ b/bin/cs @@ -2869,6 +2869,13 @@ _alloc_uuid() { fi } +# A conversation id goes onto claude's command line, and the README a clone or an +# adopted project brings can say anything, so only a UUID counts as one. Same +# pattern as hooks/session-start.sh's UUID_RE. +_is_uuid() { + [[ "$1" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]] +} + # The 8 colors claude's /color slash command accepts (verified against the # binary's own error message in claude 2.1.162). Anything else errors with # "Invalid color X". Notably absent: teal, magenta, white, black, gray, hex. @@ -3834,7 +3841,9 @@ migrate_session() { local _legacy_uuid _legacy_color _legacy_uuid=$(awk '/^claude_session_id:/ { sub(/^claude_session_id:[[:space:]]*/, ""); gsub(/["\r]/, ""); print; exit }' "$readme") _legacy_color=$(awk '/^claude_session_color:/ { sub(/^claude_session_color:[[:space:]]*/, ""); gsub(/["\r]/, ""); print; exit }' "$readme") - if [ -n "$_legacy_uuid" ] && [ -z "$(_read_local_state "$_state" claude_session_id)" ]; then + # The README is whatever the clone or the adopted project committed, so + # only a UUID is taken as a conversation id. + if _is_uuid "$_legacy_uuid" && [ -z "$(_read_local_state "$_state" claude_session_id)" ]; then _set_local_state "$_state" claude_session_id "$_legacy_uuid" fi if [ -n "$_legacy_color" ] && [ -z "$(_read_local_state "$_state" claude_session_color)" ]; then @@ -8359,9 +8368,12 @@ launch_claude_code() { # transcript on disk). Used for both the CS_CLAUDE_SESSION_ID env export # below and for the spawn args at exec time. Empty on an existing session # that has never had a conversation, such as the first open after - # cs -adopt; the launch below starts one and records it. + # cs -adopt; the launch below starts one and records it. A value that is + # not a UUID (written before ids were checked, or by hand) names no + # conversation and counts as empty. local claude_session_id claude_session_color claude_session_id=$(_read_local_state "$session_dir/.cs/local/state" claude_session_id) + _is_uuid "$claude_session_id" || claude_session_id="" claude_session_color=$(_read_local_state "$session_dir/.cs/local/state" claude_session_color) # Build the trailing positional prompt arg that applies the session's @@ -9099,7 +9111,7 @@ adopt_session() { local prior_binding prior_binding=$(_read_local_state "$target_dir/.cs/local/state" claude_session_id) create_session_structure "$target_dir" - if [ -n "$prior_binding" ]; then + if _is_uuid "$prior_binding"; then _set_local_state "$target_dir/.cs/local/state" claude_session_id "$prior_binding" else _unset_local_state "$target_dir/.cs/local/state" claude_session_id diff --git a/lib/40-state.sh b/lib/40-state.sh index 6821c281..ba8f76c2 100644 --- a/lib/40-state.sh +++ b/lib/40-state.sh @@ -13,6 +13,13 @@ _alloc_uuid() { fi } +# A conversation id goes onto claude's command line, and the README a clone or an +# adopted project brings can say anything, so only a UUID counts as one. Same +# pattern as hooks/session-start.sh's UUID_RE. +_is_uuid() { + [[ "$1" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ ]] +} + # The 8 colors claude's /color slash command accepts (verified against the # binary's own error message in claude 2.1.162). Anything else errors with # "Invalid color X". Notably absent: teal, magenta, white, black, gray, hex. diff --git a/lib/45-migrate.sh b/lib/45-migrate.sh index 0afc1033..2dbc72cb 100644 --- a/lib/45-migrate.sh +++ b/lib/45-migrate.sh @@ -672,7 +672,9 @@ migrate_session() { local _legacy_uuid _legacy_color _legacy_uuid=$(awk '/^claude_session_id:/ { sub(/^claude_session_id:[[:space:]]*/, ""); gsub(/["\r]/, ""); print; exit }' "$readme") _legacy_color=$(awk '/^claude_session_color:/ { sub(/^claude_session_color:[[:space:]]*/, ""); gsub(/["\r]/, ""); print; exit }' "$readme") - if [ -n "$_legacy_uuid" ] && [ -z "$(_read_local_state "$_state" claude_session_id)" ]; then + # The README is whatever the clone or the adopted project committed, so + # only a UUID is taken as a conversation id. + if _is_uuid "$_legacy_uuid" && [ -z "$(_read_local_state "$_state" claude_session_id)" ]; then _set_local_state "$_state" claude_session_id "$_legacy_uuid" fi if [ -n "$_legacy_color" ] && [ -z "$(_read_local_state "$_state" claude_session_color)" ]; then diff --git a/lib/75-launch.sh b/lib/75-launch.sh index 14558800..5f8bd69e 100644 --- a/lib/75-launch.sh +++ b/lib/75-launch.sh @@ -230,9 +230,12 @@ launch_claude_code() { # transcript on disk). Used for both the CS_CLAUDE_SESSION_ID env export # below and for the spawn args at exec time. Empty on an existing session # that has never had a conversation, such as the first open after - # cs -adopt; the launch below starts one and records it. + # cs -adopt; the launch below starts one and records it. A value that is + # not a UUID (written before ids were checked, or by hand) names no + # conversation and counts as empty. local claude_session_id claude_session_color claude_session_id=$(_read_local_state "$session_dir/.cs/local/state" claude_session_id) + _is_uuid "$claude_session_id" || claude_session_id="" claude_session_color=$(_read_local_state "$session_dir/.cs/local/state" claude_session_color) # Build the trailing positional prompt arg that applies the session's diff --git a/lib/85-adopt-uninstall.sh b/lib/85-adopt-uninstall.sh index 59e1766b..307c78a9 100644 --- a/lib/85-adopt-uninstall.sh +++ b/lib/85-adopt-uninstall.sh @@ -131,7 +131,7 @@ adopt_session() { local prior_binding prior_binding=$(_read_local_state "$target_dir/.cs/local/state" claude_session_id) create_session_structure "$target_dir" - if [ -n "$prior_binding" ]; then + if _is_uuid "$prior_binding"; then _set_local_state "$target_dir/.cs/local/state" claude_session_id "$prior_binding" else _unset_local_state "$target_dir/.cs/local/state" claude_session_id diff --git a/tests/test_adopt.sh b/tests/test_adopt.sh index 4e2cbd44..a79bc34e 100755 --- a/tests/test_adopt.sh +++ b/tests/test_adopt.sh @@ -524,6 +524,52 @@ test_readopt_without_local_state_starts_fresh_without_asking() { "claude starts the recorded conversation" || return 1 } +# A project that commits .cs/ brings its README frontmatter along. Adopt leaves +# the conversation slot empty, so the first open's migration imported the +# README's claude_session_id and the resume prompt passed it to claude unquoted: +# whoever wrote the project chose words on claude's command line. +test_adopt_ignores_a_committed_readme_id_that_is_not_a_uuid() { + local project_dir="$TEST_TMPDIR/my-project" + mkdir -p "$project_dir/.cs" + printf -- '---\nstatus: active\nclaude_session_id: --dangerously-skip-permissions --model x\n---\n# Session\n' \ + > "$project_dir/.cs/README.md" + (cd "$project_dir" && printf 'y\n' | CS_ASSUME_TTY=1 "$CS_BIN" -adopt probe >/dev/null 2>&1) \ + || { echo " FAIL: re-adopt should succeed"; return 1; } + _adopt_claude_stub + + local output + output=$("$CS_BIN" probe <<< "" 2>&1) || true + + if grep -q "Continue previous conversation" <<< "$output"; then + echo " FAIL: an id that is not a UUID must not be offered for resume: $output"; return 1 + fi + local launches recorded + launches=$(cat "$TEST_TMPDIR/claude-args" 2>/dev/null) + assert_eq "1" "$(printf '%s\n' "$launches" | grep -c .)" "claude launches exactly once" || return 1 + if grep -q -- '--dangerously-skip-permissions' <<< "$launches"; then + echo " FAIL: the README's words reached claude's argv: $launches"; return 1 + fi + recorded=$(_adopt_state_id "$project_dir") + [[ "$recorded" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] \ + || { echo " FAIL: the open must record a real conversation id: '$recorded'"; return 1; } + assert_output_contains "$launches" "--session-id $recorded" "claude starts the recorded conversation" || return 1 +} + +# Re-adopt puts back the conversation the records name, and only a conversation +# id: words in the slot name nothing to resume. +test_readopt_drops_a_prior_binding_that_is_not_a_uuid() { + local project_dir="$TEST_TMPDIR/my-project" + mkdir -p "$project_dir" + (cd "$project_dir" && "$CS_BIN" -adopt old-name >/dev/null 2>&1) + printf 'claude_session_id: --dangerously-skip-permissions\n' >> "$project_dir/.cs/local/state" + rm "$CS_SESSIONS_ROOT/old-name" + + (cd "$project_dir" && printf 'y\n' | CS_ASSUME_TTY=1 "$CS_BIN" -adopt new-name >/dev/null 2>&1) \ + || { echo " FAIL: re-adopt should succeed"; return 1; } + assert_eq "" "$(_adopt_state_id "$project_dir")" \ + "re-adopt keeps no id when the prior one is not a UUID" || return 1 +} + # ============================================================================ # Runner # ============================================================================ @@ -560,6 +606,8 @@ run_test test_first_launch_after_adopt_offers_the_projects_newest_conversation run_test test_second_launch_after_adopt_asks_and_resumes run_test test_readopt_keeps_the_prior_conversation_binding run_test test_readopt_without_local_state_starts_fresh_without_asking +run_test test_adopt_ignores_a_committed_readme_id_that_is_not_a_uuid +run_test test_readopt_drops_a_prior_binding_that_is_not_a_uuid # README frontmatter run_test test_readme_has_yaml_frontmatter diff --git a/tests/test_local_state.sh b/tests/test_local_state.sh index 6befa7a1..c32bd8a0 100755 --- a/tests/test_local_state.sh +++ b/tests/test_local_state.sh @@ -217,6 +217,116 @@ EOF assert_file_contains "$session_dir/.cs/README.md" '^aliases: \["legacy-frontmatter"\]' || return 1 } +# ============================================================================ +# Cycle 3a: a conversation id that is not a UUID never reaches claude +# ============================================================================ + +HOSTILE_ID="--dangerously-skip-permissions --model x" + +# A claude stub that records each launch's argv, one line per launch. +_argv_claude_stub() { + cat > "$TEST_TMPDIR/claude-stub" << SCRIPT +#!/bin/bash +printf '%s\n' "\$*" >> "$TEST_TMPDIR/claude-args" +exit 0 +SCRIPT + chmod +x "$TEST_TMPDIR/claude-stub" + export CLAUDE_CODE_BIN="$TEST_TMPDIR/claude-stub" +} + +# A shared session cloned into the sessions folder: the README travels with it, +# .cs/local does not. +_hostile_readme_session() { # name + local session_dir="$CS_SESSIONS_ROOT/$1" + mkdir -p "$session_dir/.cs/memory" + printf -- '---\nstatus: active\nclaude_session_id: %s\naliases: ["%s"]\n---\n# Session: %s\n' \ + "$HOSTILE_ID" "$1" "$1" > "$session_dir/.cs/README.md" + echo "# Session narrative" > "$session_dir/.cs/memory/narrative.md" + echo "# Session" > "$session_dir/CLAUDE.md" + (cd "$session_dir" && git init -q && git add -A && git commit -q -m "init") + echo "$session_dir" +} + +_assert_uuid() { # value, message + [[ "$1" =~ $UUID_V4_RE ]] || { echo " FAIL: $2: '$1'"; return 1; } +} + +# Phase 12 imported the README's claude_session_id verbatim, and the resume +# prompt passed it to claude unquoted, so a committed README chose words on +# claude's command line. A value that is not a UUID names no conversation: the +# open starts the first one, as for a session with no id at all. +test_clone_with_a_readme_id_that_is_not_a_uuid_starts_fresh() { + local session_dir + session_dir=$(_hostile_readme_session hostile-clone) + _argv_claude_stub + + local output + output=$("$CS_BIN" hostile-clone <<< "" 2>&1) || true + + if grep -q "Continue previous conversation" <<< "$output"; then + echo " FAIL: an id that is not a UUID must not be offered for resume: $output"; return 1 + fi + local launches recorded + launches=$(cat "$TEST_TMPDIR/claude-args" 2>/dev/null) + assert_eq "1" "$(printf '%s\n' "$launches" | grep -c .)" "claude launches exactly once" || return 1 + if grep -q -- '--dangerously-skip-permissions' <<< "$launches"; then + echo " FAIL: the README's words reached claude's argv: $launches"; return 1 + fi + recorded=$(_extract_state_value "$session_dir/.cs/local/state" claude_session_id) + _assert_uuid "$recorded" "the open records a real conversation id" || return 1 + assert_output_contains "$launches" "--session-id $recorded" "claude starts the recorded conversation" || return 1 + _assert_readme_clean "$session_dir/.cs/README.md" || return 1 +} + +# The same clone on a machine where claude already ran in the folder. Phase 8 +# binds the newest transcript; had Phase 12 imported the README value first, +# Phase 8 would print it back to the terminal as the orphan it repaired. +test_migration_never_records_a_readme_id_that_is_not_a_uuid() { + local session_dir + session_dir=$(_hostile_readme_session hostile-history) + local proj uuid="44444444-4444-4444-8444-444444444444" + proj="$CS_TRANSCRIPTS_DIR/$(cd "$session_dir" && pwd -P | tr '/.' '--')" + mkdir -p "$proj" + printf '{"type":"user","sessionId":"%s"}\n' "$uuid" > "$proj/$uuid.jsonl" + _argv_claude_stub + + local output + output=$("$CS_BIN" hostile-history <<< "" 2>&1) || true + + if grep -q -- '--dangerously-skip-permissions' <<< "$output"; then + echo " FAIL: the README's value was recorded and echoed: $output"; return 1 + fi + assert_eq "$uuid" "$(_extract_state_value "$session_dir/.cs/local/state" claude_session_id)" \ + "the folder's own conversation is bound" || return 1 + assert_output_contains "$(cat "$TEST_TMPDIR/claude-args")" "--resume $uuid" \ + "the open resumes the folder's conversation" || return 1 +} + +# Local state written before ids were checked (an import by an earlier cs, a +# hand edit) can already hold a value that is not a UUID. The launch treats it +# as no id: it starts the first conversation and records a real id over it. +test_launch_ignores_a_recorded_id_that_is_not_a_uuid() { + local session_dir + session_dir=$(create_test_session_with_git recorded-junk) + printf 'claude_session_id: %s\n' "$HOSTILE_ID" > "$session_dir/.cs/local/state" + _argv_claude_stub + + local output + output=$("$CS_BIN" recorded-junk <<< "" 2>&1) || true + + if grep -q "Continue previous conversation" <<< "$output"; then + echo " FAIL: an id that is not a UUID must not be offered for resume: $output"; return 1 + fi + local launches recorded + launches=$(cat "$TEST_TMPDIR/claude-args" 2>/dev/null) + if grep -q -- '--dangerously-skip-permissions' <<< "$launches"; then + echo " FAIL: the recorded words reached claude's argv: $launches"; return 1 + fi + recorded=$(_extract_state_value "$session_dir/.cs/local/state" claude_session_id) + _assert_uuid "$recorded" "a real id replaces the recorded words" || return 1 + assert_output_contains "$launches" "--session-id $recorded" "claude starts the recorded conversation" || return 1 +} + # ============================================================================ # Cycle 3b: migration relocates the session log to machine-local .cs/local/ # ============================================================================ @@ -443,6 +553,9 @@ run_test test_resume_leaves_readme_untouched run_test test_migration_leaves_a_body_line_that_looks_like_a_field run_test test_migration_readme_survives_a_failed_frontmatter_write run_test test_migration_moves_fields_from_readme_to_local_state +run_test test_clone_with_a_readme_id_that_is_not_a_uuid_starts_fresh +run_test test_migration_never_records_a_readme_id_that_is_not_a_uuid +run_test test_launch_ignores_a_recorded_id_that_is_not_a_uuid run_test test_migration_moves_session_log_to_local run_test test_session_start_rebinds_uuid_in_local_state run_test test_session_start_stamps_the_context_date_for_this_conversation diff --git a/tests/test_uuid.sh b/tests/test_uuid.sh index 7c06847a..b79701d6 100755 --- a/tests/test_uuid.sh +++ b/tests/test_uuid.sh @@ -279,11 +279,34 @@ test_lazy_migration_preserves_uuid_when_transcript_matches() { "recorded UUID with a matching transcript must not be rewritten" || return 1 } +# A recorded conversation id is passed to claude as an argument, so only a UUID +# counts as one; everything else is treated as no id at all. +test_is_uuid_accepts_only_a_uuid() { + ( + # shellcheck source=lib/40-state.sh + source "$SCRIPT_DIR/../lib/40-state.sh" + _is_uuid "abcd1234-5678-4abc-9def-fedcba987654" || { echo " FAIL: a v4 UUID is a UUID"; exit 1; } + _is_uuid "ABCD1234-5678-4ABC-9DEF-FEDCBA987654" || { echo " FAIL: so is an upper-case one"; exit 1; } + local bad + for bad in "" "--dangerously-skip-permissions" \ + "abcd1234-5678-4abc-9def-fedcba987654 --model x" \ + "--resume abcd1234-5678-4abc-9def-fedcba987654" \ + "abcd1234-5678-4abc-9def-fedcba98765" \ + "abcd1234-5678-4abc-9def-fedcba98765g" \ + $'abcd1234-5678-4abc-9def-fedcba987654\n--model'; do + if _is_uuid "$bad"; then + echo " FAIL: accepted '$bad'"; exit 1 + fi + done + ) +} + # ============================================================================ # Runner # ============================================================================ echo "Running test_uuid.sh" echo "" +run_test test_is_uuid_accepts_only_a_uuid run_test test_new_session_allocates_and_records_uuid run_test test_resume_uses_recorded_uuid run_test test_lazy_migration_backfills_uuid From 598441a36d8652d467cb4b6f2ddd94e9614a8f63 Mon Sep 17 00:00:00 2001 From: "bogdan.gherghina" Date: Sat, 3 Oct 2026 17:23:05 +0300 Subject: [PATCH 3/3] launch: the resume answer passes the conversation id to claude as one quoted argument instead of an unquoted --resume string (review T2) continue_flag held "--resume " 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 --- bin/cs | 17 +++++++++-------- lib/75-launch.sh | 17 +++++++++-------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/bin/cs b/bin/cs index a3d7720d..99402c1c 100755 --- a/bin/cs +++ b/bin/cs @@ -8755,8 +8755,9 @@ EOF exec $CLAUDE_CODE_BIN --name "$session_name" --session-id "$claude_session_id" ${launch_prompt:+"$launch_prompt"} fi - # For existing sessions, ask if user wants to continue previous conversation - local continue_flag="" + # For existing sessions, ask if user wants to continue previous conversation. + # The answer sets the id to resume; it goes to claude as one quoted argument. + local resume_id="" if [ "$is_new" = "false" ]; then # cs records only the conversation it launched, so one started any other # way on this folder — a `/desktop` handoff, a claude opened on the @@ -8856,7 +8857,7 @@ EOF case "$response" in [nN]|[nN][oO]) _disarm_rotation_marker "$session_dir" "$pending_handoff" - continue_flag="" + resume_id="" ;; [rR]) if [ -n "$pending_handoff" ]; then @@ -8874,7 +8875,7 @@ EOF # r without a pending handoff was never offered: treat as the # default resume answer, disarm included. _disarm_rotation_marker "$session_dir" - continue_flag="--resume $claude_session_id" + resume_id="$claude_session_id" ;; [dD]) # Nothing survives d: it retires the handoff it was offered, and @@ -8897,7 +8898,7 @@ EOF fi # d without a pending handoff was never offered: treat as the # default resume answer. - continue_flag="--resume $claude_session_id" + resume_id="$claude_session_id" ;; *) # Also the unattended spawn path, which takes this default @@ -8908,18 +8909,18 @@ EOF # resolve to a sibling Claude session the user ran in a # different terminal between cs launches. A session reaching # this prompt always has one (the unbound case started above). - continue_flag="--resume $claude_session_id" + resume_id="$claude_session_id" ;; esac echo "" fi - if [ -n "$continue_flag" ]; then + if [ -n "$resume_id" ]; then # Try continuing previous conversation SECONDS=0 local rc=0 # shellcheck disable=SC2086 - $CLAUDE_CODE_BIN --name "$session_name" $continue_flag ${launch_prompt:+"$launch_prompt"} || rc=$? + $CLAUDE_CODE_BIN --name "$session_name" --resume "$resume_id" ${launch_prompt:+"$launch_prompt"} || rc=$? if [ $rc -ne 0 ] && [ $SECONDS -lt 3 ]; then # Quick failure suggests no conversation to continue. Rebind so # the fresh transcript claude is about to create is tracked by diff --git a/lib/75-launch.sh b/lib/75-launch.sh index 5f8bd69e..2d06b65a 100644 --- a/lib/75-launch.sh +++ b/lib/75-launch.sh @@ -617,8 +617,9 @@ EOF exec $CLAUDE_CODE_BIN --name "$session_name" --session-id "$claude_session_id" ${launch_prompt:+"$launch_prompt"} fi - # For existing sessions, ask if user wants to continue previous conversation - local continue_flag="" + # For existing sessions, ask if user wants to continue previous conversation. + # The answer sets the id to resume; it goes to claude as one quoted argument. + local resume_id="" if [ "$is_new" = "false" ]; then # cs records only the conversation it launched, so one started any other # way on this folder — a `/desktop` handoff, a claude opened on the @@ -718,7 +719,7 @@ EOF case "$response" in [nN]|[nN][oO]) _disarm_rotation_marker "$session_dir" "$pending_handoff" - continue_flag="" + resume_id="" ;; [rR]) if [ -n "$pending_handoff" ]; then @@ -736,7 +737,7 @@ EOF # r without a pending handoff was never offered: treat as the # default resume answer, disarm included. _disarm_rotation_marker "$session_dir" - continue_flag="--resume $claude_session_id" + resume_id="$claude_session_id" ;; [dD]) # Nothing survives d: it retires the handoff it was offered, and @@ -759,7 +760,7 @@ EOF fi # d without a pending handoff was never offered: treat as the # default resume answer. - continue_flag="--resume $claude_session_id" + resume_id="$claude_session_id" ;; *) # Also the unattended spawn path, which takes this default @@ -770,18 +771,18 @@ EOF # resolve to a sibling Claude session the user ran in a # different terminal between cs launches. A session reaching # this prompt always has one (the unbound case started above). - continue_flag="--resume $claude_session_id" + resume_id="$claude_session_id" ;; esac echo "" fi - if [ -n "$continue_flag" ]; then + if [ -n "$resume_id" ]; then # Try continuing previous conversation SECONDS=0 local rc=0 # shellcheck disable=SC2086 - $CLAUDE_CODE_BIN --name "$session_name" $continue_flag ${launch_prompt:+"$launch_prompt"} || rc=$? + $CLAUDE_CODE_BIN --name "$session_name" --resume "$resume_id" ${launch_prompt:+"$launch_prompt"} || rc=$? if [ $rc -ne 0 ] && [ $SECONDS -lt 3 ]; then # Quick failure suggests no conversation to continue. Rebind so # the fresh transcript claude is about to create is tracked by