Skip to content

feat(freshclaude): lifecycle truth — the sidecar exits when its stream ends, exited status with Resume, and created only after the CLI boots (core PR-B) - #865

Merged
danshapiro merged 25 commits into
mainfrom
feat/freshclaude-core-lifecycle
Oct 23, 2026
Merged

danshapiro merged 25 commits into
mainfrom
feat/freshclaude-core-lifecycle

Conversation

@danshapiro

Copy link
Copy Markdown
Owner

Core PR-B of the freshclaude core-runtime redesign (design, §10 PR-B: C08, C09, C10). Core PR-A merged as #863; this PR's diff is PR-B only.

What users get

  • When a Claude process crashes or its session ends, the pane no longer stays busy and no longer swallows messages without a word. It shows that the session ended. If a turn was cut off, it rings once. A send to an ended session is refused visibly, and the text goes back into the composer.
  • The ended pane offers Resume session for freshclaude and kilroy. Resume brings the conversation back without a page reload. Reloading the page also recovers it.
  • A resume that cannot start (for example, the conversation no longer exists) fails clearly with a typed reason. Before, you got a pane that never answered.
  • A pane is reported as created only after the Claude CLI has actually started. created now arrives about 0.7 s later (median 936/940 ms fresh/resume on the pinned CLI, against 269/256 ms before), inside the approved 0.6–1.5 s range. The first reply is not slower: create to the first turn's result went from 1104/1056 ms to 1026/1042 ms.

What changed

  1. C08: the sidecar exits when its SDK stream ends (G02, G23). The sidecar reports how the stream ended (sdk.stream.ended: cli_exited, stream_error or stream_eof) as its last line and exits with code 3. A requested stop still exits 0 and reports nothing. The server no longer expects a result followed by an idle at the end of each turn (result_idle_pair_pending is gone). The fakes now end a turn the way the real sidecar does.
  2. C09: exited status, dead-session record and Resume (G05). A session whose sidecar ended on its own keeps a record (claude_dead_sessions.rs, at most 256). Its snapshot reads exited, a send to it gets the lost-session frame plus a request error, and resuming or closing it clears the record. The pane gets freshAgent.status{exited} and then SIDECAR_EXITED, with the cause. The client's ended card offers Resume for claude/kilroy.
  3. C10: created only after the CLI boots (protocol v2). create carries protocol: 2 and a boot object (fresh, resume or fork; boot.mjs, claude_launch.rs create_line()). The sidecar answers created once initializationResult() resolves. A refused boot is a typed create.failed (CONVERSATION_NOT_FOUND, RESUME_POINT_NOT_FOUND, FORK_GUARD_REFUSED, SESSION_ID_IN_USE, BOOT_FAILED, SIDECAR_PROTOCOL_MISMATCH) followed by exit code 2. On the Rust side the handshake lives in claude_handshake.rs. The command catalog is published from the boot.
  4. Review fixes on C08–C10:
    1. The CLI's terminal-only commands (doctor, color) are left out of the boot catalog.
    2. A session is registered under the sessions-map lock before its consumer reads a line, so the boot catalog and an early CLI death are never dropped.
    3. A resume that lands while the dead sidecar is being stopped gets no exit report, but the cut-off turn still rings once.
    4. The fake kilroy runtime ends a turn with its result and no idle.
    5. A crashed kilroy pane recovers through a reload too (e2e).
    6. The dead-session helpers and the create line moved out of claude.rs.
    7. Comments no longer say the real sidecar sends an idle.
    8. The exit banner now ends with "Resume the session to continue."
  5. One teardown path. Both a requested stop (a pane kill, a rollback replacement, server shutdown) and a sidecar that ended on its own go through SidecarProcesses::stop in claude.rs. It runs a graceful stop, the /proc ownership reap, and the whole-tree death confirmation. A requested stop is silent. For an unrequested end:
    1. The dead record is written first, so a resume during the stop can clear it.
    2. The exit report goes out only after the stop has confirmed the old tree gone, within its bounded window.
    3. A tree that outlives that window keeps being killed in the background. The report does not wait for it: on Linux such a process has outlived SIGKILL and runs no more code.
    4. When the stop itself had to kill node, that signal is not reported as the cause.
    5. The background kill now backs off between attempts (0.5 s, doubling, at most 1 min) instead of rescanning /proc every 25 ms forever. It logs teardown_escalation_confirmed when the tree dies.
    6. claude_sidecar_exit::reap is deleted.
  6. Finisher fixes from the final review:
    1. The default-model e2e spec (from PR-A) and the unreadable-store rollback test now read the v2 boot fields. Before, the spec could never match the resumed create, and the rollback test's no-fork check passed whatever ran.
    2. New tests pin the teardown path in item 5 and the sidecar's exit code when it loses stdout while exiting.
    3. The comments now say exactly what the unrequested-end report waits for.

Tests

All run on the final head:

  • cargo test -p freshell-freshagent: 1440 unit tests passed, plus the integration targets (8 sidecar-teardown, 1 interrupt-dispatch, 2 transcript-export). This includes the new claude::end_of_output_tests (5 tests):

    1. A stream end is reported only once its whole tree is dead: node, a CLI stand-in in node's process group, and a TERM-immune stand-in that left the group with setsid. The report comes once and rings nothing.
    2. A node killed mid-turn reports SIGKILL as its cause and rings once.
    3. When the stop had to kill a wedged node, the cause names no signal.
    4. A handle_kill of the same sidecar kills the same tree and reports nothing.
    5. A tree the stop cannot confirm gone is reported, then killed in the background.

    It also includes the escalation backoff tests. Checked by mutation: reporting before the stop fails tests 1–3, dropping the forced-stop filter fails test 3, a report that waits for the background kill fails test 5, and removing the backoff pause fails the backoff test.

  • freshell-ws: freshagent_claude_lifecycle 12, freshagent_claude_real_cli 9, freshagent_claude_checkpointed_send 4, freshagent_claude_rollback 7, freshagent_claude_attach 8, freshagent_session_lease 9. All passed.

  • freshell-server safe11_term22_shutdown_reaping: 3 passed.

  • Sidecar Vitest (test/unit/claude-sidecar/ plus the provider claude-sidecar-shutdown, claude-cli-sim, claude-cli-contract and checkpoint-uuid-replay suites): 21 files, 199 tests passed. Two new tests cover a sidecar whose stream ended or whose boot was refused after its stdout reader went away. It keeps exit code 3 or 2 (0 without the guard).

  • Cloud e2e (chromium), 43 passed, 0 failed:

    • fresh-agent-error-turn-attention: 5
    • fresh-agent-control-rust: 25
    • fresh-agent-rollback-rust: 9
    • freshclaude-restart-parity-rust: 2
    • freshagent-default-model-rust: 2

Known interim limits

  • A send that reaches the sidecar after its stream ended is ignored by the sidecar until C18. The server already refuses sends to a session it knows has ended.
  • The real-CLI lifecycle lane (crash, resume and stop against the pinned CLI) comes in C29. Today's lifecycle tests use the CLI simulator and Node fakes.
  • While the stop of an ended sidecar runs, a reloaded pane already reads exited and may resume. This window is normally milliseconds. A sidecar that closed its output but kept running gets the 2 s grace period before it is killed.

🤖 Generated with Claude Code

danshapiro and others added 25 commits October 22, 2026 12:47
…exits 0

The simulator turned every CLI exit into a thrown "Claude Code process
exited with code N" error, including code 0. The pinned SDK does not: its
process transport reports no error for exit code 0 (getProcessExitError in
sdk.mjs), so the message iterator simply ends. That natural end of stream is
the case the sidecar must handle as a session end (G23, design C08), and the
simulator could not produce it.

Now a `crash { code: 0 }` step ends the stream with no error and no result,
like the real SDK. Other codes and signals still throw the SDK's messages.

Evidence, from the pinned CLI 2.1.237 against the local endpoint (probes run
through claude-cli-scenarios.mjs, not committed): SIGINT mid-turn makes the
CLI report an aborted result, exit 0, and the iterator end with no error;
SIGTERM mid-turn makes it exit 143 and the iterator throw "Claude Code
process exited with code 143".

Red: claude-cli-sim.test.ts "a CLI that exits with code 0 mid-turn ends the
stream cleanly" failed with { error: 'Claude Code process exited with code 0' }.
Green: claude-cli-sim.test.ts and test/unit/claude-sidecar (117 tests); the
real-CLI contract and conformance suite still passes (39 tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Design C08 (docs/plans/2026-10-22-freshclaude-core-runtime.md §5.1, §5.7,
§10 PR-B). Each sidecar hosts one session, but when that session's SDK
stream ended (the CLI died, or it exited 0) the Node sidecar stayed alive
with no session: the server never evicted it, later sends got "session not
found", and nothing recovered (G02). And the server assumed every turn ends
with a result plus a trailing idle, so a stream that ended mid-turn after an
earlier result left the session busy forever (G23).

Sidecar (crates/freshell-claude-sidecar):
- consumeStream's finally: on an end nobody asked for, cancel the parked
  cards, ring a result-less turn end (interim, until C18), send the
  stream-end idle (interim, until the exited status of C09 replaces it),
  then `sdk.stream.ended {sessionId, reason, message}` as the LAST line, and
  exit with code 3 once stdout and stderr have flushed. Requests that arrive
  after that are ignored. A requested teardown (shutdown, SIGTERM, stdin
  closing) exits 0 and reports nothing.
- New pure stream-end.mjs: reason `cli_exited` (the SDK's "Claude Code
  process terminated by signal …" / "… exited with code …"), `stream_error`
  (anything else thrown), `stream_eof` (no error); the message is cut to
  1000 characters. The `stream.ended` log record uses the same reasons
  (warn with the error, info for an end of stream).
- The catch's `sdk.error "SDK error: …"` is gone: the cause rides
  sdk.stream.ended and the server's exit report, so the user sees one banner.

Server (crates/freshell-freshagent):
- Deleted `result_idle_pair_pending`, its doc comment, its plumbing through
  create, attach-resume and rollback, and its tests. An idle now always
  folds as a terminal edge; the real sidecar only sends one at its stream
  end.
- The consumer keeps the sidecar's `sdk.stream.ended` (never broadcast) and
  the end-of-output arm now reaps the evicted sidecar for its exit status
  (no zombie left) and reports `freshAgent.error{SIDECAR_EXITED}` with a
  `cause` ({reason, message} from the stream end, or for a Node death
  {reason: sidecar_exited, message, exitCode | signal}); the message names
  the cause. Logged as `freshagent.claude.sidecar_exit` (warn: reason,
  cause, exit status, turn in flight, rings).
- Interim ring rule (§5.7): the server rings only when no stream end was
  reported (a Node death mid-turn); the sidecar already rang a reported one.
  Exactly one edge either way.
- New small module claude_sidecar_exit.rs (StreamEnded, SidecarExitCause,
  death_rings, reap) with unit tests.

Fakes stop emitting a per-turn idle (they now end a turn the way the real
sidecar does, with its result): the in-crate fake's interrupt settle, the
e2e fake claude-SDK sidecar (completion and interrupt), the rollback test
fake (sent idle only; now a result, and the test waits for it) and the e2e
fake-claude-sidecar.mjs (sent turn.complete + idle; now result + edge). The
in-crate tests that injected a result plus a trailing idle inject the
result alone. The e2e fake's stream-error lane now models the real end of
stream and exits 3; its unit tests are rewritten to that behavior.

Tests (red first):
- test/unit/claude-sidecar/stream-end.test.ts (new): a simulator CLI crash,
  a natural end mid-turn (CLI exit 0) and a non-death stream error each end
  with sdk.stream.ended last and exit code 3 (red: "the sidecar did not exit
  within 10000 ms", 4 cases); an idle CLI death exits with no edge; shutdown
  and stdin closing mid-turn exit 0 with nothing alert-related; streamEnd on
  the exact SDK strings.
- crates/freshell-ws/tests/freshagent_claude_lifecycle.rs (new, RealSidecarSim):
  a CLI crash mid-turn gives exactly one turn.complete and SIDECAR_EXITED
  with cause cli_exited, and the sidecar process is gone; a natural end of
  stream mid-turn ends with status idle and no live session (not busy); a
  Node SIGKILL mid-turn rings exactly once with cause sidecar_exited/SIGKILL;
  an idle CLI death reports the exit with no edge. Red on the parent commit:
  the three stream-end cases time out waiting for SIDECAR_EXITED (the
  sidecar never exits), the Node death has no cause.
- claude.rs a_stream_end_idle_after_an_earlier_result_releases_the_busy_truth
  (new, G23): red on the parent commit ("in_turn never became false").
- Rewritten to the new protocol: sidecar-log.test.ts (reason cli_exited for
  a SIGKILL; new: an end of stream logs at info with no error),
  interrupt-idle-gate.test.ts (waits for sdk.stream.ended, not sdk.error),
  fake-claude-sdk-sidecar-control.test.ts (no per-turn idle; the
  stream-error lane exits 3), and the e2e STREAM_ERROR lane in
  fresh-agent-error-turn-attention.spec.ts now expects one edge, the
  SIDECAR_EXITED error on the pane, sdk.stream.ended last and the sidecar
  process gone.
- The rig keeps its server's claude lane (`ClaudeRig::fresh_claude`) and
  gains `RigConnection::read_for` for bounded "nothing more arrives" checks.

Green: cargo test -p freshell-ws (1205 passed); cargo test -p
freshell-freshagent --lib (1335 passed; 1 failure,
session_handoff::tests::an_opencode_placeholder_handoff_resolves_the_durable_owner_and_completes,
passes alone: a pre-existing HOME race with claude_snapshot.rs tests that
remove HOME); vitest test/unit/claude-sidecar + test/unit/sidecar (all
pass); e2e helpers fake-claude-sdk-sidecar-control (11 passed); clippy -D
warnings and fmt clean.

Deviations from the design text:
- The stream-end `sdk.status idle` is still sent before sdk.stream.ended
  (staging rule §10.2): today it is what takes a compacting pane out of its
  busy state; C09's exited status replaces it.
- `sdk.exit` stays on the requested (abort) path until C18 removes it.
- The server reaps the sidecar to read its exit status, which the design's
  "or the exit status" cause needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to C08 (the fakes stop emitting a per-turn idle). The claude
lane's "approval: Deny …" test in fresh-agent-control-rust.spec.ts waited
for the fake sidecar's trailing `sdk.status idle` after the denied turn's
errored result and used it as the fence for the exactly-one-edge check. The
real sidecar ends a turn with its result alone (it sends idle only when its
stream ends), and so does the fake now, so the wait timed out on the cloud
backend ("the errored sdk.result + unified edge + trailing idle marker for
the denied turn", 3 of 3 attempts).

The lane now waits for the errored result and the edge after it, still
asserts exactly one edge for the denied turn (fenced before the follow-up
send), and asserts that no per-turn idle follows the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A prerequisite for C09's mid-compaction death test (design §10 PR-B): the
simulator's /compact finished after 25 ms, so a test could not kill the
sidecar while it was compacting without racing that window.

Now `/compact HANG` keeps the compaction in progress (status `compacting`,
no result) until the CLI dies, the way the real CLI behaves when the local
provider's HANG mode holds its summary request. The instructions reach the
summary request on the real CLI: `/compact APIERR:400` already fails the
compaction there (T15, scenario `compact`). What the real CLI does when a
hung compaction is interrupted is not recorded, so an interrupt fails the
simulator loudly ("not modeled") instead of guessing.

Red: claude-cli-sim.test.ts "a /compact whose summary request hangs ...
stays compacting with no result" failed (the last status was null: the
compaction completed).
Green: claude-cli-sim.test.ts 17/17; claude-cli-contract.test.ts (real CLI
plus conformance) 39/39.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Design C09 (docs/plans/2026-10-22-freshclaude-core-runtime.md §3.3, §5.7,
§6, §8, §10 PR-B). When a claude sidecar ended unrequested, the server sent
only the SIDECAR_EXITED banner: a pane that was compacting stayed busy
forever, the composer stayed enabled, the next send vanished, there was no
Resume, and the session's snapshot fell back to a disk `idle` the client
could not trust (G05).

Server (crates/freshell-freshagent):
- New small module claude_dead_sessions.rs: DeadClaudeSession (every id the
  session answered to — map key, envelope stamp, durable ids — plus its
  settings, commands, the end reason and time) and DeadClaudeSessions, a
  store bounded at 256 deaths (oldest forgotten first; a second death
  replaces the first record).
- The end-of-output path records the evicted session (before the reap's
  wait, so a resume that lands meanwhile forgets it), then broadcasts
  `freshAgent.status{exited}` (unless that resume already happened), then
  SIDECAR_EXITED, then the death edge when owed (unchanged interim rule).
- The snapshot overlay (apply_snapshot_metadata) answers a dead id with
  `exited`, statusFromLiveState and the session's last settings/commands.
- handle_send on a dead id (map key, stamp or durable id): the nested
  `INVALID_SESSION_ID` lost frame plus a request error (code
  INVALID_SESSION_ID, "The Claude agent process for this session exited.
  Resume the session to continue."); never accepted. Other unknown ids keep
  SESSION_NOT_FOUND (C20/C21 change those).
- A create that resumes the durable id, an attach-resume, or a kill forgets
  the record. has_live_session stays live-only (false for a dead id).
- Logs: freshagent.claude.dead_session_recorded / _dropped / _forgotten
  (info), freshagent.claude.send_to_dead_session (warn).

Sidecar: the interim stream-end `sdk.status idle` is gone (the staging rule:
its consumer, the exited status, switches in this commit). The e2e fake's
stream-error lane drops it too.

Client:
- The session-ended card offers "Resume session" for claude/kilroy (any
  provider with a canonical durable id; triggerRecovery resolves the same
  id), not only codex.
- A refused claude send (any request error for an owned send) puts its text
  back into the composer, in front of anything typed since, with "Message
  not sent: <reason>" (a server `CODE: ` prefix is stripped). New composer
  handle method restoreText.

Tests (red first):
- crates/freshell-ws/tests/freshagent_claude_lifecycle.rs (RealSidecarSim):
  new a_sidecar_death_mid_compact_ends_exited_with_one_ring_and_an_exited_snapshot
  (red: last status stayed `compacting`) and
  a_send_to_a_dead_session_gets_the_lost_frame_and_a_request_error (red: no
  lost frame); an_idle_cli_death... now also expects `exited` and
  a_natural_end_of_stream... expects `exited` instead of the removed idle
  (red: `idle`).
- claude.rs: a_resumed_dead_session_no_longer_reads_as_ended and
  closing_a_dead_session_forgets_it (red with forgetting disabled: the old
  ids still read `exited`); claude_dead_sessions unit tests (5).
- stream-end.test.ts: no stream-end idle after a CLI crash, and a CLI crash
  mid-compaction ends with only `compacting` (red: an idle followed).
- fake-claude-sdk-sidecar-control.test.ts: no status after the stream-error
  edge (red: the idle).
- FreshAgentView.test.tsx: Resume for an ended freshclaude/kilroy session,
  even mid-compaction, resumes the same durable id (red: no Resume button);
  a refused freshclaude/kilroy send is restored with the reason (red: the
  composer stayed empty).
- freshAgentSlice.test.ts: exited replaces compacting and the exit banner
  keeps it exited and not busy (a pin: the slice already did this; the
  server never sent `exited`).
- e2e: fresh-agent-error-turn-attention.spec.ts gains a production-sidecar
  crash lane for freshclaude and kilroy (background crash: seq +1, tab
  highlight, ended card with Resume, Resume restores the conversation with
  no reload and takes the next message); its stream-error step now expects
  `exited`; the Stop lane's boot moved into a shared helper. The kilroy
  crash lane in fresh-agent-control-rust.spec.ts crashes in a background
  tab and recovers through Resume with no reload (seq +1, highlight), then
  through the restart as before.

Deviations from the design text:
- The dead record keeps no attention clock or turn lock yet: nothing uses
  them until the transparent resume (C20) moves them across.
- Recovery after a send to a dead id is the client's lost-frame recovery;
  the server-side transparent resume is C20.
- The composer restore covers every refused claude send, not only the three
  named codes: a request error always means the message was not sent.
- Rig snapshots read the overlay through a test-support seam
  (live_snapshot_overlay): the rig server cannot read the rig's transcripts
  (the store root comes from this process's environment).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`sendFreshAgentTurn` clicked Send and then waited for the pane to read
idle, but the pane still reads idle in the instant after the click, so the
helper could return before the send reached the sidecar. The "real-user
reload ... NO manual persist flush" lane then read the fixture's
transcript too early and missed its second turn.

The race was already there before C09: earlier cloud runs on other branches
failed the same assertion with [] and with only one of the two turns
(scratchpad logs locator-e2e.log, final-e2e.log, cp-cloud-full.log). On
this branch it failed in all three cloud attempts and in a local run, while
the page itself showed the second turn: it had landed a moment after the
transcript was read.

Now the helper waits for the turn's completion edge (`turnCompletion.seq`
goes up; the fixture writes both transcript lines before it), then for idle.

Red: the lane failed locally at aceaa17 ("Received: ['no-flush first
turn']"). Green: the whole spec passes locally (4 passed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sidecar protocol v2 (design §4.2, §5.1). The sidecar answers `created`
only once the CLI has booted the conversation (`initializationResult()`
resolved, no prompt needed, T2), and a boot the CLI refuses is a typed
`create.failed` instead of a `created` followed by a session that is
already dead.

Sidecar:
- New pure module boot.mjs: bootOptions(boot, sessionId) maps the v2
  `create.boot` ({kind: fresh | resume | fork}) onto the SDK options
  (resume never sets sessionId); classifyBootError names the CLI's exact
  T4/T5 refusals: SESSION_ID_IN_USE (from the CLI's stderr),
  CONVERSATION_NOT_FOUND, RESUME_POINT_NOT_FOUND, FORK_GUARD_REFUSED, else
  BOOT_FAILED.
- `created {requestId, sessionId, protocol: 2, cliPid}` after the boot. A
  `booted` flag gates every SDK message until then: the refused boot's
  error_during_execution result is only the refusal's evidence, never an
  sdk.result. A refused boot writes only `create.failed {code, message}`
  and exits with code 2. A create that is not protocol 2 fails fast
  (SIDECAR_PROTOCOL_MISMATCH, no SDK call). A second create is refused.
- The command catalog comes from initializationResult().commands and is
  published right after `created`, then again only when it changes (a
  turn's init removes the terminal-only commands; later inits repeat it).
- Logs boot.ok (info) and boot.failed (warn) with the boot kind and time.

Rust:
- New claude_launch.rs (ClaudeBoot, SIDECAR_PROTOCOL) and
  claude_handshake.rs (read_created): typed CreateFailure, a `created` in
  another protocol is SIDECAR_PROTOCOL_MISMATCH (logged at error), lines
  before the answer are logged at debug and dropped, and every boot is
  logged as freshagent.claude.boot with its elapsed time.
- All three create sites (create, attach-resume, rollback) write the v2
  line. handle_create forwards the sidecar's code in
  freshAgent.create.failed and sets `created.sessionRef` for a resume
  create (also on a dedup replay).
- Session host: the bridge announces a claude/kilroy native identity once
  per distinct id, from `created.sessionRef` for a resume, so a hosted
  resume `start` resolves with no prompt; a turn's init no longer
  re-announces it.

Boot cost (decision 4), median of 7 on the pinned CLI 2.1.237 against
the local endpoint: create to `created` went from 269/256 ms
(fresh/resume) to 936/940 ms; create to the first turn's result, with
the send written as soon as `created` arrives, went from 1104/1056 ms to
1026/1042 ms. `created` is ~0.7 s later; the first turn is not slower.

Deviations:
- The v2 `boot` object replaces the flat resume/fork keys now, so every
  fake sidecar (Rust tests, e2e fixtures) reads `boot` and answers
  `protocol: 2`; tests that asserted the flat keys assert `boot`.
- Attach-resume keeps every boot refusal transient: it found the
  transcript just before, so the Lost rule of §4.2 waits for
  decide_boot/establish_runtime (C11/C12). The SESSION_ID_IN_USE retry
  waits for fresh boots with a minted id (C23).
- Rollback maps FORK_GUARD_REFUSED onto its existing guard retry until
  C13 replaces that flow.
- interrupt-idle-gate: a Stop can no longer reach the CLI before it
  boots, so the cold-Stop cases now overtake the send after the boot
  (the receipt-shape knob only applies before boot); the "Stop during a
  refused resume boot" case is now boot-handshake.test.ts's refusal.
- The hosted-MCP session-host test's exact-resume case now expects the
  identity from `created`; the old "different init id" check is replaced
  by real-sidecar tests.
- The electron checkout-free fake SDK reports its boot and the test
  expects the catalog after `created` (it also expected the stream-end
  idle that C09 removed).
- Observed while verifying on the real CLI: the simulator refuses a
  drops-turn guard even when nothing is discarded; the real CLI boots.
  The guard test discards a turn, so it does not depend on that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…me apart

C10's update of the checkout-free acceptance bound the command-catalog
frame to `catalog`, a name the test already uses for the model catalog,
so the file no longer compiled. The electron-runtime lane (run locally
against a freshly staged runtime) caught it; it now passes 3 of 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
C10 moved the resume and fork keys of the sidecar `create` line into the
protocol-v2 `boot` object. Seven e2e specs inspect the fake sidecar's
request log for the resume or fork create and still read
`resumeSessionId` / `forkSession` / `resumeSessionAt`, so the first
cloud run on C10 failed them (rollback undo/redo for claude and kilroy,
settings resume, restart parity, zero-turn restart, hidden-pane rebind,
reconcile completion, wave-A identity stores). They now read
`boot.kind`, `boot.resume` and `boot.resumeSessionAt`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… commands

Review finding F1 on PR-B (C10). C10 publishes the command catalog at boot,
from initializationResult(), but only a turn's system/init names the CLI's
terminal-only commands (terminal_slash_commands). So /doctor and /color
reached the slash menu of every new or resumed pane until its first turn,
and the test that guarded this had been relaxed to read the last catalog.

Sidecar:
- New small module terminal-commands.mjs: PINNED_TERMINAL_COMMANDS (the
  pinned CLI 2.1.237's list, doctor and color) and usableCommands(). The
  catalog leaves out the pinned list until the first init reports the CLI's
  own, which then replaces it. With the pinned CLI the lists agree, so the
  boot catalog is the only one published.

Tests (red first):
- session-settings.test.ts: the FIRST published catalog again must not list
  doctor or color (red: it listed both).
- boot-handshake.test.ts: the boot catalog leaves out doctor and color, and
  later inits publish nothing (red); new case: an init naming a different
  list (simulator knob FRESHELL_CLAUDE_SIM_TERMINAL_COMMANDS) replaces the
  pinned list and the catalog is republished (red: the boot catalog listed
  color).
- claude-cli-contract.test.ts (real CLI): the boot result names no
  terminal-only list, and every init lists exactly PINNED_TERMINAL_COMMANDS.
  Mutation check: pinning only 'doctor' fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… line

Review finding PRB-C10-boot-catalog-dropped-on-resume. Since C10 the
sidecar publishes the command catalog right after `created`. handle_create
started the session's consumer, then awaited the identity sink's alias
tombstone write (a resume create in production), and only then inserted the
session into the sessions map. A consumer that read the catalog line in
that window found no session and dropped it: a resumed pane's slash menu
had only the built-in entries until a turn's init republished it. The same
window let a CLI that died right after its boot leave a live zombie: the
end-of-output path found nothing to evict, ended silently, and the session
was inserted afterwards.

Server (crates/freshell-freshagent):
- The consumer is spawned and the session inserted under the sessions map
  lock, at all three registration sites (create, rollback respawn,
  attach-resume). The consumer reaches its session only through that lock,
  so it cannot handle a line before the session is registered.
- handle_create: cli_index is written before the locked registration and
  the alias tombstone after it (the attach-resume order), so no await sits
  between starting the consumer and registering the session.
- create and attach-resume forget the resumed session's dead record under
  the same lock, so a death of the new incarnation is recorded after it.
- New small module claude_test_holds.rs (test seams; none set in
  production) with a registration hold: a wait between starting the
  consumer and inserting the record, standing in for the production sink
  await the rigs do not have. test-support:
  FreshClaudeState::hold_session_registration.

Tests (RealSidecarSim, red first):
- the_boot_catalog_reaches_the_live_state_however_long_the_registration_takes:
  fresh and resume creates under a 1.5 s registration hold have the boot
  catalog in their snapshot overlay with no turn (red: the overlay had no
  `commands`).
- a_cli_death_while_the_session_registers_still_ends_the_session: a CLI
  crash 300 ms after boot, inside the hold, still gives SIDECAR_EXITED and
  an `exited` overlay (red: no exit report ever arrived).

Deviation: the suggested oneshot gate was not needed; holding the map lock
across spawn and insert gives the same ordering with no new channel or
teardown paths to drop it on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review finding PRB-C09-reap-window-exited-gate. The end-of-output path
records the dead session, waits for the sidecar's exit status (reap), then
reports the exit. C09 skipped only the `exited` status when a resume had
brought the session back during that wait; SIDECAR_EXITED still went out
under the same id. An attach that resumed the session under its own pane id
(the reload and reconnect path) then got an error banner and was reset to
idle while it was live. The rule had no test.

Server: the `exited` status and SIDECAR_EXITED are now one report, sent
only while the dead record still stands; when a resume or a close forgot it
during the reap, both are skipped and logged
(freshagent.claude.exit_report_skipped, info). The death ring is kept: the
turn the death cut short did end, unwitnessed (the unified attention rule).

Test seam: claude_test_holds.rs gains an exit-report hold (parks the
end-of-output path after it recorded the dead session, before the reap);
test-support: FreshClaudeState::hold_exit_reports.

Test (red first): claude.rs
a_resume_during_the_reap_gets_no_exit_report_but_the_lost_turn_rings
(red: SIDECAR_EXITED reached the resumed pane). Mutation check: replacing
the dead-record check with `true` fails it.

Deviations:
- The ring is not part of the skipped report (the finding suggested it
  could be): a crashed turn ends unwitnessed whether or not the session was
  resumed meanwhile.
- The test is in-crate on the fake sidecar, not on RealSidecarSim: the
  attach-resume finds the transcript through this process's environment,
  which a rig cannot point at its own store (as for the C09 sibling test
  a_resumed_dead_session_no_longer_reads_as_ended).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review finding F2. C09 removed the sidecar's end-of-stream `sdk.status idle`,
so the real sidecar sends no idle at all, but comments still said it sends
one when its stream ends: the consumer's terminal-edge comment, the status
fold, the TurnTracker invariants, the fold_terminal_edge doc, the in-crate
fake's interrupt comment, and the e2e fakes and tests.

The in-crate G23 test drove that idle through the hand-written fake and
said the real sidecar sends it. It is renamed
a_legacy_fake_idle_after_an_earlier_result_releases_the_busy_truth and its
doc now says what it pins: the consumer's idle arm, which only the legacy
protocol fakes reach until C17/C18 delete it. The real-sidecar G23 behavior
stays pinned on RealSidecarSim by
a_natural_end_of_stream_mid_turn_leaves_the_session_not_busy.

No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review finding F3. C08 stopped the fakes from sending an idle after each
turn, but fake-kilroy-runtime.mjs still sent `sdk.status idle` after every
turn's result and edge, and after the synthesized Stop result. Run as a real
server's sidecar (FRESHELL_CLAUDE_SIDECAR), that counted every turn as
ending twice now that result_idle_pair_pending is gone, and it kept teaching
the result-plus-idle pair the real sidecar never sends (G23).

- fake-kilroy-runtime.mjs: no idle after a turn's result (the scripted turn
  and the held turn's synthesized Stop result). The idles after create and
  after an interrupt with nothing in flight stay, as in the SDK fake.
- kilroy-runtime.test.ts (red first): a full turn and a held-turn Stop
  assert that no idle follows the result (red: the idle). Per-turn idle
  waits are gone; a Stop with nothing in flight serves as the read barrier.
- harness-06-misc-fixtures.spec.ts: the smoke turn no longer waits for the
  per-turn idle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review finding F4. C09 replaced the kilroy crash lane's reload recovery
(crash, page.reload, the same durable session re-attaches) with the Resume
button instead of keeping both, so no browser test covered recovering a
crashed fresh-agent pane by reloading, the path whose attach must now clear
the server's dead-session record.

fresh-agent-control-rust.spec.ts, kilroy crash lane: after the Resume
recovery and its follow-up turn, a second CRASH_NOW ends the session again;
once the pane reads as ended the page reloads, and the lane asserts the
reloaded pane resumed the SAME durable id (one more fixture resume), the
ended card is gone, and a follow-up turn lands. The restart step then runs
on the reloaded page, and the transcript check includes the new turn.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… claude.rs

Review finding F5. PR-B added logic to claude.rs that has a home in the new
small modules (design §3.2): the dead-session helpers on FreshClaudeState,
and three copies of the v2 create-line fields (protocol, sessionType, boot)
in handle_create, the rollback respawn and attach-resume.

- claude_dead_sessions.rs: SharedDeadSessions, the lane's shared record
  store, with find, record (taking a Death: the ids, settings, commands and
  reason the end-of-output path has) and forget, logging as before
  (freshagent.claude.dead_session_recorded / _dropped / _forgotten). The
  state's field is this handle; dead_session, record_dead_session and
  forget_dead_sessions are gone from claude.rs.
- claude_launch.rs: create_line(request_id, session_type, boot, fields),
  the one builder of type, protocol, requestId, sessionType and boot; each
  launch passes only its own fields, which can never replace those. C11's
  ClaudeLaunchSpec::create_request() replaces it.
- claude.rs PROVIDER is pub(crate) for the moved logs.

Deviation: refuse_send_to_missing_session stays in claude.rs. It is a
handler's answer built from claude.rs-private helpers (send_error,
lost_session_frame, the broadcast); it now reads the shared store.

Test (red first): claude_launch
a_create_line_carries_the_protocol_session_type_and_boot_with_the_launch_fields
(red: no builder). The moved code is covered by the existing dead-session
tests (in-crate `dead` 30/30, freshagent_claude_lifecycle 12/12) and the
create/rollback/attach lanes (in-crate claude 317/317,
freshagent_claude_rollback 7/7, freshagent_claude_attach 4/4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review finding F6. The SIDECAR_EXITED banner still told the user to "Reopen
the pane or create a new agent to continue", directly above the ended
card's "Resume session" button (C09), while a refused send says "Resume the
session to continue." The banner now ends the same way: "The Claude agent
process exited unexpectedly: <cause>. Resume the session to continue."

Text only, no test: the user's rules forbid tests that check prose. The e2e
lanes that read the banner match "exited unexpectedly", which is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ycle

Brings in main's merged work (#852-#862) as PR-A reconciled it: the send
fence fix, hosted configure, G28 TranscriptLocation, graceful teardown with
SidecarTreeGuard and ClaudeNodeCommand (claude_spawn.rs), sdk-env.mjs, the
sidecar's stdout-error shutdown, the durable transcript projection, client
alerts and the managed session-host startup.

Conflicts and how they were resolved:

- crates/freshell-claude-sidecar/index.mjs: both import sets kept
  (stream-end, boot, terminal-commands, sdk-env). The stdin-close handler
  keeps PR-B's `exiting` guard. The new stdout-error handler (#859) does the
  same: a sidecar already exiting on its own (refused boot, unrequested
  stream end) has closed its query and keeps its own exit code (3 or 2),
  so the handler only logs; otherwise it shuts down and exits 0 as before.

- crates/freshell-freshagent/src/claude.rs: the three session inserts
  (create, rollback respawn, attach resume) take PR-B's record (no
  result_idle_pair_pending, the dead-session forget under the lock) plus
  PR-A's tree_guard. All three start sites already share
  spawn_sidecar_process (ClaudeNodeCommand + spawn_guarded) and write
  PR-B's create_line() v2 request.

- One reap/teardown path: PR-B's end-of-output path reaped an ended
  sidecar with its own claude_sidecar_exit::reap (a bounded child.wait),
  while #859's requested teardown stops through graceful_then_kill and the
  tree guard. The process stop is now one function,
  SidecarProcesses::stop in claude.rs (graceful_then_kill, the /proc
  ownership reap, the tree-death confirmation), used by
  teardown_removed_session_within (requested: consumer aborted first, map
  entry removed first, so it stays silent and logs
  freshagent.claude.teardown) and by the consumer's unrequested-end path
  (which still records the dead session before the stop, and now reports
  the exit only once the old tree is confirmed gone; an unconfirmed tree
  escalates detached through the shared escalate_unconfirmed_teardown).
  claude_sidecar_exit::reap and its budget are gone; the exit status comes
  from the stop, and a node the stop had to force reports no status as
  the cause. freshagent.claude.sidecar_exit now also logs stop and
  confirmation.

- crates/freshell-freshagent/src/lib.rs: both module lists kept.

- crates/freshell-session-host/src/providers/fresh_agent.rs: both test
  sets kept (PR-B's simulated-CLI resume starts, PR-A's hosted configure).
  PR-B's HostedTransport::from_parts now also takes the broadcast sender
  PR-A's configure subscribes to (provider_frames).

- test/e2e-browser/fixtures/fake-claude-sidecar.mjs: protocol-2 `created`
  and the boot.resume field (PR-B) with PR-A's FAKE_CLAUDE_SIDECAR_INIT_AT
  knob.

Protocol v2 follow-through for tests PR-A brought in: fakes answer
`created` with protocol 2 (checkpointed_send, claude_sidecar_teardown,
safe11 shutdown reaping, the hosted configure fake); the rollback G28 test
reads the fork from create.boot; the stdout-loss unit test and the pinned
CLI shutdown contract send a v2 create (protocol 2, boot fresh), and the
streaming query fixture reports initializationResult(). The teardown unit
fixture drops result_idle_pair_pending, which PR-B removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…core-lifecycle

PR-A moved two test commits past e128d37 before it landed as #863: the
stdout-loss unit test now pins the `stdout.closed` warn record, and the
pinned-CLI shutdown contract uses the shared resolvePinnedClaude /
createClaudeFixture helpers and FAKE_MODEL.

Conflict: test/integration/providers/claude-sidecar-shutdown.mjs, the
create line. Resolved to PR-B's protocol-v2 create (protocol 2, a fresh
boot) with PR-A's FAKE_MODEL. The sidecar's merged stdout-error handler
logs `stdout.closed` once at warn before anything else, so the new
record assertion holds unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e sidecar create log

PR-A's G11 default-model spec (freshagent-default-model-rust) came in with
the sync merges and still looked for the resumed sidecar create by its v1
top-level `resumeSessionId`. Protocol v2 (C10) moved that id into
`boot.resume`, so the resume step could never match (a 30 s timeout for
both freshclaude and kilroy), and the fresh-create check
`!frame.resumeSessionId` held for every create. The fresh create is now
`boot.kind === 'fresh'` and the resumed one is a resume boot of the
durable id, like the seven specs e639d5e converted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the v2 boot

The G28 test claude_undo_with_an_unreadable_store_is_internal_error_not_nothing_to_undo
asserted "no fork may run against an unchecked store" by reading the v1
top-level `forkSession` key. No protocol-2 create carries it, so the check
passed whatever the undo spawned. It now reads `boot.kind`, as the merge
already did for the sibling fork test, and first asserts that every logged
create names its boot kind, so the check cannot go vacuous again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e when it loses stdout

The sync merge added `if (exiting) return` to the sidecar's stdout-error
handler, and nothing covered it. Two tests now drive index.mjs past it: an
SDK stream that ends (the CLI killed under an idle session) and a refused
boot (a protocol-1 create), each after the test destroyed the sidecar's
stdout reader. They expect exit 3 and exit 2, one `stdout.closed` warn
record, and the stream-end or boot-failure record.

Without help the guard is rarely reached: the exit flush usually finishes
before the pipe's error event, so the process exits with its own code
either way. The new fixture entry (slow-stderr-flush-entry.mjs) delays the
stderr flush callback by 500 ms, as a slowly read stderr would, so the
error event lands inside the exiting window. With the guard removed both
tests fail with exit code 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sted teardowns

The sync merge made SidecarProcesses::stop the one way a claude sidecar's
process tree is stopped, for a requested teardown and for a sidecar that
ended on its own, and nothing tested the unrequested side. The new
claude::end_of_output_tests drive a Node fake through the production
create, so the consumer, eviction and stop are the real ones. The fake
starts two TERM-immune stand-ins for the claude CLI: one in its process
group and one that left it with setsid.

1. A stream end (sdk.stream.ended, exit 3): both stand-ins and node are
   dead when the pane hears `exited`, read with no waiting. The report
   comes once, with the reported cause, and Rust rings nothing.
   sidecar_exit logs stop=abnormal_exit, confirmation=confirmed and
   exit_status="code 3".
2. Node SIGKILLed mid-turn: the tree is dead at the report, the cause's
   signal is SIGKILL, and the cut-off turn rings once.
3. A sidecar that closed its stdout but kept running and ignored the
   shutdown line and SIGTERM: stop=forced, and the cause has no exit code
   or signal, though the log records SIGKILL.
4. The same fake stopped by handle_kill: the same tree dies, with no exit
   report, no ring and no dead record.

Checked by mutation: broadcasting `exited` before the stop fails 1-3, and
dropping the forced-stop filter on the cause fails 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dows

A stop that cannot confirm the sidecar's tree gone hands it to a detached
escalation. That escalation ran its 24-round kill-and-confirm window again
at once, forever: a tree stuck in the kernel (it survived SIGKILL) cost a
full /proc environ scan every 25 ms and a warning every 0.6 s for the rest
of the server's life. Since the merge every unrequested death whose stop
ends unconfirmed starts one, so they could pile up.

The escalation still never gives up, because an explicit kill's deferred
stop commit waits for it to confirm death. It now pauses between windows:
half a second, doubling, at most a minute. Each unconfirmed window logs one
`teardown_escalation_continues` warning that names the next pause, and the
escalation logs `teardown_escalation_confirmed` (info, with the window
count and elapsed time) when the tree dies. The loop runs over a small
TreeDeathWindow trait, so a test drives it with a scripted window. That
test fails if the pause is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The end-of-output path said the old tree is "killed and confirmed gone
BEFORE the exit report offers Resume", and the merge commit claimed the
same. Both hold only within the stop's bounded confirmation window. When
the window ends with a member still alive, the report goes out at once
while the detached escalation keeps killing the tree. That is the
intended behavior: on Linux such a member has outlived SIGKILL. It runs
no more code and is only slow to finish dying, or stuck in the kernel,
so a resume cannot race it. Holding the report back would leave the pane
busy for as long as the kernel keeps that member. The comments now say
so.

The comment on the dead record now also says why the record comes before
the stop. A resume that lands during the stop (an attach is a recovery
verb) forgets the record, and that is how the report knows to stay away.
The comment also notes the cost: while the stop runs, a snapshot already
reads `exited`. The stop is short unless node closed its output and kept
running, which gets the 2 s grace period.

New test a_tree_the_stop_cannot_confirm_gone_is_reported_and_killed_in_the_background
uses a zero-round confirmation window, so the setsid'd TERM-immune
stand-in outlives the stop. It asserts one `exited` and one
SIDECAR_EXITED, confirmation=not_confirmed, one teardown_unconfirmed
error, the stand-in killed by the escalation, and the report logged
before teardown_escalation_confirmed. Making the report await the
escalation fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@danshapiro
danshapiro merged commit d4a80a9 into main Oct 23, 2026
7 checks passed
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.

1 participant