From 7f986270d6e4d075f998f175615938f36c15f2a1 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:02:34 -0700 Subject: [PATCH 01/25] docs(plans): freshclaude parity and alerts audit plans Core runtime design (identity, turn accounting, Stop, lifecycle, rollback, fork, stuck detection, test realism) plus the transcript/live-rendering, client alerts/UX, and agent API/hosted/ops workstream plans. Co-Authored-By: Claude Opus 5.5 --- .../2026-10-22-freshclaude-api-hosted-ops.md | 462 ++++++++++ ...2026-10-22-freshclaude-client-alerts-ux.md | 670 ++++++++++++++ .../2026-10-22-freshclaude-core-runtime.md | 870 ++++++++++++++++++ .../2026-10-22-freshclaude-transcript-live.md | 315 +++++++ 4 files changed, 2317 insertions(+) create mode 100644 docs/plans/2026-10-22-freshclaude-api-hosted-ops.md create mode 100644 docs/plans/2026-10-22-freshclaude-client-alerts-ux.md create mode 100644 docs/plans/2026-10-22-freshclaude-core-runtime.md create mode 100644 docs/plans/2026-10-22-freshclaude-transcript-live.md diff --git a/docs/plans/2026-10-22-freshclaude-api-hosted-ops.md b/docs/plans/2026-10-22-freshclaude-api-hosted-ops.md new file mode 100644 index 0000000000..401d4b9911 --- /dev/null +++ b/docs/plans/2026-10-22-freshclaude-api-hosted-ops.md @@ -0,0 +1,462 @@ +# Plan: freshclaude/kilroy — Agent API, MCP, hosted gateway, and operations workstream + +Scope: audit gaps G18, G19, G28, G29, G30, G31. Worktree `/home/dan/code/freshell/.worktrees/freshclaude-parity` (branch `feat/freshclaude-parity`, identical to `origin/main` 1c7e4dff7). Kilroy is sessionType `kilroy` on the same `FreshClaudeState` runtime, so every item below covers it unless a line says otherwise. All line numbers were checked against that tree. + +This is a design document. No repository file was changed. + +--- + +## 0. Decisions at a glance + +| # | Item | Decision | Why | +|---|------|----------|-----| +| G28 | Three-state transcript locator, plus the shared `home_dir()` fallback for every Claude store-root caller | **This effort. Ship first.** | It is a small correctness fix. On the Windows desktop app (no `HOME`), freshclaude history, resume and undo fail. A transient IO error is reported as "session lost". The G18 capture work and the core-runtime attach/rollback work both build on the new locator. | +| G31 | Graceful sidecar shutdown, plus a process-tree guard: a Windows job object, and a Unix process group for macOS | **This effort.** | Confirmed with a live probe (§3.1): a hard-killed Node sidecar leaves the real CLI running the current turn until that turn finishes. Windows and macOS are both shipping Electron targets (`config/electron-builder.yml:90-102`), and on them nothing reaps the CLI. The job-object code already exists privately in `freshell-server/src/checkpoints.rs:819-960`, so this is a lift-and-reuse. | +| G30 | Model-catalog probe environment parity | **Partly this effort.** Shared JS clean-env module, and one shared Rust node-command builder (which also gives the probe the G31 tree guard). **Deferred:** routing the probe through the session host in managed mode. | The shared builders remove the drift at its source. They also fix a real leak: on Windows and macOS, a probe that times out leaves its CLI grandchild behind. Routing through the host needs a new supervisor/session-host RPC. It matters only in the opt-in managed mode, where the static model list is already the fallback. That work belongs to `docs/plans/2026-10-05-managed-provider-environment.md`. | +| G19 | Ordinary Freshell MCP server inside freshclaude/kilroy panes, gated on `settings.codingCli.mcpServer` | **This effort.** **Deferred:** gating terminal-mode CLIs (GitHub #446) goes to its own follow-up PR. | It restores what the legacy freshclaude had, and it is cheap because the platform command resolver exists. A probe (§3.2) confirmed that the endpoint and token can travel through the inherited environment and never through argv. Gating terminal panes changes behavior for every terminal CLI, so it is outside freshclaude parity. The shared gate helper makes that follow-up a one-line change. | +| G18 | REST/MCP create, split, send-keys and capture for freshclaude/kilroy in the default server | **This effort. Biggest item.** It depends on core-runtime interfaces I1–I3 (§1). **Deferred:** an in-process codex lane (that would be a new codex feature), and moving opencode onto the lane abstraction. | This is the only medium-severity gap here. The lane drives the same `FreshClaudeState::handle_create`/`handle_send` that a browser's `freshAgent.create`/`send` reach, deduplicated on the pane's `createRequestId`. That gives exactly one writer. It is not a second implementation the way the opencode REST lane is (`lib.rs:6339-6800`). | +| G29 | Hosted gateway routing for `freshAgent.configure`, plus a routing test that checks every message type | **This effort. Last, as its own PR.** Contents: routing, a no-wildcard routing classifier, and a real hosted `configure` RPC that follows the existing `compact` pattern. | If configure is routed to the gateway with nothing behind it, the user gets either a fake error or a silent drop. The `compact` RPC already shows the five-crate path. The classifier makes it a compile error to add a fresh-agent message without deciding its routing. This touches managed-runtime crates, so it ships as a separate PR the managed-runtime owners can review. | + +Suggested PR slices, in order: **PR1 G28 → PR2 G31+G30 → PR3 G19 → PR4 G18 → PR5 G29.** PR4 waits on core-runtime I1–I3. PR1–PR3 do not depend on core-runtime. + +--- + +## 1. Interfaces needed from the core-runtime workstream + +Core-runtime (G01–G09, G14–G16, G21–G24, G27, G32) restructures `claude.rs` handlers and the consumer. This plan needs the interfaces below. Where an interface is missing, I give the fallback this plan would ship with instead. + +| ID | Interface | Used by | Fallback if absent | +|----|-----------|---------|--------------------| +| **I1** | `FreshClaudeState::handle_create(msg, provenance) -> ClaudeCreateOutcome`, where `ClaudeCreateOutcome = Created { session_id, session_ref: Option } \| Failed { code, message, retryable, owner: Option }`. The WS frames it broadcasts stay byte-identical, and the WS dispatcher ignores the return value. It is a mechanical change: each of the 19 `self.fail_create*(…); return;` exits becomes `return self.fail_create*(…)`, and `fail_create*` (`claude.rs:2698-2763`) both broadcasts and returns `Failed`. The success tail (`claude.rs:2672-2696`) and the replay arm (`1885-1904`) return `Created`. | G18 create/split | PR4 makes this change itself, in one refactor commit placed before its first red test. | +| **I2** | `handle_send(msg) -> ClaudeSendOutcome = Accepted { session_key } \| Refused { code, message }`, with the same idea. `send_error` (`claude.rs:7428`) returns `Refused`; the `send.accepted` broadcast (`claude.rs:4619`) returns `Accepted`. | G18 send-keys | Same: PR4 makes the change. | +| **I3** | A typed lane-event observer: `FreshClaudeState::subscribe_turn_events() -> broadcast::Receiver`, with `ClaudeTurnEvent::{ Settled { session_key, retired_request_ids: Vec, at }, Waiting { session_key, at }, Ended { session_key, reason } }`. It is published from the single attention-edge emitter that the G14/G24 fixes introduce (log, then broadcast, then notify). `retired_request_ids` is the G08 fix (stamp a uuid on each send, retire by replay) exposed as Rust request ids. A dedicated low-volume channel never carries `sdk.stream` deltas, so it cannot lag. | G18 send-keys turn correlation | **Idle-required semantics.** REST send-keys answers `409 SESSION_BUSY` (retryable) while the session has a turn in flight. Otherwise it waits for the first `freshAgent.turn.complete` stamped for the session after `Accepted`, read from the shared broadcast bus. That is correct for the REST-only case. A browser send racing in can make REST report done one turn early. That race is documented and is removed once I3 lands. | +| **I4** | One launch-profile builder used by every sidecar `create` line the lane writes. That is `handle_create`, `resume_for_attach` (`7173-7183`), `resume_for_handoff`, and the rollback fork (`6312-6343`). This is the G15 fix. Shape: `fn sidecar_create_request(&self, profile: &ClaudeLaunchProfile) -> Value`. | G19 (adds `freshellMcp` and `freshellContext`) | PR3 adds the two fields at all four sites itself. | +| **I5** | G05 resume-on-send for a dead session addressed by its durable id (codex `ensure_session_alive` style). | G18 (benefits automatically) | REST send to a session that is not live answers `409 FRESH_AGENT_SESSION_NOT_LIVE` with `retryAfterMs`. The browser's attach-on-reload stays the recovery driver. REST never starts a second recovery driver. | +| **I6** | G32 (Rust mints the durable UUID with the SDK `sessionId` option) may change what `created.session_id` is. | G18 | None needed. The lane treats the id as opaque and also returns `sessionRef` when it knows one. | +| **I7** | G04: a Rust integration harness that runs the **real** `index.mjs` with `FRESHELL_CLAUDE_SDK_QUERY_MODULE` pointing at a scripted SDK, instead of inline fake sidecars. | G18/G19/G31 integration tests | Use the request-logging inline-fake pattern of `crates/freshell-ws/tests/freshagent_claude_attach.rs:26-130`, and port to the real-sidecar harness when it lands. | +| **I8** | G01's rollback rewrite consumes the G28 `TranscriptLocation` and does not call `locate_transcript` directly. | G28 | PR1 converts the five rollback call sites minimally (§2.1). | + +Ordering constraint: **PR1 (G28) should land before core-runtime touches `resume_for_attach` or the rollback sites,** so that core-runtime builds on the three-state locator. + +--- + +## 2. Items: current behavior, target, design, files + +### 2.1 G28: three-state transcript locator and the shared home fallback + +**Verified current behavior** +- `claude_home_candidates()` reads only `CLAUDE_CONFIG_DIR`, `CLAUDE_HOME` and `HOME` (`claude_snapshot.rs:31-54`). It has no Windows `USERPROFILE` fallback, and on POSIX it has no passwd-entry fallback. +- Only `locate_transcript_selected` (the naming path) adds `USERPROFILE`, under `cfg(windows)` (`claude_snapshot.rs:96-122`). A third resolver, `freshell-ws/src/claude_truth.rs:170-200`, uses `std::env::home_dir()` on Windows only. A fourth, `session_directory::provider_home()` (`freshell-server/src/session_directory.rs:1039`), already uses `std::env::home_dir()` everywhere. That is exactly the CLI's `os.homedir()` behavior. +- `find_transcript` turns IO failures into "missing": `read_dir(...).ok()?`, `.flatten()`, and `Path::is_file()` (`claude_snapshot.rs:196-245`). `locate_transcript` (`65-69`) inherits this. +- Callers that treat "missing" as proof the session is gone: + - `resume_for_attach` maps it to `ResumeClaudeError::NotFound` (`claude.rs:7085-7087`). That leads to `lost_session_frame` and `INVALID_SESSION_ID` (`claude.rs:6980-6982`). + - The snapshot returns `NotFound` → 404 `FRESH_AGENT_LOST_SESSION` (`claude_snapshot.rs:1204`, `snapshot.rs:355-359`). + - The rollback sites answer `NOTHING_TO_UNDO` or `REDO_UNAVAILABLE` (`claude.rs:5783, 5897, 5912`; `6262` silently falls back). + - The reconcile existence probe returns `Absent` (`freshell-server/src/existence.rs:265-283, 305-321`). Its type `ClaudeTranscriptLocator = Arc Option>` (`existence.rs:50`) has no error channel. The comment at `:270` says so. +- With no roots at all (Windows with no `HOME`): the snapshot returns an Io error (500) and attach returns `Transient` (`claude_snapshot.rs:1198-1202`; `claude.rs:7076-7082`). Electron passes `process.env` through and sets no `HOME` (`electron/server-spawner.ts:238-257`). +- An error-aware `locate_transcript_checked` exists (`claude_snapshot.rs:259-275`), but only the resume-resolve endpoint uses it (`main.rs:3858-3900`). Its traversal layout also differs on purpose (Node parity: direct plus `subagents/`). + +**Target** +- One store-root resolver for every Claude caller: `CLAUDE_CONFIG_DIR`, then `CLAUDE_HOME`, then `std::env::home_dir()/.claude`, de-duplicated. On Windows that is USERPROFILE; on POSIX it is HOME, falling back to the passwd entry. This matches what the real CLI does. +- One traversal with three outcomes: `TranscriptLocation::{ Found(PathBuf), Absent, Unreadable(LocateError) }`, where `LocateError = NoStoreRoot | Io { root, kind, message }`. + - `Found` if the file is found under any root, even when another root errors. + - Otherwise `Unreadable` if any root failed with an error other than `NotFound`/`NotADirectory`, or if there are no roots. + - Otherwise `Absent`. + - The traversal stays the existing `find_transcript` layout: direct, plus one nested level. The Node-parity resolve endpoint keeps its own checked layout. +- How each caller maps the result: + - attach: `Unreadable` → `Transient` (never lost). + - snapshot: `Unreadable` → `Io` → 500. + - rollback: `Unreadable` → `INTERNAL_ERROR` (retryable), which is the same code the existing read-error branch uses at `claude.rs:5791-5796`. + - existence probe: `Unreadable` → `SessionExistence::Unknown` (a bounded deferral, never `dead_session`). + - naming (`locate_transcript_selected`): `Found` only, on the shared candidates. Its `cfg(windows)` special case is deleted. + +**File-by-file** +- `crates/freshell-freshagent/src/claude_snapshot.rs` + - Add `pub(crate) fn claude_home_candidates_from(var: impl Fn(&str) -> Option, home: Option) -> Vec` (pure), with `claude_home_candidates()` calling it as `(|k| std::env::var(k).ok(), std::env::home_dir())`. + - Add `pub enum TranscriptLocation`, `pub enum LocateError`, `pub fn locate_transcript_state(id) -> TranscriptLocation`, and `fn find_transcript_state(root, id)`. The latter uses `read_dir_sorted_or_empty`/`candidate_is_file`, which already exist at `:297-330`, but keeps the existing layout and the directory-only filter. Errors keep scanning; the first error is remembered and a later hit still wins. + - `find_transcript` becomes `find_transcript_state(..).found()`, so there is one traversal. + - `locate_transcript` stays as a thin `Found`-only wrapper for display-only callers (`claude_can_redo_now :1102`, `native_history.rs:398`). Each is reviewed: unreadable reads as "no redo" or a history error, never as "lost". + - `get_claude_snapshot` (`:1190-1205`) maps the three states. The separate `claude_home_candidates().is_empty()` branch is deleted because it is now `Unreadable(NoStoreRoot)`, and the existing message text stays. + - `locate_transcript_selected` uses the shared candidates and its `cfg(windows)` block is deleted. +- `crates/freshell-freshagent/src/claude.rs` + - `resume_for_attach` (`7076-7087`) matches on `locate_transcript_state`. Its own empty-candidates branch is deleted. It logs `warn` with event `freshagent.claude.transcript_unreadable`, the root and the io error kind, and never the path contents. + - Rollback sites `5783, 5837, 5897, 5912, 6262`: `Unreadable` → `rollback_error_frame(op, "INTERNAL_ERROR", "claude transcript store unreadable: ")`. Coordinate through I8. + - `:8211` (naming config root) uses the shared candidates. +- `crates/freshell-freshagent/src/lib.rs:72` re-exports `TranscriptLocation` and `locate_transcript_state`. +- `crates/freshell-server/src/existence.rs`: `ClaudeTranscriptLocator = Arc TranscriptLocation + Send + Sync>`. Both sites map `Found` → Present (plus an observed-set insert), `Absent` → fall through (or Absent at `:272`), `Unreadable` → `Unknown`. Update the `direct_locator_over` test helper (`:536`). +- `crates/freshell-server/src/main.rs:2110-2114`: the closure returns `freshell_freshagent::locate_transcript_state(id)`. +- `crates/freshell-ws/src/claude_truth.rs:170-200`: `ClaudeRoots::from_env` delegates to `freshell_freshagent::claude_home_candidates()` (made `pub`). That removes the last divergent resolver. This is a refactor: its tests pin the outcome, not the code. + +### 2.2 G31: graceful shutdown and process-tree guard + +**Verified current behavior** +- `teardown_removed_session` (`claude.rs:10101-10148`) writes `{"type":"shutdown"}`, then immediately calls `terminate_pid` (SIGTERM) and `child.start_kill()` (SIGKILL), then reaps by `/proc` tag. `shutdown()` (`claude.rs:1828-1848`) does the same, one session at a time. +- `terminate_pid`, `reap_owned_claude_sidecars` and the tree confirmation are no-ops off Linux (`claude.rs:9865-9889, 10064-10073`). Teardown answers `TeardownConfirmation::PlatformLimited` (`10135-10147`), and the session-handoff stop answers `StopResult::PlatformLimited` (`claude.rs:1195, 1242-1247`). +- `spawn_sidecar` (`claude.rs:9639-9720`) uses `kill_on_drop(true)`. On Windows that ends only `node.exe`. There is no job object and no process group. +- The sidecar's shutdown handler is `shutdown(); process.exit(0)` (`index.mjs:625-633, 680`). The SDK's CLI spawn is **not** detached (the `SOe(t,n,{cwd,stdio,signal,env,windowsHide})` call in the vendored `sdk.mjs`), so the CLI shares node's process group. +- A private, Windows-only `WindowsJob` (create kill-on-close job, assign a suspended process, resume, count active processes, terminate, settle) already exists in `crates/freshell-server/src/checkpoints.rs:819-960`, behind the target dependency `windows-sys =0.61.2` with `Win32_System_JobObjects` (`freshell-server/Cargo.toml:160-170`). + +**Probe evidence:** see §3.1. The graceful path kills the CLI in about 100 ms. A hard kill of node leaves the CLI streaming the turn until it ends. + +**Target** +1. **Grace first.** Write the shutdown line, then `tokio::time::timeout(SIDECAR_GRACEFUL_EXIT = 2s, child.wait())`. Escalate (SIGTERM, then SIGKILL, then reap) only if node has not exited. Tree confirmation runs either way. `shutdown()` tears sessions down concurrently (`futures::future::join_all`), so a server stop is bounded by about one grace period, not N of them. Log at `info`: `freshagent.claude.teardown` with `{session_id, graceful: bool, elapsed_ms, confirmation}`. +2. **Process-tree guard (RAII).** `spawn_sidecar` returns a `SidecarTreeGuard` stored on `ClaudeSession`. Dropping it, or calling `terminate()`, kills the whole tree on every platform. That also covers the roughly 15 early-return `start_kill` sites in `handle_create`, and panics, without editing each site. + - **Windows:** spawn with `CREATE_SUSPENDED`, assign to a `KillOnCloseJob`, then resume (the existing helper does this). Teardown calls `job.terminate()` then `job.settle()`, which waits until active processes reach 0. `teardown_removed_session` returns `Confirmed` instead of `PlatformLimited`, so handoff gets a real stop confirmation on Windows. + - **All Unix:** spawn with `process_group(0)`. Escalation also does `killpg`. On non-Linux Unix (macOS), confirmation is "the group is gone" (`kill(-pgid, 0)` returns ESRCH, as in `checkpoints.rs:808-818`). Linux keeps its stronger start-time-checked `/proc` confirmation. The `killpg` escalation runs on Linux too, so CI exercises the wiring. + - If creating the guard fails (job or group), log `error` with event `freshagent.claude.tree_guard_unavailable` and continue with today's behavior. A pane never fails because of this. + +**File-by-file** +- `crates/freshell-platform/src/process_job.rs` (new, `cfg(windows)`): move `WindowsJob` here as `KillOnCloseJob`, unchanged. Add the `windows-sys =0.61.2` target dependency (same features) to `crates/freshell-platform/Cargo.toml`. +- `crates/freshell-server/src/checkpoints.rs:819-960`: use `freshell_platform::process_job::KillOnCloseJob`. This is a pure refactor, covered by the existing checkpoint tests in the Windows lane. +- `crates/freshell-freshagent/src/claude_spawn.rs` (new): + - `SidecarTreeGuard` (per-platform implementation). + - `ClaudeNodeCommand` builder, shared with G30 (§2.3). + - `async fn graceful_then_kill(child, stdin, guard, grace) -> TeardownConfirmation`. +- `crates/freshell-freshagent/src/claude.rs`: `ClaudeSession` gains `tree: SidecarTreeGuard`. `spawn_sidecar` (`9639`) returns it. `teardown_removed_session` (`10101`) and `shutdown()` (`1828`) go through `graceful_then_kill`. Delete the now-redundant `terminate_pid` stub for non-Linux. +- `crates/freshell-freshagent/Cargo.toml`: add a `cfg(windows)` `windows-sys` dependency only if the guard calls Win32 directly. It should not, because the guard calls `freshell-platform`. + +### 2.3 G30: model-catalog probe environment parity + +**Verified current behavior** +- `model-catalog.mjs:9` always strips `ANTHROPIC_API_KEY`. +- `index.mjs:117-121` (`createClaudeSdkCleanEnv`) keeps it when `FRESHELL_CLAUDE_ONECLI_API_KEY === '1'`. +- `ClaudeCatalogProbe` (`model_capabilities.rs:150-215`) spawns `node model-catalog.mjs` with no managed secrets, no ONECLI flag, no `setpriv` run-as wrapper and no provider-exec check. `spawn_sidecar` applies all four (`claude.rs:9650-9701`). +- On timeout, the probe kills the process group only on Linux (`model_capabilities.rs:182-205`). On Windows and macOS the probe's SDK-spawned CLI is left behind. +- In the default server none of these differences matter, because managed secrets are installed only in the session host (`freshell-session-host/src/main.rs:1327`). The audit's corrected text agrees. + +**Target (this effort)** +- **JS:** one clean-env function. Add `crates/freshell-claude-sidecar/sdk-env.mjs` exporting `createClaudeSdkCleanEnv(env)`. `index.mjs` and `model-catalog.mjs` both import it. The probe then keeps the key exactly when the sidecar would. +- **Rust:** one builder. `ClaudeNodeCommand::new(entry).with_managed_environment().with_run_as().with_ownership_tag(id).spawn_guarded()` does the following: resolves `FRESHELL_CLAUDE_NODE`; applies the setpriv wrapper; applies managed secrets plus the ONECLI flag; runs `provider_exec::check_command`; sets the ownership tag; returns `(Child, SidecarTreeGuard)`. `spawn_sidecar` and `ClaudeCatalogProbe` both use it. The probe's timeout path calls `guard.terminate()` instead of the Linux-only `killpg`. +- Inject the secrets and environment source so tests are hermetic: `ClaudeNodeCommand::from_parts(env: &dyn Fn(&str)->Option, secrets: Option>)`. + +**Deferred: routing the probe through the session host in managed mode.** It needs a `capabilities.probe` admin/host RPC and a supervisor route, which is owned by the managed-provider-environment plan. It affects only the opt-in managed mode where OneCLI is the only authentication, and there the dialog already falls back to the static list. Recorded as a follow-up. + +### 2.4 G19: ordinary Freshell MCP inside freshclaude/kilroy panes + +**Verified current behavior** +- The sidecar deliberately leaves out the MCP injection (`index.mjs:64-68`). Its only `mcpServers` source is `hostedMcpServers(req.hostedMcp)` (`index.mjs:445`), which returns undefined unless hosted Claude is active (`hosted-mcp.mjs:3-6`). +- The terminal lanes inject MCP through `generate_mcp_injection` (`terminal_tabs.rs:2593-2600`) and give the terminal `FRESHELL/FRESHELL_URL/FRESHELL_TOKEN/FRESHELL_TERMINAL_ID/FRESHELL_TAB_ID/FRESHELL_PANE_ID` via `build_terminal_base_env`. That function is duplicated at `freshell-ws/src/terminal.rs:8773-8803` and `freshell-freshagent/src/terminal_tabs.rs:387`. +- `settings.codingCli.mcpServer` is declared (`freshell-protocol/src/settings.rs:98`), defaults to true (`freshell-server/src/settings.rs:49`), and is toggled in the UI (`AdvancedSettings.tsx:66-77`). No Rust code reads it. GitHub #446 is open. +- The MCP client takes its endpoint and token from `FRESHELL_URL`/`FRESHELL_TOKEN` (`tools/freshell-mcp/http-client.ts:17-18`) and its caller pane from `FRESHELL_TAB_ID`/`FRESHELL_PANE_ID` (`freshell-tool.ts:126-136, 220-235`). +- `RealMcpRuntime::server_command` resolves `FRESHELL_MCP_NODE`+`FRESHELL_MCP_ENTRY` (which Electron sets, `server-spawner.ts:256-257`), else the dist build, else tsx source (`mcp_inject.rs:186-219`). `build_mcp_server_command(rt, target)` (`:447`) renders it for a provider target. +- The SDK passes `mcpServers` to the CLI as `--mcp-config ` **on argv** (seen in the vendored `sdk.mjs`). Any `env` in that config would therefore show up in `ps`. This is why the hosted design keeps the grant in the inherited environment (`hosted-mcp.mjs:1-2`). + +**Probe evidence:** see §3.2. A stdio MCP child receives the `env` given to the SDK `query()` (FRESHELL_* arrived intact). It starts about 800 ms after CLI start even with no prompt, and the init message reports `freshell: connected`. + +**Target** +- When `settings.codingCli.mcpServer` is true (read **live** at each sidecar create, so no restart is needed) and the lane is not hosted, the sidecar's `query()` gets `mcpServers.freshell = { command, args }`. Only paths and flags travel in the create line. No token or env goes into the config. +- The sidecar process environment carries the endpoint: Rust sets `FRESHELL=1`, `FRESHELL_URL` and `FRESHELL_TOKEN` at spawn, from the server's own bound port and auth token. These are injected values, not re-read from env. The pane context `FRESHELL_TAB_ID`/`FRESHELL_PANE_ID` arrives as `freshellContext` in the create line. The sidecar merges it into the `env` passed to `query()`, so it reaches the CLI and from there the MCP child. +- This matches terminal panes, which already expose `FRESHELL_TOKEN` to the agent's shell, so it adds no new exposure. The token never appears on argv. A contract test proves that (§5.4). +- On a resume or respawn (attach-resume, handoff, rollback), the same recipe and context are reapplied through I4. The pane context comes from new optional `paneId` on `freshAgent.create` (`tabId` already exists) and new optional `tabId`/`paneId` on `freshAgent.attach`. The context is stored in the session's launch profile. +- If the MCP command cannot be resolved (for example, no dist and no tsx), log `warn` with event `freshagent.claude.mcp_unavailable` and the error text, and create the pane without MCP. The pane never fails over this. +- Kilroy gets the same treatment. The hosted lane is unchanged: it keeps its fixed recipe and never gets the ordinary one. + +**File-by-file** +- `crates/freshell-platform/src/freshell_context_env.rs` (new): `FreshellEndpoint { url, token }` and `fn freshell_context_env(endpoint, terminal_id: Option<&str>, tab_id, pane_id) -> BTreeMap`. Both `build_terminal_base_env` copies delegate to it (a refactor; the existing tests at `terminal.rs:11544-11575` keep pinning the behavior). +- `crates/freshell-protocol/src/settings.rs`: `impl ServerSettings { pub fn agent_mcp_server_enabled(&self) -> bool }`. This is the one shared gate; the #446 follow-up uses it. +- `crates/freshell-protocol/src/client_messages.rs`: optional `pane_id` on `FreshAgentCreate`; optional `tab_id`/`pane_id` on `FreshAgentAttach`. `shared/ws-protocol.ts` (`FreshAgentCreateSchema :847-872` and the attach schema) gets the matching optional fields. +- `crates/freshell-freshagent/src/claude.rs`: + - `FreshClaudeState` gains `freshell_endpoint: Arc>` and `freshell_mcp: Arc>`, where `ClaudeFreshellMcpProvider = Arc BoxFuture<'static, Option> + Send + Sync>` and `ClaudeFreshellMcp { command, args }`. Setters follow the `set_identity_sink` pattern. + - `spawn_sidecar` takes the endpoint and sets the env. + - The create line gets `"freshellMcp"` and `"freshellContext": {tabId, paneId}` (through I4). + - Log `info` with event `freshagent.claude.mcp_injection` and `{session_type, enabled, resolved}`. +- `crates/freshell-server/src/main.rs` (next to `fresh_claude_state` wiring, `1054-1088`): + - Install the endpoint from `PORT`/bind and `auth_token`. + - Install a provider closure that reads `settings_store.shared_settings_lock()` (`settings_store.rs:335`), checks `agent_mcp_server_enabled()`, then calls `build_mcp_server_command(&RealMcpRuntime, host_native_target())`. `host_native_target()` is `ProviderTarget::Windows` on a Windows host and `Unix` otherwise. + - The session host never installs either. +- `crates/freshell-claude-sidecar/mcp-servers.mjs` (new, absorbing `hosted-mcp.mjs`): `export function mcpServersFor(req, env)`. It returns the hosted recipe, the ordinary recipe or `undefined`, never both, and never an `env` key. `index.mjs:445` calls it, and `index.mjs` merges `req.freshellContext` into the SDK `env`. Update the scope comment at `index.mjs:64-68`. +- Client: `FreshAgentView.tsx` `buildCreateMessage` (`:2030-2068`) adds `paneId`. The attach builders (`:640`, `sendFencedFreshAgentAttach`) add `tabId`/`paneId`. + +**Deferred: gating terminal-mode injection on the same setting (#446).** It changes behavior for every terminal CLI (claude, codex, gemini, kimi, opencode), so it is not freshclaude parity. Do it immediately after, as its own PR, using `agent_mcp_server_enabled()`. There is a known inconsistency until then: turning the toggle off removes MCP from freshclaude panes but not from terminal panes. Note this in the PR description. + +### 2.5 G18: REST/MCP agent API for freshclaude/kilroy in the default server + +**Verified current behavior** +- `create_tab` maps `claude|kilroy|codex|opencode` (`lib.rs:1962-1970`). With a hosted gateway it calls `create_hosted_agent_tab`. Without one, it returns **503 "durable fresh-agent gateway is not installed"** for anything except opencode (`lib.rs:4814-4862`). +- Split with `agent` returns the same 503 for every provider (`pane_ops.rs:185-199`). +- `pane_entry_for_request` reads the REST-only pane map. It falls back to the layout only when a gateway exists (`lib.rs:5106-5149`, the `state.hosted_rest_gateway()?` at `:5110`). So send-keys on a UI-created freshclaude pane returns 404, and capture returns 422 (`lib.rs:6286-6330, 6981-7055`). +- The gateway is installed only under `feature="managed-runtime-v1"` plus an explicit opt-in (`main.rs:1197-1221`). +- The seam is already provider-generic: `HostedFreshAgentRestGateway { snapshot, create_agent, send_agent, capture }` (`hosted_rest.rs:123-145`). The hosted implementation builds a `FreshAgentCreate` exactly as a browser would and calls the same create path (`fresh_agent_proxy_rest.rs:124-163`). It waits for `freshAgent.turn.complete` or `status: idle` (`:264-276`). +- `render_snapshot_text` (role-prefixed plain text with a UTF-8-safe byte cap) lives privately in `freshell-session-host/src/providers/fresh_agent.rs:1274-1316`. +- `handle_create` dedupes on `requestId` and replays `created` (`claude.rs:1885-1904`). It broadcasts `created` to every connection (`2686`). A conn-less caller passes `provenance: None`, as documented at `:1871-1873`. The session host already drives it this way (`fresh_agent.rs:417-458`). +- The client does not snapshot a Claude placeholder: `getFreshAgentSnapshotThreadId` keys on the durable UUID (`src/lib/fresh-agent-snapshot-thread.ts:50-56`). So a pane born as `{sessionId: , status: 'connected'}` behaves like a UI-created pane after `created`. The hosted REST path already depends on that. +- MCP `new-tab` posts `/api/tabs`, then send-keys with `data: "\r"` (`freshell-tool.ts:469-505`). Its instructions steer agents to `agent:"opencode"` (`:58-61`). The resume shorthand excludes Claude (`:367-370`). +- The client resolves Claude pane defaults: `permissionMode` from saved settings, else `bypassPermissions`; model `opus[1m]`; effort; `defaultPlugins` (`PaneContainer.tsx:706-760`, registry `fresh-agent-registry.ts:63-110`). The REST and hosted paths send none of these today. A REST-created freshclaude pane would therefore run with SDK `permissionMode` undefined, while a UI-created one runs `bypassPermissions`. + +**Design (one writer)** +- Rename the seam to what it is. In `hosted_rest.rs`, `trait HostedFreshAgentRestGateway` becomes `rest_lane.rs` `trait FreshAgentRestLane`, and the `HostedRest*` types become `RestLane*`. `create_hosted_agent_tab` becomes `create_lane_agent_tab`. Add `FreshAgentState::rest_lane_for(provider) -> Option`, which returns: + - the hosted gateway if one is installed (all providers, exactly today's behavior); + - otherwise the in-process **Claude lane** for `provider == "claude"` (freshclaude and kilroy); + - otherwise `None`. Opencode keeps its own path. Codex keeps its 503 (deferred). +- `ClaudeRestLane` (new, `crates/freshell-freshagent/src/claude_rest_lane.rs`) holds a `FreshClaudeState` clone, the `freshAgent.enabled` gate (`Arc bool>`, backed by `fresh_codex.is_enabled()` in `main.rs`, the same gate the WS create uses at `terminal.rs:1890`), and a settings reader. + - **create:** check the gate. If disabled, answer `403 FRESH_AGENT_DISABLED`. Otherwise build a `FreshAgentCreate` with `request_id` set to a server-minted id that also becomes the pane's `createRequestId`. Apply the resolved Claude defaults (below), `session_ref` (resume), `tab_id`/`pane_id`, and `naming_handle` (body or minted; omitted for kilroy, matching `naming::is_unified_agent_mode`). Call `handle_create(create, None)` and map I1: + - `Created` → 200; + - `SESSION_RESERVED` with owner → 409 via `fail_json_conflict_with_owner` (`lib.rs:4704`); + - half-sent fence or invalid input → 400; + - `CLAUDE_SIDECAR_*` (spawn or write) → 503; + - anything else → 500. + + Because the pane's `createRequestId` equals the create's `requestId`, any later browser `freshAgent.create` for that pane replays the same session through `create_dedup`. That makes "no parallel writer" hold by construction, not by convention. + - **send:** strip exactly one trailing `\r` or `\n` (the submit key). A payload made only of line terminators is a **no-op 200 `{status:"noop"}`**, so the MCP pattern of sending literal text and then a separate `ENTER` works. Resolve the session id: `sessionRef` durable if known, else the pane's `sessionId`. If it is not live, answer `409 FRESH_AGENT_SESSION_NOT_LIVE` (retryable; see I5). Otherwise subscribe to I3, call `handle_send` with `request_id: Some(rid)` and `settings: None`, then wait for `Settled` with `rid` in `retired_request_ids`: + - before the deadline: 200 `{status:"idle", submittedTurnId: rid, sessionRef}`; + - deadline reached: approx `{status:"approx"}`; + - `Ended`: `409 FRESH_AGENT_SESSION_ENDED`. + + The turn goes through the normal consumer, so the unified `freshAgent.turn.complete` edge, the bell and attention all fire unchanged. + - **capture:** find the durable id (pane `sessionRef`, else a new `FreshClaudeState::durable_id_for(id)` that reverse-looks-up `cli_index`). With no durable id, return empty text (no turn yet). Otherwise read `get_snapshot(session_type, durable)` and render with the shared `render_snapshot_text`. G28 states: `Absent` and live → empty text; `Absent` and not live → 404; `Unreadable` → 503. +- **Ordering for create and split:** mint the layout ids first (`layout.create_tab` / `layout.split_pane`). Then create the session with that pane context, which G19's `FRESHELL_PANE_ID` needs. Then attach content, register the `PaneEntry`, and broadcast `tab.create`/`pane.split` with `activate:false` (focus-neutral contract). On failure, close the tentative pane or tab. Split already works this way (`pane_ops.rs:185-260`). The hosted lane moves to the same order, with no visible change. +- **Defaults:** `claude_create_defaults.rs` provides `fn resolve_claude_rest_create(session_type, body: &RestLaneProviderInputs, settings: &ServerSettings) -> ResolvedClaudeCreate`. It mirrors `PaneContainer.tsx:706-760` for the Claude runtime: + - model = body, else registry `opus[1m]`. This mirrors the client's current rule; G11 will change both sides. + - `permissionMode` = body, else `settings.freshAgent.providers[st].defaultPermissionMode`, else `bypassPermissions`. + - effort = body, else provider effort, else `high`. + - plugins = body, else `settings.freshAgent.defaultPlugins`. + - `modelSelection` = body, else provider. + + The same values go into the create and into `paneContent`, so the browser displays and later sends identical settings. These defaults also apply to Claude through the **hosted** lane, which fixes the same divergence there. Codex and opencode are untouched. +- **`pane_entry_for_request`:** replace the gateway-only fallback with "layout fallback when `rest_lane_for(content.provider)` is `Some`". Also parse a provider-matched `sessionRef` into `PaneEntry.durable_id`. +- **MCP tool (`tools/freshell-mcp/freshell-tool.ts`):** `agentResumeProvider` accepts `'claude'` (and `'kilroy'`, which maps to provider `claude`). Update the `INSTRUCTIONS` "Fresh agents" text (`:58-61`) to say `agent:"claude"|"kilroy"|"opencode"`. No test asserts on that text. +- **Docs:** update the AGENTS.md "Fresh-Agent Orchestration" paragraph and add a README usage line. `docs/index.html` is not affected (no UI change). +- **Logging (JSONL, ids only, never prompt text):** `freshagent.claude.rest_lane.create` (`info`: request_id, session_type, outcome, code, elapsed_ms) and `freshagent.claude.rest_lane.send` (`info`: pane_id, session_id, request_id, outcome, completed, waited_ms). Refusals log at `warn`. + +**Alternative considered and rejected:** let the browser perform the create (REST broadcasts a `creating` pane and waits for the browser's `freshAgent.created`). This hangs headless callers that have no browser connected. It makes REST latency depend on a browser, and it needs a server fallback anyway, which would mean two create drivers racing on one `requestId`. + +**Deferred.** +- An in-process **codex** REST lane: it would be a new codex feature. After this PR it is about one struct plus the `rest_lane_for` arm. +- Moving **opencode's** bespoke REST lane (`lib.rs:4864-4990, 6339-6960`) onto `FreshAgentRestLane`: a separate cleanup. +- Early return on `Waiting` (an approval is pending): rare under the bypass default; follow-up. + +### 2.6 G29: hosted gateway routing for `freshAgent.configure` + +**Verified current behavior** +- `HostedFreshAgentCommand` and `from_message` cover create, attach, send, interrupt, kill, recovery.stop, approval, question, compact, fork, undo and redo. They do **not** cover `FreshAgentConfigure` (`freshell-ws/src/hosted_fresh_agent.rs:16-48`). It ends in a `_ => None` wildcard, so nothing forces new variants to be classified. +- `dispatch_if_installed` runs first, under the rule "never allowed to fall through" (`terminal.rs:1091-1096`). Configure falls through to the in-process states (`terminal.rs:2092-2113`), which do not own hosted sessions. The result is: + - Claude: an invisible top-level `SESSION_NOT_FOUND` (`claude.rs:4748-4750`). + - Codex: a nested `INVALID_SESSION_ID`, which falsely marks the pane lost. +- In hosted mode each send already carries the pane's settings. The actor writes them into the soul profile (`host_actor.rs:581-630, 1207-1223`). So a hosted model change today only takes effect at the next turn, and other devices are not told about it. +- `compact` already shows the full hosted RPC path: `FreshAgentCompactRequest` (`freshell-runtime-protocol/src/fresh_agent_ops.rs:8`), `AdminCommand::FreshAgentCompact` (`lib.rs:2863`), supervisor (`service.rs:531, 1699`), session host (`main.rs:622`), actor `FreshAgentOperation::Compact` (`host_actor.rs:182-193`), and transport `dispatch_operation` (`fresh_agent.rs:784-890`). + +**Target** +- **Classifier.** `pub(crate) fn hosted_route(&ClientMessage) -> HostedRoute { Gateway(HostedFreshAgentCommand), NotFreshAgent }`, written as an exhaustive `match` over all 44 `ClientMessage` variants with **no wildcard**. Adding a variant fails to compile in `freshell-ws` until someone decides where it goes. `dispatch_if_installed` uses it. Add `HostedFreshAgentCommand::Configure`. +- **Proxy** (`fresh_agent_proxy.rs:193-450`): add a `Configure` arm. It resolves the soul and calls `client.fresh_agent_configure(request_id, soul, turn_settings(settings, cwd))`. An unknown soul or an RPC failure produces `operation_error_event`, a nested error the user can see. +- **Runtime RPC (compact pattern):** + - `FreshAgentConfigureRequest { soul_id, settings: FreshAgentTurnSettings, expected_control_epoch }` + - `AdminCommand::FreshAgentConfigure` / `HostCommand::FreshAgentConfigure` + - runtime-client method `fresh_agent_configure` + - supervisor route + - session-host dispatch + - actor `configure()`: `apply_turn_settings` to the profile, persist, then `transport.dispatch_operation(&FreshAgentOperation::Configure { settings })` + - `HostedTransport` arm: build `FreshAgentConfigure`, then call the provider's `handle_configure`. The resulting `freshAgent.session.metadata` or error frames reach the browser through the existing provider-event relay. + + Saving the intent before the live apply matches the current per-send rule. If the live apply is refused (Claude refuses mid-turn), the setting still applies at the next turn, as it does today. + +--- + +## 3. New probe evidence (real CLI 2.1.237 / SDK 0.3.237, local fake endpoint; Anthropic API never contacted) + +### 3.1 Sidecar shutdown versus hard kill (G31) +Scripts: `/tmp/claude-1000/-home-dan-code-freshell/1374fc0b-889d-437e-b369-0aa473c2880f/scratchpad/shutdown-probe/harness.mjs` and `/tmp/claude-1000/-home-dan-code-freshell/1374fc0b-889d-437e-b369-0aa473c2880f/scratchpad/shutdown-probe/child.mjs`. A child node process acts as the sidecar and holds one streaming turn. The fake provider streams for about 6 s. + +| Mode | Node exit | Provider request closed | CLI death | +|------|-----------|-------------------------|-----------| +| `graceful`: `abort.abort(); q.close(); process.exit(0)` (exactly `index.mjs` `shutdown`) | +24 ms after the shutdown signal | +54 ms | **+126 ms** | +| `hard`: SIGKILL node mid-turn (what Windows `start_kill` does when it beats the shutdown line) | immediate | **only when the provider finished the turn (+5.8 s)** | +2.1 s after the turn ended | + +Conclusions: +- The graceful path reliably stops the CLI. A 2 s grace period is generous (about 15 times the observed time). +- When node is killed hard, the orphaned CLI **keeps executing the in-flight turn until it ends**. With tool use that can be minutes of file edits. It does then exit on stdin EOF. +- On Linux the `/proc` tag reaper covers this today. On Windows and macOS nothing does. Hence the grace period plus the tree guard. + +### 3.2 MCP environment inheritance (G19) +Scripts: `/tmp/claude-1000/-home-dan-code-freshell/1374fc0b-889d-437e-b369-0aa473c2880f/scratchpad/mcp-env-probe/probe.mjs` and `/tmp/claude-1000/-home-dan-code-freshell/1374fc0b-889d-437e-b369-0aa473c2880f/scratchpad/mcp-env-probe/mcp-server.mjs`. +- The stdio MCP child received `FRESHELL`, `FRESHELL_URL`, `FRESHELL_TOKEN`, `FRESHELL_TAB_ID` and `FRESHELL_PANE_ID` from the SDK `env` option, unchanged. +- It started about 806 ms after `query()` both with a prompt and with **no prompt**. The `system/init` message reported `mcp_servers: [{name:"freshell", status:"connected"}]`. +- The vendored `sdk.mjs` builds `Z.push("--mcp-config", JSON({mcpServers}))`, so MCP config goes on argv. A token must therefore never go in `mcpServers.env`. + +--- + +## 4. Ordered TDD commits + +Each step: write the failing test (red), make it pass (green), then refactor. A "refactor" commit has no behavior change and is pinned by the tests named in it. Commit messages end with the attribution trailer. + +### PR1: G28 +1. **red:** `claude_snapshot.rs` unit tests for `claude_home_candidates_from`: + - (a) HOME unset, injected home `/h` → `[/h/.claude]`; + - (b) Windows-shaped input (no HOME, injected `C:\Users\u`) → `[C:\Users\u\.claude]`; + - (c) order `CONFIG_DIR > CLAUDE_HOME > home`, with duplicates removed. + + **green:** pure function plus wrapper. **refactor:** delete the `cfg(windows)` branch in `locate_transcript_selected`. Rewrite `snapshot_with_no_resolvable_store_root_is_io_not_notfound` (`:1733-1748`) to drive "no roots" through the pure seam. Its intent (cannot check, so must not deny) is kept: on POSIX, `home_dir()` can no longer be emptied through env. +2. **red:** locator tests: + - missing `projects` → `Absent`; + - `projects` with mode 000 → `Unreadable(Io{kind: PermissionDenied})` (skipped when `geteuid()==0`); + - a hit under root 2 while root 1 is unreadable → `Found`; + - root 1 unreadable and root 2 absent → `Unreadable`; + - the nested layout is still found; + - no roots → `Unreadable(NoStoreRoot)`. + + **green:** `locate_transcript_state`, `find_transcript_state`; `find_transcript` becomes a projection of it. +3. **red:** `get_claude_snapshot` with `CLAUDE_CONFIG_DIR` → mode-000 projects → `Io` (500), not `NotFound`. **green:** map the three states. +4. **red:** `crates/freshell-ws/tests/freshagent_claude_attach.rs`. Attach by durable UUID with an unreadable store: assert **no** `INVALID_SESSION_ID` frame, and that the resume answers transiently. **green:** `resume_for_attach` match. +5. **red:** `crates/freshell-ws/tests/freshagent_claude_rollback.rs`. Undo with an unreadable store → `INTERNAL_ERROR`, not `NOTHING_TO_UNDO`. Redo with an unreadable original → `INTERNAL_ERROR`, not `REDO_UNAVAILABLE`. **green:** the five rollback sites. +6. **red:** `freshell-server/src/existence.rs` test with an injected locator returning `Unreadable` → `SessionExistence::Unknown` (both the cold and warm-fallback paths). **green:** change the locator type and wire `main.rs:2110`. +7. **refactor:** `claude_truth.rs` `ClaudeRoots::from_env` delegates to the shared candidates. Its existing tests pin the result. + +### PR2: G31 and G30 +8. **refactor:** move `WindowsJob` to `freshell-platform::process_job::KillOnCloseJob`, and have checkpoints use it. Pinned by the existing checkpoint tests in the native Windows lane (`docs/development/windows-electron-build.md`). +9. **red (Linux):** `claude_spawn.rs` test. A fake sidecar script writes `shutdown-handled` and exits 0 on the shutdown line, and writes `sigterm` from its SIGTERM trap. `graceful_then_kill` must leave only `shutdown-handled` and return within the grace period. A second fake ignores the shutdown line: teardown must still finish within grace plus the confirmation budget, the process must be dead, and the log must say `graceful:false`. **green:** `graceful_then_kill`, used by `teardown_removed_session` and `shutdown()` (concurrent). +10. **red (Linux):** the `SidecarTreeGuard` Unix arm. Spawn `sh -c 'sleep 300 & sleep 300'` through the builder with `process_group(0)`. `guard.terminate()` must leave no group members (`kill(-pgid,0)` returns ESRCH). **green:** the Unix arm, wired into `spawn_sidecar`. The Linux `/proc` confirmation stays. +11. **red (`cfg(windows)`):** spawn `node -e "spawn(process.execPath,['-e','setInterval(()=>{},1e3)']); setInterval(()=>{},1e3)"` through the builder. Dropping the guard must reach `active_processes()==0` within 5 s, and `teardown_removed_session` must return `Confirmed`. **green:** the Windows arm (suspended spawn, assign, resume). Runs in the native Windows lane. +12. **red (JS):** `test/unit/claude-sidecar/sdk-env.test.ts`. It strips `CLAUDECODE`; it strips `ANTHROPIC_API_KEY` unless `FRESHELL_CLAUDE_ONECLI_API_KEY==='1'`; it drops the flag itself. Extend `model-catalog.test.ts`: with the flag set, the fake `query` receives the key. **green:** `sdk-env.mjs`, imported by both entry points. +13. **red (Rust):** `ClaudeNodeCommand::from_parts` tests. With an injected secrets snapshot `{ANTHROPIC_API_KEY}` and `FRESHELL_PROVIDER_RUN_AS_*`, the sidecar command and the catalog-probe command produce the same program, wrapper args, ONECLI flag and secret env. Only the entry file differs. **green:** the builder. `spawn_sidecar` and `ClaudeCatalogProbe` use it, and the probe's timeout calls `guard.terminate()`. +14. **contract (real CLI):** `test/integration/providers/claude-sidecar-shutdown.{mjs,test.ts}`, modelled on `claude-checkpoint-uuid-replay.*`. It runs the **real** `index.mjs` with the pinned CLI and a local fake endpoint, mid-turn. Sending `{"type":"shutdown"}` must make node exit, the CLI pid disappear, and the provider request close, all within 1 s. This pins the assumption the grace period depends on. + +### PR3: G19 +15. **refactor:** add `freshell_platform::freshell_context_env`. Both `build_terminal_base_env` copies delegate. Pinned by `terminal.rs:11544-11575` and the `terminal_tabs` env tests. +16. **red (JS):** `test/unit/claude-sidecar/mcp-servers.test.ts`. + - Ordinary request → `{freshell:{command,args}}`. + - Hosted request with hosted env → the fixed hosted recipe. + - Neither → `undefined`. + - Both set → hosted wins and ordinary is ignored. + - The returned config **never has an `env` key**. That is the behavior that keeps a token off argv. + + Second case: `freshellContext` is merged into the `env` that the fake `query` receives. **green:** `mcp-servers.mjs`; `index.mjs` uses it. +17. **red (protocol):** serde round-trip for `FreshAgentCreate.pane_id` and `FreshAgentAttach.{tab_id,pane_id}`. An absent field is omitted on serialize, and old frames still parse. **green.** Matching zod change; extend `test/unit/shared` ws-protocol tests. +18. **red (Rust integration, I7 harness or request-log fake):** + - With a fake MCP provider returning `Some`, the create line has `freshellMcp`. The spawn env has `FRESHELL_URL`/`FRESHELL_TOKEN`. `freshellContext` matches the create's `tabId`/`paneId`. Same for kilroy. + - With the provider returning `None`, no `freshellMcp`. + - With `with_hosted_mcp(true)`, never `freshellMcp`. + - Attach-resume and rollback respawn carry the same recipe and context (through I4). + + **green:** state setters, `spawn_sidecar` env, and the create builder. +19. **red (`freshell-server`):** the provider closure built in `main.rs` (extract a `fn claude_mcp_provider(settings_lock, runtime)` for testing). After `PATCH /api/settings {codingCli:{mcpServer:false}}` it returns `None`, and after flipping back it returns `Some`, with no restart. **green.** +20. **red (client):** the `FreshAgentView` tests that capture sent frames assert that `freshAgent.create` includes `paneId` and `freshAgent.attach` includes `tabId`/`paneId`. **green.** +21. **contract (real CLI):** `test/integration/providers/claude-freshell-mcp.{mjs,test.ts}`. Real `index.mjs` and the pinned CLI. Create with `freshellMcp` pointing at a probe MCP server, and give the sidecar env `FRESHELL_URL`/`FRESHELL_TOKEN=`. Assert: + - init reports `freshell: connected`; + - the probe server saw `FRESHELL_URL`/`FRESHELL_TOKEN`/`FRESHELL_TAB_ID`/`FRESHELL_PANE_ID`; + - `/proc//cmdline` does **not** contain the token. + +### PR4: G18 (needs I1–I3, or their fallbacks) +22. **refactor (only if I1/I2 have not landed):** the typed `ClaudeCreateOutcome` and `ClaudeSendOutcome` returns, with WS frames byte-identical. Pinned by every existing `freshagent_claude_*` test. +23. **refactor:** rename `hosted_rest` to `rest_lane` (trait and types), and `create_hosted_agent_tab` to `create_lane_agent_tab`. Add `rest_lane_for`. Pinned by `pane_ops_tests.rs`, `pane_ops_tab_tests.rs`, `snapshot.rs:513`, `lib.rs:9231` and `fresh_agent_proxy_tests.rs`. +24. **refactor:** move `render_snapshot_text` into `freshell-agent-runtime` (`snapshot_text.rs`) and have session-host use it. Its test `snapshot_text_renderer_is_plain_and_utf8_bounded` moves along. +25. **red:** a shared fixture `test/fixtures/fresh-agent/claude-create-defaults.json` (settings input → expected create fields). + - A vitest test runs the extracted pure `buildFreshAgentPaneContent` (moved out of the `createContentForType` callback at `PaneContainer.tsx:692`) over the fixture. + - A Rust test runs `resolve_claude_rest_create` over the same fixture. + + Both run real resolvers, and either side's drift fails its own test. **green:** extract the TS function; add the Rust resolver. +26. **red (`lib.rs` unit, fake `FreshClaudeState` sidecar or the I7 harness), no gateway:** + - `POST /api/tabs {agent:"claude"}` → 200 `{tabId,paneId,sessionId}`. The broadcast `tab.create` has `paneContent{provider:"claude", sessionType:"freshclaude", sessionId, createRequestId, status:"connected", model:"opus[1m]", permissionMode:"bypassPermissions", effort:"high"}`. + - `agent:"kilroy"` is the same apart from `sessionType` and no `namingHandle`. + - `agent:"codex"` is still 503. + - With the gate off → 403 `FRESH_AGENT_DISABLED`, and no layout tab is left behind. + + **green:** `ClaudeRestLane`, plus `set_claude_rest_lane` in `main.rs`. +27. **red:** create failure mapping. A `SESSION_RESERVED` with owner → 409 with `ownerKind`/`ownerGeneration`. A sidecar spawn failure (bad `FRESHELL_CLAUDE_NODE`) → 503. Both roll the tentative tab back. **green.** +28. **red:** `POST /api/panes/:id/split {agent:"claude"}` creates through the lane and broadcasts `pane.split` with `activate:false`. On failure the split is closed. **green.** +29. **red:** `pane_entry_for_request` resolves a UI-created freshclaude pane (layout only, with `sessionRef`) with no gateway. Send-keys and capture reach the lane. **green.** +30. **red:** send-keys. + - An idle session's turn → 200 `{status:"idle", submittedTurnId}` after `Settled` names the request id. + - `"\r"` alone → 200 `noop`, and nothing is sent to the sidecar. + - A trailing `\r` is stripped (the sidecar request log shows the text without it). + - A session that is not live → 409 `FRESH_AGENT_SESSION_NOT_LIVE`. + - Deadline → `approx`. + - Fallback build (no I3): busy → 409 `SESSION_BUSY`. + + **green.** +31. **red:** capture. + - After a scripted turn → `"user: …\nassistant: …"`. + - No turn yet → empty 200. + - Unreadable store (G28) → 503. + + **green.** +32. **integration:** `crates/freshell-ws/tests/rest_freshclaude_lane.rs`, using the merged REST and WS harness (`rest_claude_identity.rs:52-…`) and I7. + - `POST /api/tabs {agent:"claude"}`; a WS observer sees `freshAgent.created` and `ui.command tab.create`. + - Send-keys returns idle. The observer sees `freshAgent.turn.complete` for that session (the unified edge). + - Capture shows the assistant text. + - **One writer:** a second WS client sends `freshAgent.create` with the pane's `createRequestId`, gets the replayed `created` with the same id, and the fake's spawn counter is still 1. +33. **red (`test/unit/mcp/freshell-tool.test.ts`):** `new-tab {agent:"claude", resume:}` posts `sessionRef {provider:"claude", sessionId}`, and `kilroy` maps to the same. **green:** `agentResumeProvider`. Then update the instructions text (not tested). +34. **e2e:** see §5.5. Then the docs commit (AGENTS.md, README). + +### PR5: G29 +35. **red (`freshell-ws`):** `hosted_route` unit test. One sample frame of every `freshAgent.*` type (create, attach, send, interrupt, configure, compact, approval.respond, question.respond, kill, recovery.stop, fork, undo, redo) must classify as `Gateway`. Configure is red today. **green:** the exhaustive no-wildcard classifier, plus `Configure`. +36. **red (`freshell-runtime-protocol`):** `FreshAgentConfigureRequest` serde round-trip in `fresh_agent_ops_tests.rs`, and `AdminCommand`/`HostCommand` variants. **green.** +37. **red (supervisor `service.rs` tests):** admin configure routes to the soul's host and keeps the epoch fence. **green.** +38. **red (`host_actor` tests):** `configure()` persists the settings into the profile (they survive `FreshAgentHostActor::open` on the same state dir) and calls `transport.dispatch_operation(Configure)`. A refused live apply still keeps the intent. **green.** +39. **red (session-host):** add `Configure` to `semantic_operations()` and `direct_and_hosted_provider_transport_operations_have_matching_rows` (`fresh_agent.rs:2326, 2597`). Direct and hosted rows must match, and the Claude row's provider frames include `freshAgent.session.metadata`. **green:** the `HostedTransport` arm. +40. **red (`fresh_agent_proxy_tests.rs`):** Configure with an unknown soul → a nested `freshAgent.error`. Configure with a known soul → `fresh_agent_configure` called with the mapped settings. **green:** the proxy arm and the runtime-client method. + +--- + +## 5. Test plan by layer + +### 5.1 Unit (Rust) +- `claude_snapshot.rs`: candidates (pure seam), three-state traversal, snapshot mapping. +- `claude_spawn.rs`: graceful versus forced teardown with timing bounds, Unix process-group guard, builder parity. Windows job tests are `cfg(windows)`. +- `claude_create_defaults.rs`: fixture-driven. +- `claude_rest_lane.rs`: create, send and capture mapping, against the request-log fake. +- `lib.rs` / `pane_ops*` tests: lane selection, layout rollback, pane-entry fallback. +- `freshell-protocol`: serde for the new optional fields, and `agent_mcp_server_enabled`. +- `freshell-ws` `hosted_fresh_agent.rs`: the classifier. +- `freshell-runtime-protocol`, `freshell-supervisor`, `freshell-agent-runtime` `host_actor`: configure. +- `freshell-server` `existence.rs` (`Unreadable` → `Unknown`) and the settings-driven MCP provider. + +### 5.2 Unit (JS/TS) +- `test/unit/claude-sidecar/sdk-env.test.ts`, `mcp-servers.test.ts`, and `model-catalog.test.ts` (extended). +- `test/unit/mcp/freshell-tool.test.ts` (Claude resume shorthand). +- Client: `FreshAgentView` frame tests (`paneId`/`tabId` on create and attach), and the `buildFreshAgentPaneContent` fixture test. +- `shared/ws-protocol` schema tests. + +### 5.3 Integration (Rust, `crates/freshell-ws/tests`) +- `freshagent_claude_attach.rs`: unreadable store is not lost. +- `freshagent_claude_rollback.rs`: unreadable store gives `INTERNAL_ERROR`. +- New `rest_freshclaude_lane.rs`: create, send, complete edge, capture, one-writer dedup, split, layout fallback after the REST pane map is cleared (simulates a web restart). +- New `freshagent_claude_mcp_launch.rs`: `freshellMcp`/context on create, attach-resume and rollback; hosted exclusion. +- All of these use the real `index.mjs` plus the scripted SDK once I7 lands. + +### 5.4 Contract (real pinned CLI, local fake endpoint, `test/integration/providers/`) +- `claude-sidecar-shutdown`: the graceful shutdown stops the CLI within 1 s. +- `claude-freshell-mcp`: the MCP child inherits the endpoint and context, init reports connected, and the token is absent from CLI argv. + +### 5.5 Browser e2e (Playwright `rust-chromium`, `RustServer` with the fake Claude SDK sidecar `test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs`) +Run these on the configured `FRESHELL_E2E_BACKEND`. They must not be in `CLOUD_SKIP_SPECS`. +- **New `fresh-agent-rest-claude-rust.spec.ts`** (G18): + 1. Through `McpStdioClient` (`helpers/mcp-stdio-client.ts`): `new-tab {agent:"claude", prompt:"hello"}`. The browser shows a Freshclaude tab without taking focus (`activate:false`), and the transcript shows the scripted reply. + 2. With the user on another tab, the new tab gets the needs-attention mark and exactly one bell (the alert contract holds for REST-driven turns). + 3. `capture-pane` returns the reply. + 4. `split-pane {agent:"kilroy"}` with the kilroy flag on. + 5. `send-keys` to a **UI-created** freshclaude pane (picker → Freshclaude) runs a turn that is visible in that pane. + 6. A web-server restart, then `capture-pane` on the same pane, still works (layout fallback). +- **G19:** teach the e2e fake sidecar a scripted rule `__call_freshell_mcp__`. When the create line has `freshellMcp`, the fake spawns that command with its inherited env, performs MCP `initialize` and `tools/call` (`freshell` `list-tabs`), and returns the result as assistant text. The spec types that prompt in a Freshclaude pane and asserts that the reply lists the open tabs. This proves end to end that the command Rust resolves (`dist/tools/freshell-mcp/server.js` through `ensureMcpServerBuilt`), the endpoint env and the token work against the real server. A second case turns the Settings toggle "Agent MCP server" off, opens a new pane, and asserts the fake reports that no MCP was configured. +- **G28/G31/G30/G29:** no browser surface beyond what is listed. They are covered by unit, integration, contract and the Windows lane. If managed-mode e2e is enabled in CI (`runtime-fresh-agent-qualification-rust.spec.ts`), add one case: change the model in the model dialog, and a second page converges without a send. + +### 5.6 Windows native lane +`cfg(windows)` tests for `KillOnCloseJob` reuse and the Claude tree guard. Also a manual Electron check: close a freshclaude pane mid-turn and confirm no `claude.exe` remains (Task Manager or `Get-Process claude`). Use the documented rsync plus `cmd.exe` pnpm flow (`docs/development/windows-electron-build.md`). + +--- + +## 6. Risks and mitigations + +1. **Interface churn with core-runtime** (I1–I4 touch the handlers core-runtime is rewriting). Mitigation: agree on the signatures in §1 before PR4. PR1–PR3 do not depend on them. Every interface has a documented fallback. +2. **REST send-keys correlation without I3** can report one turn early when a browser send races in. Mitigation: the idle-required fallback, documented, and removed once I3 lands. +3. **Default resolution exists twice (TypeScript and Rust).** Mitigation: the shared fixture runs both resolvers. G11 (saved default model) must update the fixture, and with it both sides. +4. **`home_dir()` semantics on POSIX with HOME unset** now use the passwd home. This matches the CLI, but a deployment that deliberately unset HOME to hide the store would now find it. That is acceptable: the CLI itself uses the same root. +5. **Grace period delays teardown** when a sidecar is wedged (at most 2 s per session). It runs concurrently at server stop. The kill path already waits on tree confirmation, so the difference is small. +6. **Windows job edge cases:** + - Nested jobs (Windows 8+ supports them). + - `CREATE_SUSPENDED` and resuming the main thread (the existing helper `find_process_thread` handles it). + - A server that is itself in a job forbidding nested assignment: the failure is logged and the pane works without the guard. +7. **Token in the freshclaude CLI environment.** The Bash tool can read `FRESHELL_TOKEN`, as it already can in terminal panes. Under the threat model this is acceptable. It is never on argv, and the contract test enforces that. +8. **User-level MCP named `freshell`** (from `~/.claude.json`, with settingSources `user,project,local`) could clash with the injected one. Terminal mode's `--mcp-config` has the same behavior today. Note it in the PR; there is no special handling. +9. **The setting is inconsistent until #446** (freshclaude honors it, terminals do not). Mitigation: the follow-up PR is queued immediately. +10. **The managed-runtime crates** (G29) belong to another plan. Mitigation: a separate PR5, using the compact pattern, with the routing test and classifier first. +11. **The `existence.rs` type change** touches `freshell-server` wiring that the kata 09v1 reconcile relies on. Mitigation: `Unreadable` → `Unknown` is the deferral that existing reconcile code already handles. The cold and warm paths are both tested. +12. **Unix process group for the sidecar.** Terminal Ctrl-C no longer reaches sidecars directly under `pnpm run dev`. They still exit through server `shutdown()` or stdin EOF (`index.mjs:684-686`). The Linux `/proc` reaping is unchanged. + +--- + +## 7. Deferred, with reasons +- **In-process codex REST lane:** a new codex feature, out of scope. It is a small follow-up after PR4 (one `FreshAgentRestLane` implementation). +- **Moving opencode onto `FreshAgentRestLane`:** a cleanup with no freshclaude impact. +- **Gating terminal MCP injection on `codingCli.mcpServer` (#446):** changes terminal behavior for all CLIs. Do it next, as its own PR, using `agent_mcp_server_enabled()`. +- **Hosted catalog probe through the session host (G30 remainder):** managed opt-in mode only, needs a new RPC, and the static fallback exists. Belongs to the managed-provider-environment plan. +- **REST early return on `Waiting`:** rare under the bypass default. Follow-up. +- **macOS tree-kill verification beyond the shared Unix process-group code:** no macOS CI. The code is exercised on Linux. Verify manually on a macOS Electron build when one is next produced. diff --git a/docs/plans/2026-10-22-freshclaude-client-alerts-ux.md b/docs/plans/2026-10-22-freshclaude-client-alerts-ux.md new file mode 100644 index 0000000000..7292918080 --- /dev/null +++ b/docs/plans/2026-10-22-freshclaude-client-alerts-ux.md @@ -0,0 +1,670 @@ +# Plan: client alerts and UX for freshclaude and kilroy (gaps G11, G17, G20, G25, G26) + +Base: worktree `/home/dan/code/freshell/.worktrees/freshclaude-parity`, branch `feat/freshclaude-parity` (identical to `origin/main` 1c7e4dff7). +Phase: design only. No repo files were changed. +Inputs: `audit-gaps.md` and `audit-matrix.md` in the scratchpad. New real-CLI probes for this plan are in `/tmp/claude-1000/-home-dan-code-freshell/1374fc0b-889d-437e-b369-0aa473c2880f/scratchpad/perm-probe/probe-perm.mjs`, with scenarios `remember`, `remember-session`, `shapes`, `plan-allow`, `plan-setmode`, `plan-bypass`, `plan-empty` and `plan-file`. They drive the pinned CLI 2.1.237 / SDK 0.3.237 against a local fake Anthropic endpoint only. + +Kilroy is sessionType `kilroy` on the same claude runtime, so every claude change below covers kilroy too. Kilroy-specific tests are listed where the path differs, which is mostly the picker or settings key `kilroy`. + +--- + +## 0. Summary of the workstream + +| Gap | What the user sees today | What changes | Layers touched | +|---|---|---|---| +| G11 | A model saved for Freshclaude/Kilroy is ignored. Every new or resumed pane opens on Opus 1M. | New and resumed panes start on the saved default model. Its effort levels go with it. One shared resolver replaces two copies of the logic that had drifted apart. | client, plus a settings schema field | +| G17 | On a phone, the tab strip and the tab switcher never show that a tab finished or is waiting. | Both mobile surfaces show the same needs-attention mark as desktop, built from one shared selector, one class helper and one indicator component. Clicking the active card dismisses the mark in click mode. | client | +| G20 | Claude's own "don't ask again" rules are dropped. "Always allow Bash" is a client-only bypass that covers every command and is lost on reload. A plan-mode exit shows raw JSON. After a plan approval the pane still says "plan" while the CLI has left plan mode. | "Allow and don't ask again" returns the CLI's own suggestions, so the CLI enforces scoped rules such as `Bash(npm test *)`. A plan card renders the plan as markdown and offers approval options. The pane's permission mode follows the CLI's real mode. For claude, the client-only bypass is removed. | sidecar, Rust, shared contract, client | +| G25 | A turn that ends while the browser's WebSocket is down never highlights or rings. | The server keeps the last attention-edge time for each claude session and sends it in `ready` on every connection. The client replays only edges newer than what this page already knew. A reload or first connect never replays anything. A notice for a server restart mid-turn is specified, with the server part handed to core. | Rust, protocol, client | +| G26 | A chime that never fires `ended` silences every later bell until reload. A hidden pane that receives an approval or question can take up to 3 s after reveal to show the card. | The ring queue gets a watchdog plus `pause`/`abort` release. Hidden panes owe a non-blocking surface refresh. They do not use the blocking "transcript dirty" path, which would stall for 15 s and then show an error (see 1.5). | client | + +Proposed PRs: four separate PRs, in this order. Each needs explicit user approval before `gh pr create`, per AGENTS.md. +1. PR-A: G26 and G11. These are client only and low risk. +2. PR-B: G17. +3. PR-C: G20. +4. PR-D: G25. This one depends on the core-runtime edge choke point (section 7). + +--- + +## 1. Verified current behavior + +### 1.1 G11: the saved default model is ignored + +- `src/components/panes/PaneContainer.tsx:712-724`: `providerDefaultModel` (the saved `freshAgent.providers[type].modelSelection.modelId`) and `runtimeProviderModel` (`codingCli.providers[runtime].model`) are only consulted when `runtimeProvider === 'codex' || 'opencode'`. Claude gets `freshAgentType.defaultModel` unconditionally. +- `PaneContainer.tsx:726,750-751`: claude panes always persist `model`, which here is always `'opus[1m]'`. They also persist the saved `modelSelection`. +- `src/lib/fresh-agent-registry.ts:176-184`: `resolveEffectiveFreshAgentModel` prefers `content.model` over `content.modelSelection` and the provider defaults. Create and every send therefore carry opus[1m]. The view uses it at `FreshAgentView.tsx:1911` (send) and `:2050` (create). +- `src/lib/session-type-utils.ts:223`: the resume path hardcodes `model: freshAgentType.defaultModel` for every fresh provider. This also affects codex resume (`FRESHCODEX_DEFAULT_MODEL`). +- The defaults themselves: `fresh-agent-registry.ts:69` (freshclaude) and `:105` (kilroy) are both `'opus[1m]'`. +- Saving works. `FreshAgentModelDialog.tsx:384-391` and `FreshAgentSettingsButton.tsx:186-197,406-409` save `{modelSelection:{kind:'exact',modelId}, effort}` per sessionType. For claude they also stamp the model's effort levels, but only into pane content: `FreshAgentModelDialog.tsx:368-370` and `FreshAgentSettingsButton.tsx:394-396` (`modelEffortLevels`). The provider defaults schema (`shared/settings.ts:143-148,748-768`) has no place for those levels. A saved catalog-only model, such as one not in the static opus table at `shared/fresh-agent-models.ts:20-28`, would therefore start with effort normalized against the opus table. For example, a model with no effort levels would still be sent `effort: 'high'`. +- The test gap: `test/unit/client/components/panes/PaneContainer.createContent.test.tsx:361-421` saves `opus[1m]`, which equals the default, so the test cannot fail. +- History: the claude exclusion came from commit 24b490d80 ("Fix fresh-agent runtime edge cases"), which only widened the codex branch. No commit records a deliberate reason for leaving claude out. + +### 1.2 G17: mobile tab surfaces have no attention mark + +- `src/components/TabBar.tsx:737-746`: on mobile, TabBar renders only `MobileTabStrip` and `TabSwitcher`. +- `src/components/MobileTabStrip.tsx:23-120`: the selectors read activity and busy state only, plus a Busy badge. There is no `turnCompletion` selector at all. +- `src/components/TabSwitcher.tsx:44-163`: the cards render title, status and a Busy badge. There is no `turnCompletion` selector. A card click dispatches `setActiveTab` only (`:63-70`), so tapping the already-active card never dismisses its attention in click mode. Desktop does dismiss on click: `TabBar.tsx:583-587` dispatches `dismissTabGreen` when `attentionDismiss==='click'`. +- Desktop draws the mark in `TabItem.tsx:90-94,168-190`. It shows the union `selectTabStripAttention` (`src/store/turnCompletionAttention.ts:22-25`, which is attention plus the watched-completion mark), styled by `tabAttentionStyle` (highlight/pulse/darken/none). The deck repeats similar logic in `src/deck/deck-selectors.ts:183-200`, and `focusTabFromDeck` (`src/deck/deck-actions.ts:10-15`) duplicates TabBar's "dismiss then activate" logic. +- Clearing on tab switch already works for every surface. `useTurnCompletionNotifications.ts:101-112` dismisses on any `activeTabId` change in click mode, and `pane-focus-ownership.ts:312-345` clears watched marks on `setActiveTab`/`switchToNext`/`switchToPrev`. The mobile chevrons and switcher therefore only lack the rendering, plus the dismiss on an already-active card. +- Sidebar rows do show attention (`Sidebar.tsx:400-412,1039,1143`), so a phone user who opens the sidebar sees it. +- Out of scope: the OS-level escalation (Web Notification or title flash) is part of kata 1wyr and is not included here. + +### 1.3 G20: claude approvals + +Code facts: +- `crates/freshell-claude-sidecar/permission-channel.mjs:36-73` parks the SDK `suggestions`, `blockedPath` and `decisionReason` and forwards them on `sdk.permission.request`. `respondPermission` (`:136-143`) resolves the client's decision verbatim. +- Rust `PendingApprovalEntry` (`crates/freshell-freshagent/src/claude.rs:490-523`) and its fold (`claude.rs:9151-9181`) drop `suggestions`. The WS broadcast relays the line verbatim (`claude.rs:9340-9363`), but the card renders only from the REST snapshot's `pendingApprovals` (`FreshAgentView.tsx:4320,4646`). `FreshAgentPendingApprovalSchema` is `.strict()` with no `suggestions` (`shared/fresh-agent-contract.ts:262-270`). +- `respondToApproval` always sends `{behavior:'allow'}` or the deny shape (`FreshAgentView.tsx:4389-4403`). +- "Always allow this session" is `alwaysAllowToolsRef` (`FreshAgentView.tsx:1620`). It is cleared on a new conversation (`:2134`), lost on remount or reload, and auto-answers every later approval with the same tool name (`:4131-4154`), whatever the command. +- `FreshAgentApprovalCard.tsx:56-62` has no ExitPlanMode branch, so the plan is shown as `JSON.stringify(input)`. +- The sidecar forwards only `system/status` with `status:'compacting'` (`index.mjs:286-287`). `permissionMode` on `system/status` and `system/init` is ignored, and `sdk.session.init` does not carry it (`index.mjs:265-279`). The Rust consumer folds the permission mode only from `sdk.configured` (`claude.rs:8451-8490`). + +Probe facts (CLI 2.1.237, fake endpoint, `perm-probe/probe-perm.mjs`): +1. `Bash npm test` in default mode: `options.suggestions = [{type:'addRules', rules:[{toolName:'Bash', ruleContent:'npm test *'}], behavior:'allow', destination:'localSettings'}]`, plus `decisionReason`, `displayName` and `description` (no `title`). +2. Returning `{behavior:'allow', updatedPermissions: suggestions}`, with or without `updatedInput`, writes `{"permissions":{"allow":["Bash(npm test *)"]}}` to `/.claude/settings.local.json`. The next `npm test` is not prompted. A different command (`ls /`) still is. +3. The same suggestions with `destination:'session'` also stop the next prompt, and no file is written. +4. Other shapes: + - `Write` in default mode: `[{type:'setMode', mode:'acceptEdits', destination:'session'}]`. + - `Read /etc/hostname`: `[{type:'addRules', rules:[{toolName:'Read', ruleContent:'//etc/**'}], behavior:'allow', destination:'session'}]` with `decisionReason:'Path is outside allowed working directories'`. + - A compound Bash command: two suggestions with mixed destinations. + - `ls /`: an empty list plus `blockedPath:'/'`. +5. `ExitPlanMode` in plan mode gets no suggestions. When a plan file exists, the CLI injects `input.plan` (the file's text) and `input.planFilePath` (`plan-file`). With no plan file the input is `{}` (`plan-empty`). +6. Approving `ExitPlanMode` makes the CLI emit `system/status {permissionMode}` at once, and the next turn's `system/init` reports the same mode: + - plain allow gives `default`; + - `updatedPermissions:[{type:'setMode',mode:'acceptEdits',destination:'session'}]` gives `acceptEdits`; + - `setMode bypassPermissions` gives `bypassPermissions`. This works because the sidecar sets `allowDangerouslySkipPermissions:true`. + +Consequence: the mode drifts today, and it would drift further once suggestions are honored. Pane content still says `plan` while the CLI is in `default`. The sidecar's `session.settings.permissionMode` (`session-settings.mjs:22-29`) also still says `plan`. Picking "Plan" again from the popover is then a silent no-op: `configureSession` diffs against the stale `plan` and never calls `setPermissionMode`. + +### 1.4 G25: attention edges missed while disconnected + +- Fresh-agent frames go out on the shared bus only (`claude.rs:1850-1854`). Nothing is buffered per session. +- A plain reconnect re-attaches every pane (`FreshAgentView.tsx:2782-2826`). For a claude session the server still tracks, attach answers with no frame at all (`claude.rs:6658-6672`, "tracked-and-alive: no frame"). +- Death-minted edges are emitted after eviction (`claude.rs:8832-8859`), so no later per-session state survives to report them. +- The client dedupe is `recordTurnComplete`'s `at <= last` check, keyed by `provider:sessionId` and `…#waiting` (`turnCompletionSlice.ts:81-96`, `turnCompletionThunks.ts:99-149`). Baselines are reset on the first ready of the page and on a server restart (`App.tsx:1338-1359`). +- The stale comment at `turnCompletionThunks.ts:128-133` ("server buffers and replays … FIRST subscriber") and the claim at `:92-97` ("never replayed from a snapshot") describe a Node-era server. +- The WS layer subscribes to the bus before building the handshake (`crates/freshell-ws/src/lib.rs:857-860`). An edge recorded after the handshake snapshot is therefore always delivered live, so there is no gap between the `ready` snapshot and the live stream. `ready` already carries an optional, capability-gated, additive payload (`runtimeOwners`, `lib.rs:650-700`; `server_messages.rs:1263-1288`; client parser `src/lib/ready-message-schema.ts:12-55` with `.catch(undefined)`). +- Part 2, a restart mid-turn: `FreshClaudeState::shutdown` drains the map and aborts consumers before the kill (`claude.rs:1828-1847`). The death arm and its banner never fire, and the pane cold-resumes idle with no notice. + +### 1.5 G26: bell queue and hidden-pane approvals + +- `src/hooks/useNotificationSound.ts:80-136`: `ringingRef` is released only by `finish()`. That runs on Audio `ended`, on the fallback oscillator's `ended`, or when there is no audio machinery. There is no timer and no `pause`/`abort` listener, so a chime paused without `ended` wedges every later bell until the page reloads. +- `FreshAgentView.tsx:149-182`: `permission.request`, `permission.cancelled`, `question.request` and `question.cancelled` are snapshot-invalidating but not transcript-invalidating. `:3141-3155`: while hidden, the pane neither marks itself dirty nor refreshes for them. On reveal, `requestRevealRefresh` is skipped unless the pane is dirty (`:1769-1778,2832-2836`). The card then waits for the 3 s busy poll (`:4029-4042`). +- The audit's suggested fix (`markSnapshotDirty()` on these events) would be wrong, for this reason: + - A dirty pane shows the blocking "Refreshing conversation" overlay (`:4360,4780-4800`). + - The overlay clears only when the refreshed snapshot's `revision` is greater than the revision recorded when the pane was marked dirty (`:3384-3402`). + - The claude revision is the transcript file's mtime (`claude_snapshot.rs:1187-1223`), and a permission request does not have to touch the transcript. + - The revealed pane would therefore retry for 15 s (`REVEAL_REFRESH_MAX_WAIT_MS`) and then show "The conversation did not finish refreshing". + - This is why `FreshAgentView.hidden-rebind.test.tsx:312` pins "does not mark a hidden pane stale for a status-only snapshot event". The fix needs a separate, non-blocking lane. + +--- + +## 2. Target behavior + +### G11 +- A new Freshclaude or Kilroy pane uses the saved default for its type. Precedence is: saved model, then the registry default. +- A pane created to resume a session from the sidebar, context menu, handoff, tab restore or the tabs thunk uses the same rule. +- An existing pane keeps its own model, including in lost-session recovery and re-creates. +- When the saved model is catalog-only, its effort levels (captured when it was picked) go with it. Effort is clamped against those levels, never against the opus table. +- One function computes new-pane model defaults for every provider. For codex and opencode, new panes behave as they do today. Codex resume now honors the saved codex default as a side effect of using the shared resolver (decision D2). + +### G17 +- MobileTabStrip: + - The active tab's title pill uses the same attention treatment as desktop when that tab has the union mark. + - A status badge says "N other tabs need attention" when other tabs have it. + - The previous and next chevrons each show a small dot when an attention tab lies in that direction. +- TabSwitcher: each card carries the attention treatment, and its accessible name includes "needs attention". In click mode, tapping a card dismisses its attention, including the already-active card, by the same path as desktop and the deck. +- `tabAttentionStyle: 'none'` hides every mark on every tab surface. +- One selector, one class helper (`tabAttentionClassName`) and one presentational `TabAttentionIndicator` serve TabItem, MobileTabStrip and TabSwitcher. TabItem's look is unchanged; the refactor is protected by the existing class tests. + +### G20 +- The claude approval card shows these actions: + - "Allow"; + - "Allow and don't ask again" when the CLI offered suggestions. It is described in plain language, one line per suggestion: "Always allow `Bash(npm test *)` in this project (saved to .claude/settings.local.json)", "Allow `Read(//etc/**)` for the rest of this conversation", "Auto-accept file edits for the rest of this conversation", and so on. It sends exactly the displayed, server-normalized suggestions as `updatedPermissions`; + - "Deny". + The client-only "Always allow " bypass is gone for claude. +- Codex keeps its client-only behavior. Its label is corrected to "Always allow in this pane", with the tooltip "Until this pane reloads" (decision D3). +- ExitPlanMode gets its own "Plan ready for review" card. It renders `input.plan` as markdown, shows `planFilePath` in small mono text, and offers four actions: + - "Approve plan": plain allow, so the CLI goes to `default`; + - "Approve and auto-accept edits": `setMode acceptEdits`, session; + - "Approve and stop asking for permission": `setMode bypassPermissions`, session; + - "Keep planning", with an optional "What should change?" field. It sends deny with that text, or the default "The user wants to keep planning." + An empty plan gets clear copy. +- The pane's permission mode follows the CLI: + 1. The sidecar folds `permissionMode` from `system/status` and `system/init` into its own session state and emits `sdk.session.metadata{permissionMode}` on a real change. + 2. Rust folds that into the session's settings, persisting it the same way as configure, and relays `freshAgent.session.metadata{permissionMode}`. + 3. The client stores `session.permissionMode` and converges the pane's `permissionMode`. It does not save this as a provider default. + +### G25 +- Every connection that opts in with `hello.capabilities.freshAgentAttentionReplayV1` gets `ready.freshAgentAttention`: for every claude session the server knows, `{provider, sessionId, sessionType, lastTurnCompleteAt?, lastWaitingAt?}`. `lastWaitingAt` is present only while approvals or questions are still pending. +- The client folds this after the restart and first-ready baseline reset: + - A session this page has never observed is seeded silently. This covers every session at page load and every session after a server restart, so a reload never replays anything. + - For a session already observed, an edge newer than the page's high-water mark is replayed through the normal `applyFreshAgentCompletion`/`applyFreshAgentWaiting` path. That gives the same receipt-time watched classification, highlight, pane mark and single bell as a live edge. +- "Observed" means one of: seeded by `ready`, seen in `freshAgent.created`, seen in a `freshAgent.session.snapshot` ack, or seen as a live edge, even one for a session with no pane on this device. +- Part 2: after a server restart mid-turn, the next attach of that session shows the banner "The server restarted while this turn was running. The turn was stopped." It does not ring, because a restart is a requested teardown. The server part is specified for the core-runtime workstream (section 7). + +### G26 +- Each ring has a backstop watchdog (`RING_WATCHDOG_MS = 5000`; the chime is about 1.03 s, the fallback tone 0.12 s). An Audio `pause` releases the queue and an `abort` falls back to the tone. A firing logs `warn` (`notification_ring_watchdog_fired`) once per ring. The timer is cleared on normal finish and on unmount. +- A hidden pane that receives a permission or question request, or their cancellation, records that it owes a surface refresh. On reveal it runs one non-blocking `requestSnapshotRefresh('reveal-surface')`. There is no overlay and no revision gate. If the pane is also transcript-dirty, the blocking reveal refresh covers it instead. + +--- + +## 3. Design and changes, file by file + +### 3.1 G26 + +`src/hooks/useNotificationSound.ts` +- Export `RING_WATCHDOG_MS = 5_000`. +- In `startRing`: + - Arm `const watchdog = window.setTimeout(onWatchdog, RING_WATCHDOG_MS)` and keep the handle in `activeWatchdogRef`. `onWatchdog` logs `log.warn({event:'notification_ring_watchdog_fired', stage: fallbackArmed ? 'fallback' : 'chime'})`, then calls `finish()`. + - `finish()` clears the timer. + - Add `audio.addEventListener('pause', finish, {once:true})`. Per the HTML spec, a natural end fires `pause` and then `ended`, so this stays idempotent. + - Add `audio.addEventListener('abort', armFallback, {once:true})`. +- The unmount cleanup clears `activeWatchdogRef`. +- Logger: `createLogger('useNotificationSound')` from `src/lib/client-logger.ts`. + +`src/components/fresh-agent/FreshAgentView.tsx` +- Add `surfaceRefreshOwedRef = useRef(false)`. +- In the WS handler near `:3147-3155`, classify the event with a new pure helper `classifyHiddenPaneEvent(message, {busySnapshotSettled})`, which returns `'transcript' | 'surface' | 'none'`. Put it in a new file `src/components/fresh-agent/fresh-agent-event-classes.ts`, together with the two sets moved from `:149-182`. + - `'transcript'` keeps today's `markSnapshotDirty()` path. + - `'surface'` while hidden sets `surfaceRefreshOwedRef.current = true`. + - `'surface'` covers `permission.request`, `permission.cancelled`, `question.request` and `question.cancelled`. Snapshot and status frames that are not busy-settled stay `'none'`, so the pinned behavior at test line 312 is unchanged. +- In the reveal effect (`:2832-2836`): if `surfaceRefreshOwedRef.current` is set, clear it, and if the pane is not snapshot-dirty, call `requestSnapshotRefresh('reveal-surface')`. The new trigger value goes into the `requestSnapshotRefresh` trigger union and the snapshot audit stages. + +### 3.2 G11 + +`src/lib/fresh-agent-registry.ts` gets a new export: + +```ts +export function resolveFreshAgentPaneModelDefaults(args: { + entry: FreshAgentRegistryEntry + providerSettings?: FreshAgentProviderDefaults + runtimeProviderModel?: string // codingCli.providers[runtime].model; PaneContainer only +}): { model: string | undefined; persistModel: boolean; modelEffortLevels?: string[] } +``` + +- `saved` is `providerSettings.modelSelection.modelId`, trimmed and non-empty. +- `configured`: + - claude: `saved ?? entry.defaultModel` (decision D1); + - codex and opencode: `saved ?? runtimeProviderModel ?? entry.defaultModel`, which is today's rule. +- `model` is `normalizeFreshAgentModel(...) ?? configured`. +- `persistModel` keeps today's rule (`provider !== 'opencode' || saved || runtimeProviderModel`). +- `modelEffortLevels` comes from `providerSettings.modelEffortLevels.levels`, but only when the provider is claude and `providerSettings.modelEffortLevels.modelId === model`. The levels are stored paired with their model so a later model change cannot pick up the wrong levels. +- Extend `resolveFreshAgentPaneCreateEffort` with an optional `modelEffortLevels`. It reuses one extracted helper, `clampEffortToStampedLevels(levels, staged)`, which `getEffectiveFreshAgentEffort` (`:201-224`) also switches to. + +`src/components/panes/PaneContainer.tsx:706-765` +- Replace the inline model logic (`:712-726`) with the resolver. Spread `modelEffortLevels` into the content when defined, and pass it into `resolveFreshAgentPaneCreateEffort`. + +`src/lib/session-type-utils.ts:198-226` +- Replace `model: freshAgentType.defaultModel` with the resolver, called with no `runtimeProviderModel`. Spread `model` when `persistModel` is true, and spread `modelEffortLevels`. + +`shared/settings.ts` +- `FreshAgentProviderDefaults` (`:143-148`) and both schemas (`:748-768`) gain `modelEffortLevels?: { modelId: string; levels: string[] }`. The patch schema accepts `null` to clear it. +- `normalizeLegacyFreshAgentProviderDefaultsInput` pick list (`:1270-1272`) and the patch normalizer (`:1005-1037`) carry the new key. +- The Rust settings store deep-merges the `freshAgent` subtree (`crates/freshell-server/src/settings_store.rs:1697`, with no per-field provider validation). Add a Rust round-trip test to prove it is kept, as commit B4. + +`FreshAgentModelDialog.tsx:384-391` and `FreshAgentSettingsButton.tsx:186-197,406-409` +- When the provider is claude, also save `modelEffortLevels: {modelId, levels}`, where `levels` is the same array they already stamp on the pane. + +### 3.3 G17 + +- `src/store/selectors/tabAttentionSelectors.ts` (new): memoized with `createSelector`. + - `selectTabAttentionStyle(state)`. + - `selectAttentionTabIds(state)`: tab ids, in tab order, whose union mark (`attentionByTab ∪ watchedCompletionByTab`) is set. Empty when the style is `'none'`. + - `selectTabNeedsAttention(state, tabId)`: style-gated. + - Re-export `selectTabStripAttention` for TabItem. +- `src/lib/tab-attention.ts` (new): + - `tabAttentionClassName({needsAttention, style, active, surface: 'tab' | 'card' | 'pill'})`. It is the single owner of the emerald, darken and pulse class tokens. + - The `'tab'` surface returns exactly TabItem's current strings, from `TabItem.tsx:170-190`. + - `'card'` uses an emerald border and background, keeping the active ring. + - `'pill'` is a rounded emerald background for the mobile title. +- `src/components/TabAttentionIndicator.tsx` (new): an emerald dot, optionally with a count. + - Props are `{count?: number; label: string; className?: string}`. + - It renders `role="status"` with `aria-label={label}` and `aria-live="polite"`, and nothing when the count is 0. + - It also has a `variant="dot"` for the chevrons, which renders `aria-hidden` and lets the chevron button's `aria-description`/`title` carry the meaning. +- `src/components/MobileTabStrip.tsx`: + - Select `selectAttentionTabIds` and the style. + - Apply the pill class to the title span when the active tab is in the set. + - Render the indicator ("1 other tab needs attention" / "N other tabs need attention") next to `i / N`. + - Show dots on prev/next when any attention tab id lies before or after the active index. + - Keep the existing `aria-label`s so current tests and e2e locators stay stable. +- `src/components/TabSwitcher.tsx`: + - Apply the card class per card. + - Add a visible "Needs attention" badge styled like the Busy badge. The final wording is decision D6. + - Append ", needs attention" to the card's `aria-label`. + - Card click dispatches the shared thunk below. +- `src/store/turnCompletionAttention.ts`: add a thunk `activateTabFromSurface(tabId)` that dismisses in click mode and then calls `setActiveTab`. Use it in TabBar (`:583-588`), TabSwitcher and `focusTabFromDeck` (`deck-actions.ts:10-15`), and delete the duplicates. +- `TabItem.tsx:168-190`: use `tabAttentionClassName(surface:'tab')`. This is a pure refactor. + +### 3.4 G20 + +Sidecar (`crates/freshell-claude-sidecar/`): +- `session-settings.mjs`: new `foldReportedPermissionMode(session, reported)`. + - It accepts only freshell's four claude modes (`default | plan | acceptEdits | bypassPermissions`; `shared/settings.ts:40`). On a real change it updates both `session.settings.permissionMode` and `session.permissionMode` (the bypass fast path in `permission-channel.mjs:174-185` reads the latter) and returns `true`. + - Any other reported mode returns `false`, and the caller logs `warn` with `event:'claude.permission_mode.unsupported'`. +- `index.mjs` `case 'system'` (`:264`): before the subtype branches, if `foldReportedPermissionMode(st, msg.permissionMode)` returns true, emit `{type:'sdk.session.metadata', sessionId, permissionMode: st.settings.permissionMode}`. Document the new frame in the protocol header (`:25-47`). + +Rust (`crates/freshell-freshagent/`): +- New `src/claude_permission_update.rs`: + - `normalize_permission_updates(v: Option<&Value>) -> Option>` keeps only well-formed known variants with exactly the documented keys: + - `addRules`, `replaceRules` and `removeRules`, with `rules:[{toolName, ruleContent?}]`, `behavior ∈ allow|deny|ask` and a destination; + - `setMode`, with a mode from the SDK `PermissionMode` set; + - `addDirectories` and `removeDirectories`, with `directories:[string]`. + - The destination must be one of `userSettings`, `projectSettings`, `localSettings`, `session` or `cliArg`. + - Unknown variants and keys are dropped, and an empty result becomes `None`. This follows the strict-snapshot lesson behind `normalize_question_definitions` (AGENT-06). + - `validate_decision(decision: &Value) -> Result<(), String>`: when `updatedPermissions` is present, it must be an array that normalizes to itself. +- `claude.rs`: + - Add a `suggestions: Option>` field to `PendingApprovalEntry` (`:490-523`), filled in the fold (`:9151`) via the normalizer and emitted by `to_json`. + - `handle_approval_respond` (`:4876-4892`): after the object check, call `validate_decision`. On failure it emits `freshAgent.error{code:'INVALID_APPROVAL_DECISION'}` and keeps the request pending, the same discipline as D2-M3. + - Consumer fold, next to the `sdk.configured` arm (`:8451`): `Some("sdk.session.metadata")` with a string `permissionMode` updates `session.configuration.settings.permission_mode` and persists the binding settings through the same ledger write the configure path uses. The line keeps being relayed verbatim as `freshAgent.session.metadata`; the table at `:9344` already maps it. Absent `model`/`effort` keys mean "no statement" to the client fold (`freshAgentSlice.ts:275-283`). + - Log `info` with `freshagent.claude.permission_mode_reported` (session id and mode). + +Shared contract (`shared/fresh-agent-contract.ts`) +- Add `FreshAgentPermissionUpdateSchema`, a strict discriminated union that mirrors the Rust normalizer. +- `FreshAgentPendingApprovalSchema` gains `suggestions: z.array(FreshAgentPermissionUpdateSchema).optional()`. + +Client +- `src/lib/fresh-agent-ws.ts:384-393`: pass `permissionMode` through. `freshAgentSlice.sessionMetadataReceived` (`:255-290`) stores `session.permissionMode` (string, or null for a clear). `FreshAgentSessionState` (`freshAgentTypes.ts:92-110`) gains the field. +- `src/lib/fresh-agent-permission-updates.ts` (new, pure): + - `describePermissionUpdate(update, {cwdLabel})` returns plain-language text. + - `rememberLabel(updates)` returns "Allow and don't ask again", or "Allow and auto-accept edits" when the only update is `setMode acceptEdits`. + - `buildAllowDecision(updates?)`, `buildDenyDecision(message?)`, `buildPlanDecision(choice, feedback?)`. +- `FreshAgentApprovalCard.tsx`: + - New props `onAllowWithUpdates?(updates)` and an optional `onAlwaysAllow`. The client-only button renders only when the caller provides `onAlwaysAllow`. + - Show the remember button plus the description list when `approval.suggestions?.length`. + - Route `toolName === 'ExitPlanMode'` to the new `FreshAgentPlanApprovalCard.tsx`. It uses `FreshAgentMarkdownBody` (`FreshAgentItemCard.tsx:338`), is `role="alert"` with `aria-label="Plan approval request"`, and keeps the touch targets of at least 44 px from the existing classes. +- `FreshAgentView.tsx`: + - `respondToApproval` takes a decision built by the helpers. + - For `provider==='claude'`, do not pass `onAlwaysAllow`, and skip the `alwaysAllowToolsRef` effect (`:4131-4154`). Gate the effect on `provider !== 'claude'` so a claude approval is never auto-answered on the client. + - New effect: when `agentSession.permissionMode` is a string different from `paneContent.permissionMode`, dispatch `mergePaneContent({permissionMode})`. Never call `saveServerSettingsPatch` from it. +- `src/deck/deck-actions.ts` `sendDeckApproval`: use `buildAllowDecision()` so there is one decision builder. The behavior is unchanged. + +### 3.5 G25 + +Rust: +- New `crates/freshell-freshagent/src/attention_ledger.rs`, provider-agnostic and populated only by claude in this workstream: + +```rust +pub struct AttentionLedger { inner: std::sync::Mutex } // no await while held +pub struct AttentionMark { pub session_id: String, pub session_type: String, + pub last_turn_complete_at: Option, pub last_waiting_at: Option } +impl AttentionLedger { + pub fn with_capacity(cap: usize) -> Self; // LRU by last touch; default 4096 + pub fn observe(&self, id: &str, session_type: &str); // create / resume registration + pub fn record_turn_complete(&self, id: &str, at: i64); // max() + pub fn record_waiting(&self, id: &str, at: i64); // max() + pub fn clear_waiting(&self, id: &str); // pending reached 0 + pub fn alias(&self, from: &str, to: &str); // envelope id flip; copies marks + pub fn forget(&self, id: &str); // requested kill + pub fn snapshot(&self) -> Vec; +} +``` + +- `FreshClaudeState` owns `attention_ledger: Arc`. The calls go in these places: + - `observe`: `handle_create` after `FreshAgentCreated` (`:1894` and the sibling create broadcasts at `:2686`, `:2787`), and in cold-resume registration (`resume_for_attach`, `:7033`). + - `record_turn_complete` and `record_waiting`: at the single claude edge choke point (section 7, I-1). For forwarded edges that is in the stdout consumer before `broadcast_tx.send` (`:8708-8725`), reading `value["at"]`. For Rust-minted edges it is the death mint (`:8832-8859`) and core's future deadman mint (G07). + - `clear_waiting`: after `fold_pending_frame` and after the respond handlers remove an entry, whenever `permissions` and `questions` are both empty. + - `alias(old, durable)`: in `try_rebind_to_live` (`:7009`) and anywhere else the envelope id changes (I-2). + - `forget`: in `handle_kill`'s success path. + - `pub fn attention_marks(&self) -> Vec` maps `snapshot()` to the protocol type with `provider:"claude"`. +- `crates/freshell-protocol/src/server_messages.rs`: + - `ReadyCapabilities.fresh_agent_attention_replay_v1: Option`. + - `Ready.fresh_agent_attention: Option>` (camelCase: `freshAgentAttention`, `lastTurnCompleteAt`, `lastWaitingAt`; `skip_serializing_if` None). +- `crates/freshell-ws/src/lib.rs`: + - Parse `hello.capabilities.freshAgentAttentionReplayV1`, next to the others at `:899-947`. + - Refactor first: replace the seven positional booleans of `build_handshake_with_capabilities` (`:634-643`) with a `HandshakeCapabilities` struct. + - When the client opted in, set `fresh_agent_attention = Some(state.fresh_claude.attention_marks())`, or `None` when empty, and echo the capability. + - Log `debug` with `ws.handshake.attention_marks` and a count. + - The ordering guarantee comes from the existing subscribe-before-handshake order (`:857-860`). + +Shared and client: +- `shared/ws-protocol.ts`: `HelloSchema` capabilities (`:438` and `:1188`) gain `freshAgentAttentionReplayV1: z.literal(true).optional()`. `ReadyMessage` gains `freshAgentAttention?`. +- `src/lib/ws-client.ts:553-562`: advertise `freshAgentAttentionReplayV1: true`. +- `src/lib/ready-message-schema.ts`: add `freshAgentAttention: z.array(z.object({provider, sessionId, sessionType?, lastTurnCompleteAt: z.number().finite().optional(), lastWaitingAt: z.number().finite().optional()})).optional().catch(undefined)`. +- `src/store/turnCompletionSlice.ts`: new reducer `advanceCompletionBaseline({terminalId, at})`. It sets `lastAtByTerminalId[terminalId] = max(existing ?? -Infinity, at)` and never queues an event. +- `src/store/turnCompletionThunks.ts`: + - Extract `completionDedupeKey(provider, id)` and `waitingDedupeKey(provider, id)`, which are today's inline strings. + - `applyFreshAgentCompletion` and `applyFreshAgentWaiting`: when no pane matches, dispatch `advanceCompletionBaseline`. An edge this page observed without a pane can never be replayed later. + - New thunk `observeFreshAgentSession({provider, sessionId})`: for both keys, if the key is unknown, set its baseline to 0. + - New thunk `foldReadyFreshAgentAttention(marks)`. For each mark and each kind: + +``` +known = lastAtByTerminalId[key] +if known === undefined -> advanceCompletionBaseline(key, at ?? 0) // first observation: never replay +else if at !== undefined && at > known -> log.info('fresh_agent_attention_replayed', {key, at}); apply({provider, sessionId, at}) +``` + + - Rewrite the stale doc comments (`:92-97,128-133`). +- `src/lib/fresh-agent-ws.ts`: `freshAgent.created` (`:178-207`) dispatches `observeFreshAgentSession` before `sessionCreated`. `freshAgent.session.snapshot` (`:361-371`) dispatches it too. +- `src/App.tsx:1358-1369`: after `resetCompletionDedupeBaselines` (when it runs) and `foldReadyRuntimeOwners`, dispatch `foldReadyFreshAgentAttention(ready.data.freshAgentAttention)`. +- `AGENTS.md` "Agent Status Indicators": add one sentence. A reconnect replays unwitnessed claude edges from `ready.freshAgentAttention` above the page's high-water mark; a reload or first connect seeds and never replays. + +Part 2 (restart notice): specified in section 7, I-5. The client already renders `freshAgent.error` codes through `sessionError` (`fresh-agent-ws.ts:518-532`). The only client work is one unit test pinning that `TURN_INTERRUPTED_BY_RESTART` shows the banner and records no attention event. + +--- + +## 4. Ordered TDD commits + +Rules for every commit: +- Write the failing test first and run it to see it fail for the right reason. +- Then make the smallest change that passes, then refactor. +- Commit with the trailer `Co-Authored-By: Claude Opus 5.5 `. +- Unit commands: `pnpm run test:vitest run --config config/vitest/vitest.config.ts`. +- Rust commands: `cargo test -p `. + +### PR-A (G26 and G11) + +A1 (red, then green). The ring watchdog releases a wedged chime. `test/unit/client/hooks/useNotificationSound.test.tsx`: +- (a) An Audio that never emits `ended`: after two `play()` calls and advancing `RING_WATCHDOG_MS`, a second `Audio` is constructed. The logger spy shows one `warn` with `notification_ring_watchdog_fired`. +- (b) After normal `ended` at 1 s, advancing to 5.5 s constructs no extra Audio. This proves the timer is cleared. +- (c) Unmounting with a pending watchdog does not drain after unmount. + +A2 (red, then green). `pause` releases the queue and `abort` plays the fallback. Same file: +- Emitting `pause` starts the next ring at once. +- Emitting `abort` invokes the oscillator path, once, and the queue continues. + +A3 (refactor). Name the ring lifecycle (`createRingAttempt`) inside the hook, with no behavior change. All the useNotificationSound tests must stay green. + +A4 (red, then green). A hidden pane owes a non-blocking refresh for approvals and questions. `test/unit/client/components/fresh-agent/FreshAgentView.hidden-rebind.test.tsx`: +- Setup: a hidden claude pane whose snapshot has revision 5 and no approvals. +- A WS `freshAgent.permission.request` arrives: there is no fetch while hidden. +- The snapshot mock then returns revision 5 with one pending approval, and the test sets `hidden=false`. +- Expect exactly one snapshot fetch, `role="alert"` with the name "Permission request for Bash" visible, and no "Refreshing conversation" status. +- Repeat for `question.request` and for a `permission.cancelled` that removes a visible card. +- The existing status-only test (`:312`) stays green. + +A5 (refactor). Extract `classifyHiddenPaneEvent` and the two event sets into `fresh-agent-event-classes.ts`, with a table-driven unit test over every event type: transcript, surface or none. + +B1 (red, then green). New panes honor the saved claude default. `PaneContainer.createContent.test.tsx`: +- A saved `freshclaude.modelSelection {kind:'exact', modelId:'sonnet'}` gives `content.model === 'sonnet'` and the matching `modelSelection`. +- The same for `kilroy` with the feature flag on. +- With nothing saved, `'opus[1m]'`. +- Codex and opencode cases unchanged: the existing tests at `:423-481` stay green. + +B2 (red, then green). Resume honors the saved default. `test/unit/client/lib/session-type-utils.test.ts`: +- `buildResumeContent({sessionType:'freshclaude', freshAgentProviderSettings:{modelSelection:{…'sonnet'}}})` gives `model: 'sonnet'`. +- No settings gives `opus[1m]`; kilroy likewise. +- Codex with a saved `gpt-5.6-luna` gives `'gpt-5.6-luna'` (decision D2). + +B3 (refactor). Introduce `resolveFreshAgentPaneModelDefaults` and use it in both call sites, deleting the inline logic. Add a unit test of the resolver's precedence table in `test/unit/client/lib/fresh-agent-registry.test.ts`. + +B4 (red, then green). The saved levels travel with the saved model. +- `test/unit/shared/settings.test.ts`: the provider-defaults patch round-trips and clears `modelEffortLevels` (`null`). +- Dialog and popover tests: a claude commit saves `{modelId, levels}` (`FreshAgentModelDialog.test.tsx`, `FreshAgentSettingsButton.test.tsx`). +- PaneContainer: a saved catalog-only `claude-haiku-x` with levels `[]` gives `content.modelEffortLevels === []`, and the create frame (asserted through FreshAgentView's create send) carries no `effort`. +- A mismatched pair (levels for model X, selection for Y) is ignored. +- Rust `settings_store` test: a PATCH then GET keeps `freshAgent.providers.freshclaude.modelEffortLevels`. + +B5 (refactor). Extract `clampEffortToStampedLevels` and use it in both `getEffectiveFreshAgentEffort` and `resolveFreshAgentPaneCreateEffort`. Existing effort tests stay green. + +B6 (e2e). New `test/e2e-browser/specs/freshagent-default-model-rust.spec.ts`, using the fake claude sidecar lane from `fresh-agent-control-rust.spec.ts:541` `bootClaudeLane` and its stdin log: +- PATCH `/api/settings` to save the `sonnet` default. +- Create a Freshclaude pane through the picker: the stdin `create.model` is `'sonnet'`. +- Resume that session from the sidebar into a new pane: the `create` carries `resumeSessionId` and `model:'sonnet'`. +- Repeat for kilroy on the `KILROY_ENABLED` gate. + +### PR-B (G17) + +C1 (red, then green). Selectors. `test/unit/client/store/tabAttentionSelectors.test.ts`: +- Tab order is respected. +- The set is the union of attention and watched marks. +- Style `'none'` gives an empty set. +- The result is memoized (same reference while inputs are unchanged). + +C2 (red, then green). `TabAttentionIndicator.test.tsx`: +- A count of 2 gives a status named "2 other tabs need attention". +- A count of 0 renders nothing. +- The dot variant is `aria-hidden`. + +C3 (red, then green). `MobileTabStrip.test.tsx` (store gains a `turnCompletion` slice): +- When the active tab has attention, the title carries the pill class. +- Attention on two other tabs gives the status "2 other tabs need attention". +- Attention only on a later tab gives the next-chevron dot and no prev dot. +- Watched-only marks count as marks. +- Style `none` shows nothing. +- The existing tests stay green, with unchanged labels. + +C4 (red, then green). `TabSwitcher.test.tsx`: +- A card with attention has the "Needs attention" badge and an accessible name ending ", needs attention". +- In click mode, tapping the active card clears `attentionByTab` and the pane flags. +- In type mode it does not. +- Tapping an inactive card with attention activates it and clears it. +- The existing "closes without changing tab when tapping the active tab card" still passes. + +C5 (refactor). Move `activateTabFromSurface` into `turnCompletionAttention.ts` and use it in TabBar, TabSwitcher and the deck. TabItem uses `tabAttentionClassName('tab')`. Gates: `TabItem.test.tsx:107-160`, `TabBar.test.tsx`, deck tests and `turnCompletionAttention.test.ts` (with a new case for the thunk). + +C6 (e2e). New `test/e2e-browser/specs/fresh-agent-mobile-attention.spec.ts`, at viewport 390×844, cloud-legal on the program-lane pattern of `fresh-agent-error-turn-attention.spec.ts`: +- Tab A is freshclaude. It sends a turn whose completion has `delayMs: 3000`. +- The user goes to tab B with "New tab" on the strip. +- When the edge lands: the status "1 other tab needs attention" appears and the prev chevron has a dot. +- Open the switcher: the card named "Switch to … needs attention" is visible. Tap it: tab A is active and `turnCompletion.attentionByTab` is empty. +- An approval raise (the `waiting` edge) in a background tab produces the same mark. + +C7 (docs). Update the mobile comment in `docs/index.html:86-92` to mention the attention marks. No README change. + +### PR-C (G20) + +D0 (contract pin, written first, green on arrival). New `test/integration/providers/claude-permission-contract.mjs` and `.test.ts`, following the pattern of `claude-checkpoint-uuid-replay.*` and adapted from `perm-probe/probe-perm.mjs`. It pins the CLI behavior the UI relies on: +- the suggestion shapes for Bash, Write and Read outside cwd; +- that `localSettings` writes `.claude/settings.local.json` and suppresses the next prompt; +- that `session` suppresses without a file; +- that ExitPlanMode's `input.plan`/`planFilePath` come from the plan file; +- that `system/status.permissionMode` is `default`/`acceptEdits`/`bypassPermissions` after the three plan approvals. + +It is a characterization test of an external dependency. It must fail if a CLI upgrade changes any of these. + +D1 (red, then green). The sidecar folds the reported mode. +- `test/unit/claude-sidecar/session-settings.test.ts`: `foldReportedPermissionMode` covers change, no change, an unsupported mode and a missing mode. +- New `test/unit/claude-sidecar/permission-mode-report.test.ts`, running the real `index.mjs` through the `FRESHELL_CLAUDE_SDK_QUERY_MODULE` seam (fixture `fixtures/permission-mode-query-module.mjs`): + - a scripted `system/status {permissionMode:'acceptEdits'}` gives exactly one `sdk.session.metadata{permissionMode:'acceptEdits'}`; + - a repeat gives none; + - a later `system/init{permissionMode:'acceptEdits'}` gives none. + - Also through the seam: a parked `canUseTool` resolves with the exact `updatedPermissions` sent in `permission.respond`. This pins that suggestions travel both ways. + +D2 (red, then green). Rust normalizer unit tests in `claude_permission_update.rs`: +- each known variant is kept byte-for-byte; +- an unknown `type`, a bad destination or a bad mode is dropped; +- extra keys are stripped; +- an empty list gives `None`; +- `validate_decision` accepts a missing list, an empty list and valid lists, and rejects scalars and unknown variants. + +D3 (red, then green). Suggestions reach the snapshot. In `claude.rs` tests, next to the existing pending-overlay tests: fold an `sdk.permission.request` with `suggestions` and assert the snapshot's `pendingApprovals[0].suggestions` equals the normalized list. Also `shared/fresh-agent-contract` tests: a snapshot with suggestions parses, and a malformed suggestion fails the strict parse. This pins why the Rust normalizer exists. + +D4 (red, then green). Respond validation. Extend `crates/freshell-ws/tests/freshagent_control_reply.rs`: +- A well-formed `updatedPermissions` is written verbatim to the fake sidecar's stdin. +- A malformed one gets `freshAgent.error{code:'INVALID_APPROVAL_DECISION'}`, nothing is written, and the request stays pending (a retry with a valid decision succeeds). + +D5 (red, then green). The Rust mode fold. A `claude.rs` test sends the fake stdout line `sdk.session.metadata{permissionMode:'default'}`. Expect: +- `session.configuration.settings.permission_mode == Some("default")`; +- `freshAgent.session.metadata` relayed with that mode; +- the REST snapshot's `settings.permissionMode` is `default`; +- the identity-sink binding records the new mode. The fake sink captures `record_binding`. + +D6 (red, then green). Client slice and WS pass-through. +- `freshAgentSlice.test.ts`: metadata with `permissionMode` sets `session.permissionMode`; an absent key keeps it. +- `fresh-agent-ws.test.ts`: the field is passed through. + +D7 (red, then green). The pane converges to the live mode. `FreshAgentView.test.tsx`: +- A pane in `plan` receives a metadata event with `permissionMode:'default'`. The pane content becomes `default`, `saveServerSettingsPatch` is not called, and the next `freshAgent.send` carries `settings.permissionMode:'default'`. + +D8 (red, then green). Native remember on the card. `FreshAgentApprovalCard.test.tsx`: +- Plain-language lines for addRules in localSettings, addRules in session, setMode acceptEdits and addDirectories. +- The remember click calls `onAllowWithUpdates` with exactly `approval.suggestions`. +- No suggestions means no remember button. +- The client-only button renders only when `onAlwaysAllow` is passed, with the label "Always allow Bash in this pane". +- Plus `test/unit/client/lib/fresh-agent-permission-updates.test.ts` for the describe and label helpers and the decision builders. + +D9 (red, then green). Plan card. New `FreshAgentPlanApprovalCard.test.tsx`: +- The markdown heading from `input.plan` renders as a heading, and `planFilePath` is shown. +- Each action produces the exact decision: + - "Approve plan": `{behavior:'allow'}`; + - "Approve and auto-accept edits": `updatedPermissions:[{type:'setMode',mode:'acceptEdits',destination:'session'}]`; + - "Approve and stop asking for permission": `bypassPermissions`; + - "Keep planning" with feedback text: `{behavior:'deny', message:, interrupt:false}`; without text, the default message. +- An empty input shows the "No plan text was provided" copy. +- Touch targets have at least 44 px classes. + +D10 (red, then green). View wiring. `FreshAgentView.test.tsx`: +- A claude approval with suggestions plus a remember click sends `freshAgent.approval.respond` with `decision.updatedPermissions` equal to the suggestions. +- A second claude approval for the same tool is not auto-answered. No respond goes out without a click; this is the regression for removing the client bypass. +- A codex approval plus the pane-scoped "Always allow" auto-answers the second one, so the client-only codex coverage is kept. +- An ExitPlanMode approval renders the plan card, not the generic card. + +D11 (refactor). `sendDeckApproval` and `respondToApproval` both use `buildAllowDecision`/`buildDenyDecision`. Delete the claude path through the client-only ref. Deck tests stay green. + +D12 (e2e). Edit `test/e2e-browser/specs/fresh-agent-control-rust.spec.ts`. The fake fixture changes are in section 7, I-3. +- Rewrite the claude test "Always Allow answers the second raise without a click" (`:884-918`) as "Allow and don't ask again sends the CLI's suggestions and never auto-answers on the client". It asserts that: + 1. the `permission.respond` stdin frame carries `updatedPermissions` equal to the program's suggestions; + 2. a second raise renders a card and produces no respond until clicked. + This is a deliberate behavior replacement; the old assertion encoded the misleading bypass. +- Add a codex-lane test, "Always allow (pane) auto-answers the second raise", so coverage of the kept codex behavior moves rather than drops. +- Add "plan approval: plan renders, Approve and auto-accept edits sends setMode, pane mode converges to Accept edits". The fake answers a setMode decision with `sdk.session.metadata{permissionMode}`, as the probe verified, and the spec asserts the settings popover now shows "Accept edits". +- Add "a kilroy approval with suggestions" on the `KILROY_ENABLED` gate. + +D13 (docs). One sentence in `README.md:126`: "Allow and don't ask again" saves Claude's suggested rule (project rules go to `.claude/settings.local.json`), and plan-mode exits show the plan with approval choices. + +### PR-D (G25) + +E1 (red, then green). Ledger unit tests in `attention_ledger.rs`: +- `max` semantics for both kinds; +- `clear_waiting` keeps `last_turn_complete_at`; +- `alias` copies marks; +- `forget`; +- LRU eviction at capacity; +- the snapshot shape. + +E2 (refactor, then red, then green). The handshake capabilities struct. +- Refactor `build_handshake_with_capabilities` to take `HandshakeCapabilities`. Every existing handshake test, including `crates/freshell-ws/tests/hello_capabilities.rs`, stays green. +- Then, red first, in `hello_capabilities.rs`: with `freshAgentAttentionReplayV1`, `ready.capabilities.freshAgentAttentionReplayV1 === true`; without it, neither the capability nor `freshAgentAttention` appears on the wire. This keeps the frozen client inert. + +E3 (red, then green). Claude records its edges. In `claude.rs` tests with an inline fake sidecar: +- A forwarded `sdk.turn.complete{at:T}` gives `attention_marks()` containing the stamp with `lastTurnCompleteAt=T`. +- `sdk.turn.waiting{at:W}` gives `lastWaitingAt=W`, and after the respond removes the last pending entry, `lastWaitingAt` is absent. +- An unrequested death mid-turn gives the death-mint `at` recorded under the stamp, even though the session is evicted. +- `handle_kill` gives no entry. +- A rebind by durable id lists both ids. +- Create and cold resume list the session with no marks. + +E4 (red, then green). WS-level replay. New `crates/freshell-ws/tests/freshagent_claude_attention_replay.rs`, with the harness copied from `freshagent_claude_attach.rs` per that file's convention: +1. Socket A (opted in) creates a session and sends; the fake emits `sdk.turn.complete{at}`. +2. Socket A sees the live edge. +3. A new socket B (opted in) gets `ready.freshAgentAttention` containing `{provider:'claude', sessionId, lastTurnCompleteAt: at}` before any other frame. +4. A waiting edge plus a respond means a socket C's ready has no `lastWaitingAt`. + +E5 (red, then green). Client baseline and replay semantics. +- `turnCompletionSlice.test.ts`: `advanceCompletionBaseline` never lowers a baseline and never queues an event. +- `turnCompletionThunks` tests (new `turnCompletion.replay.test.ts`): + - (a) a first-ready fold with marks seeds and queues nothing; + - (b) a later fold with a higher `lastTurnCompleteAt` queues exactly one fresh-agent event, with the receipt middleware stamping `watched` from focus and active tab; + - (c) an equal `at` queues none; + - (d) a waiting mark replays into the `#waiting` namespace; + - (e) after `resetCompletionDedupeBaselines` the fold seeds again with no replay, which is the restart case; + - (f) `freshAgent.created` then a fold with an `at` replays, which is the case of a turn ending in a pane created after the last ready; + - (g) a live edge for a session with no pane advances the baseline, so a later fold does not replay it; + - (h) a malformed `freshAgentAttention` parses to `undefined` through `ReadyMessageSchema` and folds nothing. + +E6 (red, then green). App wiring. Extend the existing App ready tests (the ones that cover `resetCompletionDedupeBaselines`) to drive real `ready` frames through the App handler: +1. First ready with a mark: no event. +2. A reconnect ready with the same `bootId` and a higher mark: one event and `attentionByTab[tab]` set. +3. A ready with a new `bootId`: no event. + +E7 (refactor). `completionDedupeKey`/`waitingDedupeKey`. Rewrite the stale comments. Add the AGENTS.md sentence. + +E8 (e2e). New `test/e2e-browser/specs/fresh-agent-attention-reconnect-rust.spec.ts`. It uses the claude fake-sidecar lane plus the `routeWebSocket` gate pattern from `recover-my-panes-rust.spec.ts:1921-1960`: +1. Tab A is freshclaude. Send a turn with `delayMs: 4000` and move to tab B. +2. Black-hole the WS: block, then close the live socket. +3. Wait for the fake's wire ledger to show `sdk.turn.complete`. While the socket is down, `turnCompletion.seq` is unchanged. +4. Unblock and wait for the next ready. Expect `turnCompletion.seq` +1 exactly, `attentionByTab[A]` set, and the sidebar row highlighted. +5. Reload the page. Expect `seq === 0` and no attention, which pins the no-replay-on-reload rule. + +Cloud-legal: there is no idle-grace timing, and every wait is a poll on observed state. + +E9 (client test for part 2, on core's frame). Extend `fresh-agent-ws.test.ts`: `freshAgent.error{code:'TURN_INTERRUPTED_BY_RESTART'}` sets the pane banner and records no `turnCompletion` event. This lands with or after core's server change (I-5). + +--- + +## 5. Test plan + +| Layer | New or changed tests | Lane | +|---|---|---| +| Client unit | useNotificationSound (watchdog, pause, abort, unmount); fresh-agent-event-classes; FreshAgentView.hidden-rebind (surface refresh); PaneContainer.createContent (claude, kilroy, levels); session-type-utils (resume); fresh-agent-registry resolver; settings normalizer; model dialog and popover save; tabAttentionSelectors; TabAttentionIndicator; MobileTabStrip; TabSwitcher; turnCompletionAttention thunk; TabItem regression; FreshAgentApprovalCard; FreshAgentPlanApprovalCard; fresh-agent-permission-updates; FreshAgentView (remember, no client bypass for claude, codex bypass kept, plan routing, mode convergence); freshAgentSlice; fresh-agent-ws; turnCompletion slice, thunks and replay; App ready fold | `pnpm run test:vitest run --config config/vitest/vitest.config.ts`; the full coordinated `pnpm run test` before each PR | +| Sidecar unit and real process | session-settings fold; permission-mode-report through the real `index.mjs` with a fake query module; suggestion round trip | same Vitest lane (`test/unit/claude-sidecar`) | +| Real CLI contract | `test/integration/providers/claude-permission-contract.test.ts` (pinned CLI against a local fake endpoint) | same lane as `claude-checkpoint-uuid-replay.test.ts` | +| Rust unit | claude_permission_update normalizer; attention_ledger; claude.rs (suggestions in snapshot, respond validation, mode fold and persistence, ledger recording, death mint, alias and forget); settings_store round trip | `cargo test -p freshell-freshagent`, `-p freshell-server` | +| Rust integration (WS) | hello_capabilities (opt-in and inertness); freshagent_control_reply (updatedPermissions forwarded or refused); new freshagent_claude_attention_replay | `cargo test -p freshell-ws`; `rust-gate` on the PR | +| Browser e2e | freshagent-default-model-rust; fresh-agent-mobile-attention; fresh-agent-control-rust (rewritten claude remember test, codex pane-scoped bypass, plan approval and mode convergence, kilroy suggestions); fresh-agent-attention-reconnect-rust (replay plus no replay on reload) | `pnpm run test:e2e` on the configured `FRESHELL_E2E_BACKEND`. None of these specs may join `CLOUD_SKIP_SPECS`. Every affected spec must pass on that backend before filing. | + +Coverage notes: +- The removed claude client-bypass assertion is replaced by a stronger one: suggestions are sent, and the client does not auto-answer. Coverage of the kept codex bypass moves to the codex lane. Nothing is skipped. +- Every new test drives behavior: DOM roles and names, wire frames, store state, stdin frames or CLI files. None asserts on prose or config text. + +--- + +## 6. Risks and mitigations + +1. Strict snapshot schema. A malformed or unknown suggestion would fail the whole snapshot parse and hide every card, which is the AGENT-06 class of bug. Mitigation: Rust normalizes to known variants before the snapshot, the client schema mirrors the Rust normalizer, and D3 pins the failure mode. Client and server ship together, and the build-id reload covers a mismatched client. +2. Writing into the user's project. "Allow and don't ask again" can write `/.claude/settings.local.json` when the CLI suggests `localSettings`. Mitigation: the card spells out the destination per suggestion before the click, and the README notes it. Session-scoped suggestions write nothing. +3. Mode-sync races: + - A metadata event could land between a local popover change and its configure. The sidecar applies configure synchronously before the next status, so the order is preserved. + - A send in the same frame as a convergence could carry the old mode and re-enter it. The window is one render, the pane converges again on the next report, and the e2e covers the main path. + - Configure-driven mode changes on device A still do not reach device B. That is a pre-existing gap and is left out of scope. +4. Unsupported modes. The CLI can report `dontAsk` or `auto`, which freshell does not expose. The fold ignores them and logs a warning, so the pane keeps its mode. +5. G25 dedupe-key drift. Claude's envelope id flips from the placeholder to the durable id on a cross-device rebind (G16). A flip during a disconnect can make one edge seed instead of replay, which is a missed ring, never a false one. `alias` narrows this, and G32 (core) removes it by making the durable id the envelope id from the start. +6. G25 pre-load safety. A reload resets and then seeds, the restart path does the same, and E5(a,e), E6 and E8 pin it. Server time is the only clock compared; client clocks are never compared with server `at` values, so device clock skew cannot cause a false replay or a missed one. +7. G25 memory. The ledger is a bounded LRU (4096 entries of about 100 B). Eviction can only lose a replay, never cause a false one. +8. Watchdog overlap. A slow chime that is merely delayed (for example a stalled fetch) can overlap the next ring after 5 s. This is better than permanent silence, and it is logged at `warn`. +9. Extra fetches on reveal. At most one per reveal, and only when an approval or question event arrived while the pane was hidden. +10. Behavior changes outside claude: + - Codex resume now honors the saved codex default (D2). + - The codex "Always allow" label changes (D3). + - The ring watchdog and mobile marks are shared and apply to every provider. + - Reconnect replay is claude-only until approved for others (D5). + +--- + +## 7. Interfaces needed from the core-runtime workstream + +- I-1: one claude edge choke point (touches G14, G24, G07, G05). + - Every claude `freshAgent.turn.complete` and `freshAgent.turn.waiting` should go through one method: `FreshClaudeState::emit_attention_edge(kind, stamp, sidecar_session_id, session_type, at, source)`. That covers forwarded sidecar edges, the death mint, and the future deadman and crash edges. + - Core adds logging (G14) and shared-clock seeding (G24) inside it. This workstream adds `attention_ledger.record_*`. + - Whichever workstream lands first creates the method, and the other extends it. + - Until the method exists, E3 hooks the two existing sites directly (`claude.rs:8708-8725`, `8832-8859`). +- I-2: envelope id changes (G16, G32). + - Anywhere `broadcast_id` changes (`try_rebind_to_live`, rollback re-key or materialize), call `attention_ledger.alias(old, new)`. + - If G32 makes the durable UUID the envelope id from the first frame, `alias` becomes a no-op and can be deleted. +- I-3: fake sidecar fidelity (G04). + - The e2e needs the fake sidecar's `approval` lane to forward `data.suggestions`, `data.blockedPath` and `data.decisionReason` in `sdk.permission.request`. + - It also needs a way to emit `sdk.session.metadata{permissionMode}`. Either a `mode` emission kind, or respond-arm behavior that mirrors the CLI as verified in D0: a setMode decision reports its mode, and a plain ExitPlanMode allow reports `default`. + - If G04 replaces the fake with the real `index.mjs` plus a fake query module or fake endpoint, D12 and E8 run on that instead, which is preferred. + - The D0 contract test is the source of truth the fake must mirror. +- I-4: busy and status truth (G05, G21, G23). Nothing in this plan depends on them. Their status frames improve G17's Busy and attention display, but the attention edges stay outcome-uniform. +- I-5: restart notice (G25 part 2), proposed for core because it shares the shutdown and ledger code with G05. + - In `FreshClaudeState::shutdown` (`claude.rs:1828`), before draining, collect sessions where `in_turn` is set and that have a durable id. Persist an `interrupted_by_restart{at}` marker through the identity sink: a new awaited `PaneIdentitySink` method with a short bounded timeout. + - On the next cold resume of that durable id (`resume_for_attach`, handoff resume), broadcast after the ack: `freshAgent.error{code:'TURN_INTERRUPTED_BY_RESTART', message:'The server restarted while this turn was running. The turn was stopped. Send a message to continue.'}`. Then clear the marker. + - No turn-complete edge, because a server restart is a requested teardown under AGENTS.md. + - Client contract: rendered by the existing `sessionError` banner path, pinned by E9. +- I-6: G12 overlap. This plan takes the "plan-mode changes" part of G12: the sidecar `permissionMode` fold and the `sdk.session.metadata` relay. Whoever owns G12 should not duplicate it. + +--- + +## 8. Decisions for the user (PM) + +- D1: For Freshclaude/Kilroy, should a new pane fall back to the terminal Claude CLI's model setting (`codingCli.providers.claude.model`, set in Extensions) when no fresh-pane default is saved? + - Recommendation: no. Use the saved fresh-pane default, then Opus 1M. The fresh panes have their own picker that saves a default, and this keeps new and resumed panes identical. + - The audit suggested yes, for parity with codex. Codex and opencode keep their current fallback either way. +- D2: Resumed panes start on the saved default model, not the model the conversation last used. This matches `claude --resume`. + - Side effect: codex resume now also honors the saved codex default, where today it is always GPT-6 Astra. + - Recommendation: accept. +- D3: Codex's client-only "Always allow" button. Recommendation: keep its behavior and fix the label to "Always allow in this pane" with the tooltip "Until this pane reloads". The alternative is to leave it untouched. +- D4: Plan-card choices and wording. Proposed: "Approve plan", "Approve and auto-accept edits", "Approve and stop asking for permission", and "Keep planning" with an optional note. Should the bypass option exist? Freshclaude's default mode is bypass, so recommendation: yes. +- D5: Reconnect replay for Freshcodex/Freshopencode. The ledger and the client are provider-neutral, but this plan populates it for claude only, per the "no new codex/opencode features" scope. Extending it is a small follow-up that needs approval. +- D6: The mobile badge wording, "Needs attention" and "N other tabs need attention". + +--- + +## 9. Observations out of scope (not planned) + +- `MobileTabStrip` and `TabSwitcher` titles use `getTabDisplayTitle`, while desktop uses `selectTabDisplayTitles` (unified agent names, `TabBar.tsx:243-266`). Mobile can therefore show a different tab title than desktop for session-owned tabs. +- Sidebar rows ignore `tabAttentionStyle: 'none'` (`Sidebar.tsx:1143`), while every tab surface honors it. +- The OS-level escalation (Web Notification or a title flash while the page is hidden) remains open under kata 1wyr. +- Multi-device convergence of a user-initiated permission-mode change (the configure path) is not broadcast. See risk 3. diff --git a/docs/plans/2026-10-22-freshclaude-core-runtime.md b/docs/plans/2026-10-22-freshclaude-core-runtime.md new file mode 100644 index 0000000000..e924676d46 --- /dev/null +++ b/docs/plans/2026-10-22-freshclaude-core-runtime.md @@ -0,0 +1,870 @@ +# freshclaude core runtime: final design + +Status: design, not yet implemented. Branch `feat/freshclaude-parity` (worktree `.worktrees/freshclaude-parity`), based on `origin/main` 1c7e4dff7. +Scope: freshclaude and kilroy (provider `claude`, one runtime: `FreshClaudeState` plus the Node sidecar in `crates/freshell-claude-sidecar`). Kilroy is sessionType `kilroy` on the same runtime. Every rule below applies to it unless a line says otherwise. +Pinned provider: Claude Agent SDK 0.3.237 with its bundled CLI 2.1.237. + +This document merges three candidate designs ("sdk-identity", "codex-parity", "sidecar-authoritative") and the verdicts of two judges. It covers the core-runtime gaps G01–G09, G14–G16, G21–G24, G27 and G32 from the audit, plus part 2 of G25. It also states the interfaces three parallel workstream plans depend on (§9). All scratch evidence lives under `/tmp/claude-1000/-home-dan-code-freshell/1374fc0b-889d-437e-b369-0aa473c2880f/scratchpad/` (below, `$S`). + +--- + +## 0. Decisions in one page + +1. **Rust owns turn accounting and alerts.** Rust decides whether the session is busy, handles Stop (the "Stop latch"), and creates every attention edge (`turn.complete`, `turn.waiting`) on one clock per session. The sidecar becomes a thin translator that forwards facts from the CLI. This is the sdk-identity base. Codex and opencode already work this way. +2. **Rust mints the Claude session UUID at create** and passes it to the SDK as `sessionId`. One id serves as map key, envelope stamp, ledger key and pane id from the first frame. Ids that have no transcript yet carry a **genesis** in their binding row: `Fresh`, or `Fork{parent, at, dropsTurn}`. The genesis is cleared only after the transcript has been observed on disk. Reconcile treats a bound id with a genesis as present, so it is restarted after a reboot rather than declared dead. +3. **Boot is verified without a prompt.** The sidecar answers `created` only after `initializationResult()` resolves. Boot refusals come back as typed `create.failed` codes. Nothing ever waits for `system/init`, which only arrives with a prompt. This is the fix for G01 and the basis for fork (G09). +4. **Busy is tracked per command, not per result.** Every send carries a Rust-minted UUID. The CLI's per-command lifecycle frames, its `session_state_changed` frames (enabled by an environment flag the sidecar sets), and turn evidence feed one pure `ClaudeTurnLedger`. CLI-initiated turns are covered, and a conversation reset (`/clear`) re-keys the session to the new id. +5. **Stop targets one thing:** the running command, or else the oldest command not yet started. Commands another device queued keep running and ring when they finish. Rust re-sends the interrupt if the target starts after the Stop, whichever receipt shape the CLI returned. The result verdict depends on the outcome: a Stop that landed is silent, and anything else rings. +6. **A session end is a process end.** The sidecar exits whenever its SDK stream ends. Rust's end-of-output (EOF) path is the only death path. It sends `status: exited`, rings if work was owed, and keeps a dead-session record so the next send, attach or configure resumes the session transparently. +7. **A stuck deadman** watches each busy session. Tool heartbeats count as activity only up to a cap of 12 minutes. A hung MCP tool is therefore flagged (about 22 min), while a Bash call within its 10-minute cap is not. `recovery.stop` is routed to claude. +8. **Undo/redo/fork spawn and boot-verify the replacement before touching the live session.** A drops-turn guard refusal is a typed `ROLLBACK_REFUSED` that keeps the original live, as the SDK requires. It is never retried without the guard. +9. **Delivery order:** alert fixes first, then the lifecycle fixes, then G01, then the move of turn accounting into Rust, then recovery, then the identity migration. Every commit is green and shippable on its own, and old frames stay alive until their consumer switches over. +10. **Test realism (G04).** A real-CLI contract suite pins the facts this design depends on. A simulator plugged into the sidecar's `FRESHELL_CLAUDE_SDK_QUERY_MODULE` seam must match the real CLI. Every Rust and browser test then runs the real `index.mjs`, and the two hand-written protocol fakes are deleted. + +--- + +## 1. How the judges' disagreement was resolved + +The judges split. Judge 1 chose sidecar-authoritative (7.3 against 6.6 and 6.4). Judge 2 chose sdk-identity (40 against 37 and 37). **This design starts from sdk-identity's ownership model**, for these reasons: + +- **One owner removes a whole class of bugs.** G08 (drifting counters) and G24 (two clocks) both come from splitting busy and edge state between Node and Rust. With Rust as the only owner, they cannot recur. Sidecar-authoritative removes the split in the other direction, but it needs new request/response round trips: a send acknowledgement that waits up to 10 s for the sidecar, a busy refusal decided in the sidecar, and a quiesce probe. It also adds about ten protocol frames to a 24k-line file that is already dense with fences. +- **The flaws that cost sdk-identity points can be fixed by grafting.** Judge 1 penalized it for the /clear crash, death after restart, the genesis flush window, the guard-less retry, Stop over-targeting and a 1-hour heartbeat cap. Each has a fix that the judges themselves listed as a graft (§12 maps every one). Sidecar-authoritative's structural cost cannot be grafted away. +- **It matches the repo's existing pattern.** The AGENTS.md alert contract is server-authoritative. Codex and opencode already own their edges in Rust. The codex `QuietDeadman` and synthesized-edge clock become shared modules (codex-parity's graft). + +Where the judges disagreed on facts, I re-checked against the code and the CLI: + +| Question | Re-check | Result in this design | +|---|---|---| +| Cold-Stop receipt: `still_queued: []` or `[uuid]`? | `$S/core-design-probe/probe-design2.out` P4: the receipt is `[]` (t=7409) and arrives in the same millisecond as the command's `queued` lifecycle, with `started` 6 ms later. Judge 2's re-run of `design-probes/probe-stop.mjs` gave `[uuid]`. The SDK doc (sdk.d.ts:3765) says survivors are listed and "[] does not mean nothing will run". | Both shapes are real and the order is racy. The re-send rule (§5.4) handles both. No contract test may pin the receipt contents; tests assert the outcome (the turn is aborted, no bell). | +| What does `/clear` leave on disk? | New probe `$S/final-probe/probe-clear-disk.mjs`: `type:'conversation_reset'` (stamped with old id A), then init with new id B. The `/clear` result and its lifecycle carry B. `B.jsonl` exists right after the `/clear` turn, and `resume: B` boots. | /clear re-keys A→B (§4.6). B is treated as materialized, and a `Fresh` genesis covers the flush window. | +| Are CLI-initiated turns visible? | New probe `$S/final-probe/probe-cli-turn.mjs`: after a background Agent finishes, the CLI emits `task_notification`, `session_state_changed: running`, init, result #2, then `idle`. No `command_lifecycle` frame appears, because the turn has no uuid. | The ledger opens a turn on `session_state` running or on turn evidence, so such a turn is busy and rings (§5.2). | +| Does a hung MCP tool heartbeat forever? | Judge 1's `$S/judge1-probe/probe-mcp-hang.mjs`: `tool_progress {heartbeat:true}` every 30 s with no end. | Heartbeats feed the deadman only up to a 12-minute cap (§5.6). | +| After a restart, does reconcile declare zero-turn panes dead? | `crates/freshell-ws/src/reconcile_freshagent.rs:186-196`: Absent plus `ever_observed` gives GoneObserved, which gives DeadSession `session_not_on_disk`. `existence.rs:48-60`: `ever_observed` counts ledger bindings. The terminal claude carve-out at `reconcile.rs:436-470` is the precedent. | A genesis-aware presence rule is added (§4.4). | +| Does the hosted session host wait for init? | `crates/freshell-session-host/src/providers/fresh_agent.rs:436` calls `wait_exact_native(expected)`. The bridge learns claude identity only from init and from `materialized` (`:1110-1160`). | `created` carries `sessionRef` from C10, and the bridge reads identity from it (§7.3). | +| Can the hosted proxy stop a session for recovery? | `crates/freshell-server/src/fresh_agent_proxy.rs:291-300` always answers `UNSUPPORTED_CAPABILITY`. | The client falls back to kill plus resume (§8). | + +--- + +## 2. Ground truth: pinned CLI behavior this design relies on + +Each fact was observed against CLI 2.1.237 / SDK 0.3.237 and a local fake Anthropic endpoint. The real API was never contacted. + +| # | Fact | Evidence | +|---|---|---| +| T1 | `system/init` is emitted at the start of every turn, and only once a prompt has been pushed. | `$S/init-probe/probe-fork2.mjs`; `design-probe` P1 | +| T2 | `query.initializationResult()` resolves in about 0.6–1.5 s with no prompt. It returns `commands`, `models`, `account`, `pid` (the CLI's pid) and `current_permission_mode`. | `design-probe` P18; `design-probes/probe-boot.mjs` | +| T3 | `sessionId: X` with no resume is honored: init echoes X. Nothing is written until the first prompt. The transcript appears around the first result, and each line lags the SDK frame by 95–200 ms. | `design-probe` P1; `core-design-probe` P1; `live-probe` F7 | +| T4 | `sessionId: X` when `X.jsonl` already exists: boot rejects with `Session ID X is already in use.` and the CLI exits 1. | `design-probe` P3 | +| T5 | Boot refusals arrive with no prompt. First an `error_during_execution` result with `num_turns: 0` arrives on the stream. Then `initializationResult()` rejects and the iterator throws. Messages: `No conversation found with session ID: …` (missing resume), `No message found with message.uuid of: …` (bad fork point), `Resume rejected by --resume-drops-turn: …` (guard). | `core-design-probe/probe-fork-ready.out` R2–R5; `design-probe` P4, P6 | +| T6 | Lazy fork `{resume: P, resumeSessionAt: a, forkSession: true, sessionId: F}`: it boots, and nothing is written until the first prompt. Then `F.jsonl` holds the prefix with P's line uuids preserved, and P is unchanged. Re-spawning the same fork options before that prompt is safe. | `design-probe` P5, P7 | +| T7 | `setModel` and `setPermissionMode` before the first prompt succeed and write nothing to disk. | `design-probe` P8 | +| T8 | Every uuid-stamped send gets `command_lifecycle {command_uuid, state}`: queued, then started, then completed or cancelled. Init advertises `msg_lifecycle_v1`. The frame is @internal and absent from sdk.d.ts. Every executed send is also replayed (`isReplay`) with our uuid, including slash commands. An unknown slash command is not replayed but still gets a lifecycle. The CLI also replays uuids we never sent. | `design-probe` P9; `design-probes` F4–F6, F19 | +| T9 | With `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1`, `system/session_state_changed` reports `running` when work starts and `idle` when the queue drains, once per drain (not between queued turns). It brackets CLI-initiated turns. | `design-probe` P10; `$S/final-probe/probe-cli-turn.mjs` | +| T10 | A send pushed during a tool call is absorbed: both commands start, one result arrives, and the first command's `completed` follows the result. A send pushed during streamed text queues as its own turn with its own result. | `design-probe` P11, P12 | +| T11 | A warm interrupt produces `subtype: error_during_execution`, `is_error: true`, `terminal_reason: aborted_streaming` (text) or `aborted_tools` (tool), `errors: ["[ede_diagnostic] …"]`, and lifecycle `cancelled`. The receipt arrives before the result. | `design-probe` P13; `init-probe/probe-interrupt.mjs` | +| T12 | A Stop while a second send is queued returns a receipt of `still_queued: [u2]`. The running turn is aborted, and u2 then runs as its own turn. | `core-design-probe` P5 | +| T13 | A cold Stop (CLI still starting): the receipt is `[]` when the interrupt overtook the message, or `[uuid]` when the message was already queued. In both cases the command then runs to `success/completed`. Re-sending `interrupt()` when that command reaches `started`, or when it is replayed, aborts it. | `core-design-probe` P4; `design-probes/probe-stop.mjs` coldretry; judge 2's re-run | +| T14 | If the CLI is SIGKILLed mid-turn, the iterator throws `Claude Code process terminated by signal SIGKILL`. No result arrives, and Node stays alive. A later push is lost. | `design-probe` P15 | +| T15 | `/compact` is an ordinary command with one result. The sequence is `status: compacting`, then `status: null` with `compact_result`, then `compact_boundary`, then the result. A failed compaction also ends in exactly one result. | `design-probes` F17; `live-probe` F8/F9 | +| T16 | `/clear` (also plan-mode exit and fresh-session flows, sdk.d.ts:4129) sends `type: 'conversation_reset' {new_conversation_id}`, then init with the new id B. The result carries B. `B.jsonl` exists right away, and `resume: B` boots. | `$S/final-probe/probe-clear-disk.mjs` | +| T17 | While a tool runs, `tool_progress {heartbeat: true, elapsed_time_seconds}` arrives every 30 s, including for an MCP tool that never answers. A foreground Bash call is capped at 10 min by the CLI (`BASH_MAX_TIMEOUT_MS`). | `design-probe` P17; `judge1-probe/probe-mcp-hang.mjs`; `core-design-probe/probe-heartbeat-70s` | +| T18 | An API error produces an assistant line with `isApiErrorMessage`, then a result with `subtype: success`, `is_error: true`, `terminal_reason: api_error`. | `verify/probe-apierr.mjs` | +| T19 | Background-agent completion starts an unsolicited CLI turn (`task_notification`, init, result) that carries no uuid and no lifecycle. | `live-probe` F6; `$S/final-probe/probe-cli-turn.mjs` | + +--- + +## 3. Architecture + +### 3.1 Ownership + +| Concern | Owner | Notes | +|---|---|---| +| Session identity, genesis, boot plan, re-keys | Rust | Minted before spawn. Persisted in the binding row. | +| SDK mechanics: query options, boot handshake, input queue, `canUseTool` parking, settings calls | Sidecar | The sidecar makes no turn or attention decisions. | +| Turn ledger (busy), Stop latch, effective status, deadman | Rust | Folded from the facts the sidecar forwards (§5). | +| Attention edges (`turn.complete`, `turn.waiting`) | Rust | One choke point, `ClaudeEdgeHub::emit` (§5.5), with one `AttentionClock` per durable id. The clock survives death, resume and re-keys. | +| Death detection | Rust | EOF on the sidecar's stdout. The sidecar exits whenever its SDK stream ends. | +| Logs | Both | The sidecar writes JSONL with a severity to stderr. Rust re-emits it through `tracing`, and logs every edge and decision itself. | + +### 3.2 Module layout (new code goes into small modules, not the 24k-line `claude.rs`) + +| Module | Contents | +|---|---| +| `crates/freshell-freshagent/src/attention.rs` (shared) | `AttentionClock` (`mint`, `observe`, `floor`), `turn_complete_event`, `mint_synthesized_edge`. Moved from codex (`codex.rs:11162`); codex becomes a thin wrapper. | +| `crates/freshell-freshagent/src/quiet_deadman.rs` (shared) | `QuietDeadman` and its waiter, moved from `codex.rs:693-711, 7768-7849, 10465-10530`. Adds `window_from_env`. Codex behavior is unchanged. | +| `claude_turns.rs` | `ClaudeTurnLedger`, `StopLatch`, `ResultVerdict`, `effective_status`. Pure and synchronous. | +| `claude_launch.rs` | `ClaudeGenesis`, `ClaudeBoot`, `ClaudeLaunchSpec`, `decide_boot`, `create_request()` (the only builder of a sidecar `create` line). | +| `claude_sidecar_io.rs` | `SidecarWriter` (one writer task per runtime, with a write timeout) and `spawn_stderr_logger`. | +| `claude_edges.rs` | `ClaudeEdgeHub` (the choke point), `ClaudeTurnEvent`, the `AttentionObserver` trait. | +| `claude.rs` | Handlers, `establish_runtime`, `ensure_runtime`, the consumer, the rollback/fork flows. | +| `claude_spawn.rs` | **Reserved for the API/ops workstream** (G30/G31: `ClaudeNodeCommand`, `SidecarTreeGuard`). Core does not use this name. | +| Sidecar `boot.mjs` | `bootOptions(boot, sessionId)` and `classifyBootError(err)`. Pure and unit-tested on the exact real-CLI strings. | +| Sidecar `sdk-message-frames.mjs` | The pure mapping from SDK message to frames. Created by whichever lands first, transcript-live B1 or core C05, and shared (§9.1 N6). | +| Sidecar `log.mjs` | `log(severity, event, fields)` | + +### 3.3 Session record + +```rust +/// One open claude pane lineage, keyed by its durable id. Removed on kill. +struct ClaudeSession { + durable_id: String, // map key == envelope stamp == ledger key (from C23) + session_type: SessionType, // Freshclaude | Kilroy + config: ClaudeConfiguration, // settings (+plugins), commands, runtime_cwd + ledger: Arc>, // the busy truth (§5.2) + pending: Arc>, // approvals/questions (existing type) + clock: AttentionClock, // shared attention clock (§5.5) + deadman: QuietDeadmanHandle, // §5.6 + turn_lock: Arc>, // send / configure / compact / rollback / fork serialization + published_status: Arc>, // last broadcast effective status + runtime: ClaudeRuntime, // writer, child, pids, ownership_id, consumer, stderr task + owning_operation: Option, owning_generation: Option, // as today +} + +/// Kept after an unrequested death (bounded LRU, 256 entries, mirroring codex's dead-thread cache). +struct DeadClaudeSession { + durable_id, session_type, config, clock: AttentionClock, turn_lock, cause: ExitCause, at_ms, +} +``` + +Deleted over the sequence: `in_turn`, `turn_tracker`, `result_idle_pair_pending`, `rollback_probe_slot`, `last_status`, `last_synthesized_complete_at`, `broadcast_id`, `cli_session_id`, `sidecar_session_id`. The incarnation guard becomes `ownership_id`, which is unique per spawn. On `FreshClaudeState`, these go: `cli_index`, `naming_handles` and `close_pending`. The `alias_tombstones` write path goes too, but the **read** path stays (§4.7). + +--- + +## 4. Identity, boot and history + +### 4.1 Durable id and genesis + +`handle_create` (fresh lane, from C23) mints `D = Uuid::new_v4()` when the request arrives. D is: +- the map key and the envelope stamp; +- `freshAgent.created.sessionId`, with `sessionRef = {claude, D}`; +- the binding, rollback-record and ownership-coordinator key; +- the naming target; +- the SDK `sessionId` for a fresh boot, or the `resume` value for every later boot. + +```rust +enum ClaudeGenesis { + Fresh, // boot with sessionId: id + Fork { parent: String, at: Option, drops_turn: Option }, // boot as a lazy fork +} +``` + +The genesis is stored in `BindingRow.genesis`, which is serde-optional under `LEDGER_VERSION` 1. **It is cleared only after the transcript has been observed on disk.** This happens at the first `sdk.result` while a genesis is set: a `spawn_blocking` locate retries up to 10 times, 200 ms apart, then rewrites the binding with `genesis: None`. `decide_boot` also clears it whenever it finds the transcript. So a CLI death in the window before the transcript is flushed never loses the session (T3). And a transcript that was conversed and later deleted is still judged gone, because by then the genesis is cleared. + +### 4.2 Boot decision (`decide_boot`, used by every launch) + +| Transcript for id | Genesis | Boot | +|---|---|---| +| Found | any (cleared lazily) | `Resume{resume: id}`, or the transcript path when the original cwd is gone (today's escape hatch) | +| Absent | `Fresh` | `Fresh{sessionId: id}` | +| Absent | `Fork{parent, at, dropsTurn}` | `Fork{resume: parent, resumeSessionAt: at, forkSession: true, sessionId: id, resumeDropsTurn}`. With `at: None`, fork the whole parent. | +| Absent | none | **Lost**: the nested `INVALID_SESSION_ID` lost frame. Never spawn. | +| Unreadable (IO error, no store root) | – | **Transient**, never Lost. This uses G28's `TranscriptLocation`. If that has not landed, it uses the existing `locate_transcript_checked` (§9.3 I8). | + +How each typed boot failure (`create.failed.code`) is handled: + +| Code | Source string (T4/T5) | Handling | +|---|---|---| +| `SESSION_ID_IN_USE` | `Session ID … is already in use` | A transcript appeared between the check and the spawn. Retry once as `Resume`. | +| `CONVERSATION_NOT_FOUND` | `No conversation found with session ID` | With a genesis: retry once per the genesis. Otherwise: Lost. | +| `RESUME_POINT_NOT_FOUND` | `No message found with message.uuid of` | Rollback/fork: typed `ROLLBACK_REFUSED` or `FORK_REFUSED`. The original stays live. | +| `FORK_GUARD_REFUSED` | `Resume rejected by --resume-drops-turn` | Typed `ROLLBACK_REFUSED` ("Claude folded another message into that turn, so it can't be undone to that point"). **No guard-less retry.** The SDK says consumers "MUST … clear the pending fork target and resume plainly … not retry" (sdk.d.ts:1877-1880), and the plain resume is simply the still-live original. | +| `BOOT_FAILED` / budget timeout (45 s) | anything else | Transient | +| `SIDECAR_PROTOCOL_MISMATCH` | `created.protocol != 2` | Transient. Logged at error. It catches a packaged app pairing a stale sidecar with a new server. | + +**Boot results never reach the turn machinery.** The real CLI emits the refusal as an `error_during_execution` result before `initializationResult()` rejects (T5). The sidecar holds a `booted` flag that is false until `created` is emitted. Before that, a `result` only supplies the `create.failed` message, and every other frame is dropped. The Rust handshake reader forwards nothing before `created`: pre-`created` lines are logged at debug and dropped. Tests on both sides pin this. + +### 4.3 Zero-turn sessions and pending forks + +| Case | Behavior | +|---|---| +| Create, no send, reload | The attach finds the live record and acknowledges `idle`. The snapshot returns 200 with empty `turns` (served from the `Fresh` genesis). | +| Create, no send, server restart | Reconcile says `respawn` (§4.4). The attach finds no record, and establish boots `Fresh{sessionId: D}` (T3). | +| Create, no send, sidecar crash | The dead record goes to `exited`. Resume boots `Fresh` again. There is no file, so no collision (T4 cannot happen). | +| Model or mode changed before the first send | Written to the binding settings (T7). A later boot uses them. | +| Undo, then restart before sending | The binding for C holds `Fork{A, at}`. Reconcile says `respawn`. Establish re-boots the same fork (T6). The snapshot is A's prefix through `at`. | +| Crash between the first prompt and the transcript flush | The genesis is still set (§4.1), so the boot follows the genesis. Once the file exists, `Resume` wins. | + +### 4.4 Reconcile and existence (the restart fix) + +`reconcile_freshagent.rs` (presence arm, `:178-205`): +- `has_live_session(id)` is true only for a Live record. A dead record is not live: presence falls through to the existence probe, the verdict is `respawn`, and the client's attach brings it back through `ensure_runtime`. +- New rule for claude, mirroring the terminal carve-out at `reconcile.rs:436`: if the probe says `Absent`, **and** `pane_ledger.binding_genesis("claude", id).is_some()`, **and** `!existence.ever_observed_on_disk("claude", id)`, then presence is `OnDisk` and the verdict is `respawn` (subject to the existing respawn cap). An id whose transcript was once seen and is now gone still falls through to `GoneObserved`, which gives `dead_session`. +- New `PaneLedger::binding_genesis(provider, id) -> Option` (an in-memory read). The same rule is applied in `existence.rs`'s gate path, so the warm and cold paths agree. + +### 4.5 Rollback v2 (undo/redo) and fork + +**Effective transcript of a session X:** +- if X is materialized, `X.jsonl`; +- if X is a pending `Fork{P, at}`, P's raw chain truncated through `at` (inclusive, using the existing raw-chain helpers); +- if X is `Fresh`, empty. + +**Source collapse:** forking from a pending fork forks its parent P at the new point instead. Chains never nest, so a second undo without a send in between works. + +**Rollback anchors** (fixes transcript plan N7): a single predicate `is_rollback_anchor` replaces `is_user_anchor` (`claude_snapshot.rs:789`), and the transcript projector shares it. A real prompt anchors, and so does a `` user line. `` wrappers, lines with `origin.kind == "task-notification"`, `isMeta` lines and compact summaries do not. Undo steps therefore match the turns the pane renders. + +**`handle_rollback` v2:** +1. Resolve A (§4.8). Take the `rollback_in_flight(A)` single-flight, then `turn_lock(A)`. The lock order is unchanged. +2. Busy gate: `ledger.busy()` refuses with `BUSY_TURN`. Until C17 lands, the existing tracker plus the quiesce probe make this check. +3. Compute the keep point on A's effective transcript with the existing helpers (`resolve_resume_point`, `redo_resume_target`, tip and longest-common-prefix checks). The source is A, or A's parent after collapse (undo), or the chain root (redo). +4. Mint C. The genesis is `Fork{source, at, dropsTurn: guard}`, or `Fresh` for a first-turn undo. A full redo to the root tip uses `Resume{root}`. +5. Pre-write the rollback record so it is durable before anything changes (as today). Emit the pending-card cancellations for A. +6. **Spawn before teardown:** `establish_runtime(C)` runs while A is still alive. A is idle, and the fork only reads A's file and writes nothing until its first prompt (T6). On any failure: compensate the record, tear down the half-spawned C, and answer the typed error. **A is untouched and still live.** `FORK_GUARD_REFUSED` and `RESUME_POINT_NOT_FOUND` become `ROLLBACK_REFUSED`. +7. One awaited ledger batch: binding C (`genesis`, A's settings and plugins, `supersedes: A`, `name_transition: InternalContinuation`) plus the rollback-record re-key A→C. +8. `rekey_record(A → C, Rollback)` (§4.6): runtime C goes in, and the clock, `turn_lock` and pending state carry over. Then tear down A's runtime as a requested, silent teardown. +9. Broadcast `session.materialized {A→C}` and `rolledBack`, then acknowledge the requester. + +A send that was parked on `turn_lock(A)` re-resolves after the lock through the **durable ledger chain** (`pane_ledger.lookup_by_session("claude", A)` gives the corrected terminus C). It is never resolved through an in-memory forward with a time limit, and never through `decide_boot(A)`. A's transcript exists, so that would revive the pre-rollback conversation as a second live session. + +**`handle_fork` (G09), with an eager, boot-verified child runtime (codex/opencode parity):** +1. Resolve parent A and take its `turn_lock`. Refuse with `BUSY_TURN` while busy. +2. The keep point is the last chain entry of the step group containing `input.atTurnId` (strip any `:row-N` suffix, as codex does), or the tip. Apply source collapse. +3. Mint C. Run `establish_runtime(C, Fork{source, at, drops_turn: None})`. The binding gets provenance from the forking connection and **no** `supersedes`. +4. Reply `freshAgent.forked {parentSessionId: A, sessionId: C, sessionRef, requestId}` on the requesting connection. The client re-points the pane and kills A (the existing flow at `FreshAgentView.tsx:3188-3230`). +5. Remove the claude cell from the refusal table (`terminal.rs:9553-9562`), add the claude arm (`terminal.rs:2226-2256`) and route fork in the session host. The snapshot stamps `capabilities.fork: true`; the client already gates it on idle. + +**Pending-fork snapshot:** the source's prefix rebuilt with the existing parser, stamped `threadId = sessionId = C`, with `revision = max(binding.updated_at, record.last_op_at_ms)`. Line uuids survive a fork (T6), so turn ids do not change when the fork materializes. + +### 4.6 Re-keys: rollback, conversation reset and the legacy migration + +`rekey_record(old, new, reason)` is the only place an envelope id changes: +- **Under the map lock:** move the record (runtime, ledger, pending, clock, deadman, `turn_lock`) from `old` to `new`. +- **Ledger:** write binding `new` with `supersedes: old`. The genesis depends on the reason: `Fork…` for a rollback, `Fresh` for a reset (it covers the flush window and is cleared lazily), none for the legacy migration. +- **Broadcast** `freshAgent.session.materialized {previousSessionId: old, sessionId: new, sessionRef}`. +- **Notify** `AttentionObserver::on_rekeyed(old, new)` (§9.2 I-2). +- **Session host:** the bridge emits `AgentEvent::Started{new}` on `materialized`, as it does today. + +**Conversation reset (T16).** The sidecar maps `type: 'conversation_reset'` to `sdk.session.rekeyed {sessionId: A, newSessionId: B, reason: 'conversation_reset'}`. Rust calls `rekey_record(A → B, Reset)`. The ledger entry (the `/clear` command is still in flight) moves with the record, so its result retires normally under B. +- **Init becomes verify-only.** If `cliSessionId != durable_id` and no reset preceded it, log at error (`freshagent.claude.unannounced_rekey`) and still `rekey_record(…, Unannounced)`. The CLI decides which file it writes, so following it is safer than letting the snapshot freeze. The session is never torn down. + +### 4.7 Legacy placeholder data (backward compatibility) + +| Case | Behavior | +|---|---| +| L1: a persisted pane has a nanoid `sessionId` and a durable `sessionRef` | Client hydration re-keys `sessionId := sessionRef.sessionId` (C26). For tabs that stayed open across the deploy, the server's attach path covers it: a non-UUID id with a durable `sessionRef` establishes the durable id, broadcasts `materialized(placeholder → durable)`, then acknowledges. | +| L2: a placeholder pane with no `sessionRef` (zero-turn under the old code) | The lost frame, then today's recovery recreates the session. Unchanged. | +| L3: a kill by placeholder | The persisted alias-tombstone **read** path (`claude.rs:3940-3956`, `sink.alias_tombstone_records`) still resolves the placeholder to its durable row for the retire. No new tombstones are written after C27. The read path stays for at least one release. | +| L4: old binding rows (no genesis, no plugins) | Resume from the transcript, exactly as today. Plugins come from the request when present. | +| L5: rollback records | The schema and `ROLLBACK_RECORD_VERSION` 1 are unchanged. The genesis lives in the binding row. | +| L6: orphaned `record_pending` markers | No longer written for claude. They age out through the boot scan (`PENDING_MARKER_ORPHAN_TTL_MS`). | +| L7: old client, new server | `created` carries a UUID plus `sessionRef`. The client already accepts UUID claude ids. `freshAgent.result.sessionBusy` is additive. | +| L8: new client, old server | The new client behavior (Resume, stop-for-recovery for claude, `sessionBusy`) only triggers on statuses and fields an old server never sends. | +| L9: a create re-driven after a restart | `identity_sink.lookup_by_create_request_id` maps the requestId to the id it already minted. The create becomes a resume. | + +### 4.8 Resolving an incoming id (every handler) + +Each handler resolves the id it receives in this order, and stops at the first match: +1. A map hit (a Live record). +2. The ledger chain terminus: `lookup_by_session(...)` with `corrected` true means a different terminus, so use it (an attach also gets a `materialized` frame on the reply sink). +3. A legacy placeholder: before C27 through `cli_index`, after that through the tombstone read (kill), or `sessionRef` (attach). +4. The dead map, which leads to `ensure_runtime`. +5. A UUID with a transcript or genesis, which leads to `ensure_runtime`. +6. Otherwise, the nested lost frame (`INVALID_SESSION_ID`). A send also gets a request-correlated error, so the client's owned-send cleanup runs. + +`ensure_runtime(id, fence)` is the analog of codex's `ensure_session_alive`. It is single-flight through `resuming`. It runs `establish_runtime(decide_boot(id))` under the existing lease, ownership claim and kill-fence discipline, then removes the dead entry and moves its clock and `turn_lock` across. + +### 4.9 `establish_runtime`: one spawn and commit path + +``` +claim(ownership, id) → lease(id) → decide_boot (§4.2) → spawn_sidecar → write create_request(spec) + → await created | create.failed (SIDECAR_CREATE_BUDGET = 45 s; typed retries per §4.2) + → commit_session_claim (kill fence) → register record → post-registration dead-state recheck + → binding write (awaited: settings + plugins + genesis [+ supersedes]) → lease.complete + → commit_lane_claim Live → naming bind → Established{cli_pid} +``` + +`establish_runtime` absorbs the spawn/commit bodies of `handle_create` (resume lane), `resume_for_attach`, `resume_for_handoff` and the rollback respawn. Every existing guard is **moved, not rewritten**: the `claim_dead_state` snapshot, the post-registration recheck, the lease-revoke arm and the commit-stale arm. The existing kill/claim/commit race tests run against it unchanged. `ClaudeLaunchSpec::create_request()` is the only builder of a `create` line, and it always carries `plugins` and `hostedMcp: self.hosted_mcp` (G15). + +--- + +## 5. Turns, Stop, alerts, stuck + +### 5.1 Sidecar ⇄ Rust protocol v2 (stdio NDJSON) + +Rust → sidecar: + +| Frame | Shape | From | +|---|---|---| +| `create` | `{protocol: 2, requestId, sessionId, sessionType, boot: {kind:'fresh'} \| {kind:'resume', resume} \| {kind:'fork', resume, resumeSessionAt?, dropsTurn?}, cwd?, model?, permissionMode?, effort?, plugins?, hostedMcp?}`. Mapping: fresh → `{sessionId}`; resume → `{resume}` (never `sessionId`, which the SDK forbids with resume unless forking); fork → `{resume, resumeSessionAt, forkSession: true, sessionId, resumeDropsTurn}`. Before C23, `sessionId` is required only for fork and rollback. | C10, C13, C23 | +| `send` | `{sessionId, uuid (required, Rust-minted), text, images?, checkpointAttempt?: {logicalAttemptId, requestId}}`. The checkpoint attempt reuses `uuid`. | C16 | +| `interrupt` | `{sessionId, stopId}` | C18 | +| `configure`, `permission.respond`, `question.respond`, `shutdown` | unchanged | | +| `rollback.quiesce` | **removed** | C17 | + +Sidecar → Rust: + +| Frame | Shape / meaning | From | +|---|---|---| +| `created` | `{requestId, sessionId, protocol: 2, cliPid}`, emitted only after `initializationResult()` resolves. The command catalog is published from that result. | C10 | +| `create.failed` | `{requestId, code, message}` (codes in §4.2). The sidecar then exits with code 2. | C10 | +| `sdk.session.init` | `{sessionId, cliSessionId, model, cwd, tools, capabilities}`. Rust verifies the id only (§4.6). | C10/C24 | +| `sdk.session.rekeyed` | `{sessionId: A, newSessionId: B, reason: 'conversation_reset'}` | C24 | +| `sdk.command` | `{sessionId, uuid, state: queued\|started\|completed\|cancelled\|discarded\|replayed}`. `replayed` is sent when a sent uuid is replayed (the fallback for CLIs without lifecycle frames, and the "first content" signal in §5.4). Unknown uuids are not forwarded. | C16 | +| `sdk.session.state` | `{sessionId, state: running\|idle\|requires_action}`. The sidecar sets `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS=1` in the CLI environment. | C16 | +| `sdk.status` | `{sessionId, status: 'compacting' \| null, compactResult?, compactError?, permissionMode?}`. `null` is new (T15). The `requesting` status is turn evidence. | C16 | +| `sdk.heartbeat` | `{sessionId, kind: 'tool'\|'task'\|'retry', toolUseId?, elapsedMs?}`, rate-limited to one per 5 s per kind. It comes from `tool_progress`, `task_*` and `api_retry`. It is liveness only and is never forwarded to clients. | C16 | +| `sdk.result` | `{sessionId, subtype, isError, terminalReason, numTurns, errorText?, durationMs, costUsd, usage}`. `errorText` is `errors.join` with every `[ede_diagnostic] …` entry removed. | C16 | +| `sdk.interrupt_settled` | `{sessionId, stopId, ok, stillQueued?: [uuid], message?}` | C18 | +| `sdk.send.rejected` | `{sessionId, uuid, code: 'INVALID_IMAGE'\|…, message}`. A bad image no longer crashes the sidecar. | C16 | +| `sdk.stream.ended` | `{sessionId, reason: 'cli_exited'\|'stream_error'\|'stream_eof', message}`. The last line before exit code 3. A requested teardown exits 0 without it. | C08 | +| Content frames | `sdk.assistant`, `sdk.stream`, `sdk.compact_boundary`, `sdk.session.changed`, `sdk.configured`, `sdk.permission.*`, `sdk.question.*`, `sdk.checkpointTurnIdentity`, plus workstream frames (`sdk.retry`, `sdk.notice`, `sdk.session.metadata`). `sdk.session.changed{session-commands}` is published only when the catalog actually changes. | unchanged | +| `sdk.error` | Narrowed to request-level failures. The `turnFailure` and `sessionNotFound` variants are removed. | C18 | +| `sdk.turn.complete`, `sdk.turn.waiting`, `sdk.status:'idle'`, `sdk.exit`, `sdk.rollback.quiesced` | **Removed.** Each is kept alive until its Rust consumer switches over (the staging rule in §10), then deleted in the same commit as that switch. | C17/C18 | + +### 5.2 `ClaudeTurnLedger` (pure; `claude_turns.rs`) + +```rust +struct ClaudeTurnLedger { + commands: VecDeque, // acceptance order; Cmd{uuid, request_id, kind: Turn|Compact, phase: Accepted|Queued|Started} + turn_open: bool, // the CLI is working on a turn (any origin) + compacting: bool, + lifecycle_capable: bool, // first sdk.command, or init capability msg_lifecycle_v1 + stop: Option, +} +busy() = !commands.is_empty() || turn_open || compacting +``` + +| Input | Effect | +|---|---| +| `arm(uuid, request_id, kind)` | Runs under `turn_lock`, before the write. Pushes `Accepted`. If busy rises, publish `running`. | +| `undo_arm(uuid)` | On a write failure or `sdk.send.rejected`. | +| `sdk.command queued` | Sets the phase. | +| `sdk.command started` / `replayed` (first one wins) | Sets phase `Started` and `turn_open = true`. | +| `sdk.command completed/cancelled/discarded` | Removes the command (idempotent). An unknown uuid is ignored and logged at debug. | +| `sdk.session.state running/requires_action` | `turn_open = true`. This covers CLI-initiated turns (T9, T19). | +| `sdk.session.state idle` | `turn_open = false`. Every `Started` command is removed (a started command cannot outlive an idle). `compacting = false`. `Accepted`/`Queued` commands stay, because a just-pushed send can race an idle (T9). | +| Turn evidence: `sdk.session.init`, `sdk.status requesting`, `sdk.assistant`, `sdk.stream` | `turn_open = true`. This is the fallback when `session_state` is absent. Trailers after a result (lifecycle `completed`, `idle`) never open a turn. | +| `sdk.status compacting` / `null`, `sdk.compact_boundary` | Sets or clears `compacting`. | +| `sdk.result` | Computes the verdict first (§5.3). Then `retired` = every `Started` command (exact: only one turn runs at a time, and absorbed commands start inside it, T10). Old-CLI fallback: if none started and `!lifecycle_capable`, retire the oldest `Accepted` command (the unknown-slash-command case, T8), logged as `ledger.fallback_head_retire`. Remove `retired`, then set `turn_open = false` and `compacting = false`. | +| EOF | Returns `owed = busy()`, then clears everything. | + +Retiring at the result, and closing `turn_open` there, is what makes a single ring per result exact. A CLI death after the result but before the trailing lifecycle or idle finds nothing owed, so it adds no second ring. + +`effective_status(dead, stuck)`: `exited`, else `stuck`, else `compacting`, else `running` if busy, else `idle`. This one function feeds status pushes, attach acknowledgements and snapshot overlays. + +`publish_status()` runs after every ledger, deadman or lifecycle change and broadcasts `freshAgent.status` on change. **After every forwarded `freshAgent.result`, Rust also re-broadcasts the effective status whenever it is not `idle`.** The result frame carries `sessionBusy: ledger.busy()`, computed after the fold. Together these fix the client's `turnResult`, which today forces `idle` (`freshAgentSlice.ts:585-597`), while a queued command is still owed (T10). + +### 5.3 Result verdict + +``` +aborted = terminalReason ∈ {aborted_streaming, aborted_tools} + || (terminalReason absent && subtype == error_during_execution && stop armed) // older CLIs +silent = aborted && stop armed + && (retired non-empty && retired ⊆ stop.targets || retired empty && stop.target == CliTurn) +on sdk.result: + silent → no turn.complete, no banner; latch cleared + subtype != success → turn.complete + nested freshAgent.error CLAUDE_TURN_FAILED (sanitized errorText, + else per-subtype copy; an abort with no user Stop: "Claude stopped the turn + before it finished.") + else (incl. success+is_error) → turn.complete (no banner: the transcript plan renders the API-error item, T18) +``` + +"Silent" applies only to work the Stop targeted. A command that started after the Stop, or another device's command that a mistaken re-send aborted, rings. + +### 5.4 Stop latch (single target, re-send rule) + +``` +handle_interrupt(msg): + dead → re-broadcast status exited; unknown → nested INVALID_SESSION_ID + !busy → no-op (logged freshagent.claude.stop {outcome: idle}) + target = Started commands (the running turn) → awaiting_start = None + | else oldest Accepted/Queued command T → awaiting_start = Some(T) + | else turn_open (CLI-initiated turn) → CliTurn + stop = StopLatch{stop_id, targets, awaiting_start, receipt: Pending, t_lifecycle_before_receipt: false, reissues: 0} + write interrupt{stopId}; write failure → stop = None, nested CLAUDE_INTERRUPT_FAILED +sdk.interrupt_settled ok:false → stop = None; nested CLAUDE_INTERRUPT_FAILED (requestId = the interrupt's) +sdk.interrupt_settled ok:true{stillQueued} → receipt = Received{lists_target: stillQueued ∋ T}; evaluate +sdk.command queued/started/replayed (T) → note it (and whether it came before the receipt); evaluate +evaluate (awaiting_start = Some(T), T unsettled, reissues < 2): + Case A — receipt lists T, OR the receipt arrived before any lifecycle frame of T: + re-send when BOTH the receipt and T `started` (or `replayed`) have been seen (whichever is second) + Case B — receipt [] AND T's queued/started arrived before the receipt (ambiguous: the interrupt may + have landed on T, or the observed order swapped): re-send on T's first content (`replayed`, or an + sdk.assistant/sdk.stream while T is the only Started command), unless T settles first + after a re-send: reissues += 1; awaiting_start = None (the new interrupt aborts the running T) +latch clears: every target retired (result, terminal lifecycle, idle), EOF, kill, recovery stop +``` + +Why this shape: +- **Both cold-receipt shapes are real (T13), and the order of receipt versus lifecycle is racy.** Case A handles the overtaking and pre-wait shapes either way round. +- **Case B waits for content** because the turn only produces content if the first interrupt did not land on it. P4 shows the replay about 80 ms after `started`, so the cost is a fraction of a second of extra running. +- **A wrong re-send costs little.** It hits an idle CLI (a no-op) or the next queued command. That command is not a target, so its turn rings (§5.3). Nothing is silenced by mistake, and every re-send is logged with its case. +- **Single target (codex-parity).** Commands queued behind the running turn are listed as survivors and "WILL run" (T12, sdk.d.ts:3765). They are left alone and ring normally. Stopping another device's queued message would be an unrequested change to the product. + +### 5.5 Attention edges: one choke point + +`ClaudeEdgeHub::emit(record, kind: TurnComplete|Waiting, source: Result|Death|Deadman|Pending)`: +1. Mints `at` on the record's `AttentionClock`. This is `next_monotonic_turn_complete_at` over one stored value, so the result, death and deadman sources cannot collide (G24). The clock carries across rekeys and death/resume, and starts fresh after a server restart (the regime AGENTS.md already accepts). +2. Logs `freshagent.turn.complete` or `freshagent.turn.waiting` at info, with `provider, session_id, session_type, source, at`. Ids only, never content (codex DIAG-01 shape). +3. Broadcasts the existing client frame. The shape is unchanged. +4. Calls `AttentionObserver` hooks (client-alerts plan I-1). +5. Publishes a typed `ClaudeTurnEvent` (API plan I3). + +Edge sources: + +| Source | Rule | +|---|---| +| Result | The verdict in §5.3. | +| Death (unrequested EOF) | Rings if `ledger.owed` was true. Idle deaths and requested teardowns (kill, rollback swap, recovery stop, shutdown) are silent. | +| Deadman | §5.6 | +| Waiting | When `fold_pending_frame` reports that the pending approvals and questions went from 0 to 1 or more. It uses its own namespace on the client, unchanged. When pending drops back to 0: `AttentionObserver::on_pending_drained`. | + +### 5.6 Stuck deadman (shared `quiet_deadman.rs`) + +- **Window:** `W = FRESHELL_FRESHCLAUDE_QUIET_WINDOW_MS`, default 600 000 ms (10 minutes, codex parity). +- **In flight:** `ledger.busy() && pending.is_empty()`. A pending approval or question pauses the deadman: the user is blocking, not the agent, and `turn.waiting` has already rung. Codex does not pause here; that part is not copied. +- **Feed:** every sidecar stdout line re-arms the window, including the transcript plan's `sdk.retry`/`sdk.notice`/`sdk.session.changed`, **except** an `sdk.heartbeat{kind:'tool'}` whose `elapsedMs >= C`. The cap `C = FRESHELL_FRESHCLAUDE_TOOL_HEARTBEAT_CAP_MS`, default `BASH_MAX_TIMEOUT_MS` from the server environment (else 600 000) + 120 000, which is 12 minutes. In practice: + - A legitimate Bash call within its 10-minute CLI cap is never flagged. + - A turn with no frames at all (an API stall) is flagged at W, 10 minutes. + - A hung MCP tool that heartbeats forever (T17) is flagged at about C + W, roughly 22 minutes, instead of never (sidecar-authoritative) or after 70 minutes (sdk-identity's 1-hour cap). + - Task/subagent and retry heartbeats always feed, because they are progress. +- **Fire:** if the generation is unchanged and the session is busy and not paused, the status becomes `stuck` and the hub emits one edge (`source: quiet_deadman`). Any later line clears `stuck` (back to `running`) and re-arms. A later real result rings its own edge (codex parity). +- **Recovery:** `freshAgent.recovery.stop` routes by provider (`terminal.rs:2136-2143`). `FreshClaudeState::handle_recovery_stop` does a requested teardown of the process only, keeps the row bound, moves the record to the dead map with cause `RecoveryStop`, and answers `freshAgent.recovery.stopped`. The client then calls `triggerRecovery()`. + +### 5.7 Death and the sidecar's end of stream + +- **Sidecar `consumeStream` finally block:** cancel pending cards. If the end was not requested, emit `sdk.stream.ended{reason, message}`, flush stdout, and exit with code 3. A requested abort or shutdown exits 0 and emits nothing alert-related. +- **Rust EOF arm**, after checking `ownership_id` so a stale consumer is ignored: + - Evict the record into the dead map and release the lease and ownership stamp (as today's eviction does). Disarm the deadman. + - Broadcast `freshAgent.status{exited}`. + - Broadcast nested `freshAgent.error{SIDECAR_EXITED}` with the cause from `sdk.stream.ended` or the exit status. + - If `owed`, the hub emits a death edge. + - Publish `ClaudeTurnEvent::Ended{reason}`. +- **Interim, from C08 until C18:** the sidecar still creates the result-less edge in its `finally` block. During that window the Rust EOF arm **skips its own ring when `sdk.stream.ended` was seen**, and rings only for a Node death, which produces no `stream.ended`. Tests pin exactly one `turn.complete` per result-less end in each phase. + +### 5.8 Stdin writer (G27) + +`SidecarWriter` is a task that owns `ChildStdin`, fed through an mpsc channel. Each write gets `timeout(FRESHELL_FRESHCLAUDE_WRITE_TIMEOUT_MS = 10 s, write_all + flush)`. Handlers clone the writer under the map lock, drop the lock, then await. A timeout or a broken pipe means the sidecar is wedged: kill the child **without** removing the record, so the unrequested EOF path runs (ring if owed, then `exited`, then transparent resume). `respond_session_guard` returns a writer and a pending handle instead of a `MappedMutexGuard` over the whole map. + +### 5.9 Logging (G14) + +**Sidecar** (`log.mjs`, JSONL on stderr): +- `sidecar.ready`, `boot.ok`: info. `boot.failed`: warn. +- `interrupt.failed`, `configure.failed`, `respond.unknown`, `send.rejected`: warn. +- `stream.ended`: warn for an error, info for EOF. +- `cli.stderr`: info, truncated to 2 KiB. In hosted mode, a byte count only, as today. +- Prompt text is never logged. + +**Rust** (`spawn_stderr_logger` replaces `drain_reader`, `claude.rs:9709`): +- Parses each line and re-emits it at its severity, with `session_id`, `session_type` and `ownership_id`. +- Non-JSON lines are logged at warn with `raw=true` (truncated to 4 KiB; byte count only in hosted mode). +- Rate-limited to 50 lines/s per sidecar, with a periodic dropped-lines summary. + +**Rust decisions:** +- `freshagent.turn.complete` and `freshagent.turn.waiting` (info, with source); +- `freshagent.claude.stop` (info: `stop_id`, target, case, reissues); +- `freshagent.claude.boot` (info: boot kind, retry, code); +- `freshagent.claude.status` (debug, on change); +- `freshagent.claude.deadman` (armed, paused, fired); +- `freshagent.claude.sidecar_exit` (warn: cause, exit status); +- `freshagent.claude.rekey` (info: reason); +- `ledger.fallback_head_retire` (warn). + +### 5.10 Restart notice (G25 part 2; client-alerts plan I-5) + +`FreshClaudeState::shutdown`: before draining, for every Live record whose ledger is owed, call `PaneIdentitySink::mark_turn_interrupted("claude", id, at)`. This is backed by a new serde-optional `BindingRow.turn_interrupted_at`, awaited with a 500 ms bound. On the next `establish_runtime` of that id, after the acknowledgement, broadcast nested `freshAgent.error{code: 'TURN_INTERRUPTED_BY_RESTART', message: 'The server restarted while this turn was running. The turn was stopped. Send a message to continue.'}`, then clear the marker. No edge is sent, because a restart is a requested teardown under AGENTS.md. + +--- + +## 6. Rust → client frames + +| Frame | Change | +|---|---| +| `freshAgent.created` | Carries `sessionRef` for resume creates from C10. From C23, `sessionId` is the durable UUID, always with `sessionRef`. Sent after the boot is verified. | +| `freshAgent.send.accepted` | `submittedTurnId` is the send's UUID (C16), which equals the persisted user line's uuid and so the snapshot `turnId`. Absorbed sends have no own user line. | +| `freshAgent.event{freshAgent.status}` | Rust-authored for claude: `running` (at accept), `idle`, `compacting`, `stuck`, `exited`. Re-broadcast after a result while not idle. | +| `freshAgent.result` | Adds `sessionBusy` (additive). | +| `freshAgent.event{freshAgent.turn.complete \| turn.waiting}` | Shape unchanged. Rust-minted only. | +| `freshAgent.event{freshAgent.error}` | `CLAUDE_TURN_FAILED` (sanitized), `SIDECAR_EXITED` (with cause), `CLAUDE_INTERRUPT_FAILED` (nested, with requestId), `CLAUDE_SEND_REJECTED` (with requestId), `INVALID_SESSION_ID` (nested, for configure/interrupt/send on unresolvable ids), `TURN_INTERRUPTED_BY_RESTART`. None for a user Stop. | +| `freshAgent.session.materialized` | Rollback, conversation reset, unannounced re-key and legacy placeholder migration. On an attach by a superseded id, also sent on the reply sink. | +| `freshAgent.forked` | New for claude, sent on the requesting connection. | +| `freshAgent.recovery.stopped` | New for claude and kilroy. | +| `freshAgent.event{freshAgent.session.snapshot}` (attach acknowledgement) | Sent **on the requesting connection only**, with the effective status (G21). | +| REST snapshot | Served for zero-turn ids and pending forks. `statusFromLiveState: true` for Live and dead records (`exited`). `capabilities.fork: true`. | + +--- + +## 7. Attach, multi-device, hosted + +### 7.1 `handle_attach(msg, reply: ConnSink)` + +`terminal.rs:1965` passes the connection sink. + +| State | Action | +|---|---| +| Live (map hit) | Run the coordinator gate (unchanged). Send `status_snapshot_frame(id, effective_status)` on `reply`. | +| Superseded (ledger terminus ≠ id) | Send `materialized(id → terminus)` on `reply`, then handle the terminus. | +| Dead | `ensure_runtime`, then acknowledge (Recovered). | +| No record, UUID with transcript or genesis | `ensure_runtime`, then acknowledge. | +| Non-UUID with durable `sessionRef`/`resumeSessionId` (L1) | Establish the durable id, broadcast `materialized(placeholder → durable)`, then acknowledge. | +| Otherwise | The lost frame (unchanged client recovery). | + +`try_rebind_to_live`'s stamp flip is deleted (C25). Every device addresses the session by the same durable id, so the G16 split cannot happen. A create-resume from a second device finds the live record and answers `created{durable}`. + +### 7.2 Configure, compact, interrupt on non-live ids (G22) + +- **configure:** on a Live record, apply it. On a dead record, store the settings in the dead entry and the binding, answer `freshAgent.session.metadata`, and apply them at the next establish. On an unknown id, the nested lost frame. +- **compact:** goes through the send path, after `ensure_runtime`. +- **interrupt:** §5.4. + +### 7.3 Hosted session host + +`crates/freshell-session-host/src/providers/fresh_agent.rs`: +- **C10:** for claude/kilroy, `start` takes the native id from `freshAgent.created.sessionRef.sessionId` (resume) instead of `wait_exact_native(expected)` waiting for an init. The bridge stops emitting `AgentEvent::Started` on every per-turn init and emits it once, at `created` (when the id is known) or at `materialized`. +- **C23:** fresh starts also resolve from `created.sessionId`, which is now a UUID. +- **C14:** `fork()` routes claude to `FreshClaudeState::handle_fork`. +- `hostedMcp` rides every launch through `create_request()`. +- The hosted web proxy still refuses `recovery.stop` (`fresh_agent_proxy.rs:291`). The client falls back (§8). Routing it through the host is optional follow-up work in the API plan's G29 PR. + +--- + +## 8. Client changes (core-owned) + +- **`src/store/freshAgentSlice.ts`:** + - `turnResult` writes `running` when `sessionBusy === true`, else `idle`. + - Status `exited` replaces `compacting`. Pinned by a test. +- **`src/lib/fresh-agent-ws.ts`:** passes `sessionBusy` through. `CLAUDE_SEND_REJECTED` with a requestId is routed to the owned-send failure path. +- **`src/components/fresh-agent/FreshAgentView.tsx`:** + - The session-ended card (≈4624) shows **Resume session** for `provider === 'claude'` (freshclaude and kilroy) when the status is `exited` and a canonical id is present. It reuses `triggerRecovery`. + - `restartStuckSidecar` (≈2398) sends `freshAgent.recovery.stop` for claude with the pane's own provider and sessionType, instead of the hard-coded `'freshcodex'`. On `UNSUPPORTED_CAPABILITY` (hosted proxy) it falls back to kill plus `triggerRecovery`. + - Owned-send failure (≈3063-3121): for claude (`SESSION_NOT_FOUND`, `INVALID_SESSION_ID`, `CLAUDE_SEND_REJECTED`), the text goes back into the composer with "Message not sent: ". +- **Pane hydration:** the L1 claude placeholder re-key (`sessionId := sessionRef.sessionId` when the id is not a UUID and `sessionRef.provider === 'claude'`). +- **Fork affordances** appear through `capabilities.fork`. No code change; capability tests only. + +--- + +## 9. Interface contract with the three workstream plans + +### 9.1 Transcript and live rendering ([transcript-live plan](2026-10-22-freshclaude-transcript-live.md), G10/G12/G13) + +| ID | Their need | What core provides | When | If core has not landed | +|---|---|---|---|---| +| N1 | A real `index.mjs` plus scripted-SDK harness for WS and browser tests | One simulator, `test/fixtures/claude-cli-sim/query.mjs`, through `FRESHELL_CLAUDE_SDK_QUERY_MODULE`, with scenario programs through `FRESHELL_CLAUDE_SIM_PROGRAM`. The transcript plan's `FRESHELL_CLAUDE_SDK_SCRIPT` is an alias. Rust `RealSidecarSim`/`RealSidecarCli` rigs live in `crates/freshell-ws/tests/common/claude_rig.rs`. | C02–C04 | If their E1 lands first, core's C02 extends that module. There is only ever one simulator. | +| N2 | `running` at accept; effective status on acknowledgements | Rust-authored `freshAgent.status` from the ledger. Rust consumes `sdk.status` (`compacting`/`null`) and never forwards it raw. Their interim `sdk.status running` after a compaction is unnecessary: when `compacting` clears, the ledger recomputes `running`. | C17 (C25 for acknowledgements) | If their interim frame lands first, C17 deletes it. The ledger treats `sdk.status running` as a no-op. | +| N3 | Sidecar stderr JSON reaches `tracing` | `spawn_stderr_logger` | C07 | – | +| N4 | Unsolicited CLI turns (task notifications) handled for busy and the bell | `sdk.session.state` plus turn evidence open a turn with no command. Its result rings. | C16–C18 | – | +| N5 | New exit signals reach `sessionExited`/`status exited/stuck` | `freshAgent.status{exited}` (C09) and `{stuck}` (C22), only through `freshAgent.status` | C09, C22 | – | +| N6 | `case 'result'` and the interrupt paths are owned by core; the pure mapper is shared | Core edits `sdk-message-frames.mjs`, one function per SDK message kind. Core owns `result`, `command_lifecycle`, `session_state_changed`, `conversation_reset`, `tool_progress`/`task_*` (heartbeat), and `status` compacting/null. The transcript plan owns `assistant`/`stream_event`/`user` content, `sdk.retry`, `sdk.notice` and `sdk.session.changed{transcript}`. | C05 creates the module if B1 has not | The second to land rebases. Each function has its own unit tests. | +| N7 | `is_user_anchor` mis-steps on command, stdout and notification lines | A shared `is_rollback_anchor` predicate (§4.5), also used by their projector | C13 | – | +| – | Deadman awareness of their new frames | Any stdout line feeds the deadman, so `sdk.retry`/`sdk.notice`/`sdk.session.changed` count as activity | C22 | – | +| – | API-error banner policy | Core sends no banner for `success && is_error`. Their projector renders the error item. | C18 | – | + +### 9.2 Client alerts and UX ([client-alerts-ux plan](2026-10-22-freshclaude-client-alerts-ux.md), G11/G17/G20/G25/G26) + +| ID | Their need | What core provides | When | +|---|---|---|---| +| I-1 | One edge choke point | `ClaudeEdgeHub::emit` (§5.5), plus `trait AttentionObserver: Send + Sync { fn on_registered(&self, id, session_type); fn on_turn_complete(&self, id, session_type, at); fn on_waiting(&self, id, session_type, at); fn on_pending_drained(&self, id); fn on_rekeyed(&self, old, new); fn on_forgotten(&self, id); }`, held as `Option>` on `FreshClaudeState` (installed with `with_attention_observer`). Their `AttentionLedger` implements it. Before C18, C07 routes the two existing mint/forward sites through a `emit_attention_edge` shim so that they have a hook from C07 on. | C07 (shim), C18 (hub) | +| I-2 | A hook whenever the envelope id changes | `rekey_record` is the only place ids change, and it calls `on_rekeyed`. After C23 there are no stamp flips, only rekeys. | C13, C24, C26 | +| I-3 | The fake supports permission suggestions, `blockedPath`, `decisionReason`, `ExitPlanMode` and permission-mode reporting | The simulator drives `canUseTool` with those fields and emits `system/status {permissionMode}` as the CLI does. Their D0 contract test is the source of truth. | C02 | +| I-4 | Busy and status truth | C09, C17 | | +| I-5 | Restart notice | §5.10 | C28 | +| I-6 | Plan-mode change (part of G12) | Core does not fold `permissionMode`. It forwards `sdk.status.permissionMode` untouched, and their mapper function emits `sdk.session.metadata`. | – | + +### 9.3 API, MCP, hosted, operations ([api-hosted-ops plan](2026-10-22-freshclaude-api-hosted-ops.md), G18/G19/G28–G31) + +| ID | Their need | What core provides | When | +|---|---|---|---| +| I1 | `handle_create(msg, provenance) -> ClaudeCreateOutcome` | `enum ClaudeCreateOutcome { Created { session_id, session_ref: Option }, Failed { code, message, retryable, owner: Option } }`. WS frames stay byte-identical, and the dispatcher ignores the value. | C11 | +| I2 | `handle_send(msg) -> ClaudeSendOutcome` | `enum ClaudeSendOutcome { Accepted { session_key, submitted_turn_id }, Refused { code, message } }`. Also `FreshClaudeState::session_busy(id) -> Option`. | C16 | +| I3 | A typed turn-event stream | `FreshClaudeState::subscribe_turn_events() -> broadcast::Receiver`, where `enum ClaudeTurnEvent { Settled { session_key, retired_request_ids: Vec, retired_command_ids: Vec, outcome: Completed\|Failed\|Interrupted, at: Option }, Waiting { session_key, at }, Ended { session_key, reason } }`. It is published from the hub on a dedicated low-volume channel, so it can never lag because of `sdk.stream` traffic. `Settled` is published for silent results too, with `at: None`. | C18 | +| I4 | One launch builder | `ClaudeLaunchSpec` plus `create_request()` in `claude_launch.rs`. The G19 fields (`freshellMcp`, `freshellContext`) are added there by their PR3, as `Option` fields that core passes through unchanged. | C11 | +| I5 | Resume-on-send for a dead session | `ensure_runtime` | C20 | +| I6 | `created.session_id` may change | It becomes the durable UUID. Treat it as opaque. | C23 | +| I7 | Real `index.mjs` plus scripted-SDK Rust harness | `claude_rig.rs` | C04 | +| I8 | G01 consumes the G28 `TranscriptLocation` | `decide_boot` and rollback match on `TranscriptLocation::{Found, Absent, Unreadable}`. **Their PR1 (G28) should land before C11.** If it has not, C11 adds the enum under their names in `claude_snapshot.rs`, and PR1 only extends it. | C11 | +| G31 | Graceful teardown and a tree guard | Core's sidecar exits 0 silently on `shutdown`, and the EOF arm treats a record removed before teardown as requested, so their "write shutdown, then wait 2 s for the child" composes. `establish_runtime` stores whatever guard `spawn_sidecar` returns. The module name `claude_spawn.rs` is theirs. | – | +| G29 | Hosted configure routing | Core's configure semantics (dead → store + metadata, unknown → nested lost frame) apply unchanged inside the session host. | – | +| e2e | Their specs use `fake-claude-sdk-sidecar.mjs` | C30 retires that fake. Their specs move to the real sidecar plus the simulator (I7) in the same commit. | C30 | + +--- + +## 10. Ordered TDD commit sequence + +Every commit is red → green → refactor and leaves the suite green. Five rules apply throughout: +1. **Red tests drive real code.** When a commit changes an area, that area's tests move off the protocol fakes onto the real `index.mjs` plus the simulator, and the moved tests are the red tests. +2. **Old frames stay alive until their consumer switches.** A sidecar frame keeps being emitted until the Rust consumer is switched over, and both change in the same commit. +3. **Nothing is skipped.** Tests that encoded fake-only framing are rewritten, never skipped. +4. **Characterization commits are the exception.** C01 is green when written: it pins the external CLI and fails on drift. +5. **PR boundaries.** Each PR (A–G) can merge on its own. + +### PR-A: ground truth and alert containment (sidecar fixes; Rust keeps its current turn logic) + +| # | Commit | Red first | Notes and tests that change | +|---|---|---|---| +| C01 | `test(claude): real-CLI contract suite pins the CLI facts` | `test/integration/providers/claude-cli-contract.test.ts`, one named `it` per fact T1–T19 | Extracts the fake endpoint into `test/integration/providers/claude-local-provider.mjs`, with modes: text, slow, Bash tool, API error status, hang, a hung stdio MCP server, and an async Agent. The existing `claude-checkpoint-uuid-replay.mjs` imports it. Cold-Stop scenarios assert the outcome (aborted after a re-send), never the receipt contents. The suite fails, rather than skips, when the pinned CLI is missing. | +| C02 | `test(claude): CLI simulator that matches the real CLI` | `claude-cli-conformance.test.ts` with `target: sim` fails | The simulator implements T1–T19: per-turn init, lifecycle, session state, replays, absorption vs queueing, both cold-Stop receipt shapes (a knob), boot refusals, `sessionId`/fork lazy JSONL under `CLAUDE_CONFIG_DIR`, `/compact`, `/clear`, CLI-initiated turns, heartbeats (`FRESHELL_CLAUDE_SIM_TIME_SCALE`), crash, hang, stdin stall, and `canUseTool` with suggestions and `ExitPlanMode`. Programs come from `FRESHELL_CLAUDE_SIM_PROGRAM` (it ports the e2e fake's rule language). The test compares normalized traces against `target: real`. | +| C03 | `test(claude-sidecar): unit tests run the real index.mjs over the simulator` | (port) | `test/unit/claude-sidecar/harness.ts` (`startRealSidecar`). Ports `interrupt-idle-gate`, `user-message-replay`, `permission-channel`, `rollback-quiesce` and `session-settings` with unchanged assertions; only their init-at-create fixtures go. | +| C04 | `test(rust): RealSidecarSim and RealSidecarCli rigs` | `freshagent_claude_real_cli.rs::create_send_result_rings_once` | `crates/freshell-ws/tests/common/claude_rig.rs`, behind a `test-support` feature. A green smoke test on both rigs (today's plain turn works). | +| C05 | `fix(freshclaude): a user Stop shows no error banner (G03)` | A sidecar harness test: Stop mid-turn (real T11 shape) gives no `sdk.error`. A unit test: `[ede_diagnostic]` is stripped. e2e `fresh-agent-error-turn-attention.spec.ts` Stop lane (moved to real sidecar plus simulator): no "Agent error" banner. | Creates `sdk-message-frames.mjs` if transcript B1 has not. The gate's verdict is consulted before the failure banner. | +| C06 | `fix(freshclaude): the Stop gate silences only aborted results (G06 bell)` | Sidecar harness: a cold-window Stop followed by `success/completed` gives `sdk.turn.complete`. A stale mark never swallows the next turn's bell. | `turn-complete-gate.mjs` takes the result. Interim, deleted in C18. | +| C07 | `feat(freshclaude): structured sidecar logs and edge logs (G14)` | Rust: a sidecar writing JSONL stderr produces captured `tracing` events at the parsed severity (rate-limit summary; hosted byte counts). Sidecar: `ready` at info. Rust: a forwarded `sdk.turn.complete` is logged as `freshagent.turn.complete`. | `log.mjs`, `spawn_stderr_logger`, and the `emit_attention_edge` shim around the two existing edge sites (I-1). | + +### PR-B: lifecycle truth (G02, G05 status, G23, boot handshake) + +| # | Commit | Red first | Notes and tests that change | +|---|---|---|---| +| C08 | `fix(freshclaude): the sidecar exits when its SDK stream ends (G02, G23)` | Sidecar: a simulator crash produces `sdk.stream.ended` and exit code 3; shutdown exits 0 silently. WS (RealSidecarSim): a CLI crash mid-turn gives **exactly one** `turn.complete` and `SIDECAR_EXITED` with the cause, and the sidecar process is gone. A natural end of stream mid-turn leaves the session not busy. A Node SIGKILL mid-turn gives exactly one ring. | Deletes `result_idle_pair_pending`, its tests and its doc comment (`claude.rs:638-653`). Interim ring rule (§5.7). The fakes stop emitting a per-turn idle. The e2e STREAM_ERROR lane (`fresh-agent-error-turn-attention.spec.ts:89-97`) now expects an exit. | +| C09 | `feat(freshclaude): exited status, dead-session record and Resume (G05)` | WS: SIGKILL mid-`/compact` gives `status exited`, one ring, and an `exited` snapshot. An idle death does not ring. A send to a dead id gives the nested lost frame plus a request error. `has_live_session` is false for a dead id. Client: Resume is rendered for claude/kilroy on `exited`; `exited` replaces `compacting`; a failed send restores the composer. e2e: a background crash gives `turnCompletion.seq` +1, a highlight and Resume, and the Resume restores the conversation with no reload (freshclaude and kilroy). | The dead map (§3.3). The kilroy crash lane in `fresh-agent-control-rust.spec.ts` asserts `turnCompletion.seq` and Resume. | +| C10 | `feat(freshclaude): created only after the CLI boots; typed boot failures` | Sidecar: the `classifyBootError` table on the exact T4/T5 strings. A refused boot emits **no** `sdk.result` and exits 2. A protocol mismatch fails fast. WS: a create-resume of a missing transcript gives `create.failed`, not `created` followed by a zombie. A bad `CLAUDE_CMD` gives `create.failed`. The handshake reader forwards no line before `created`. Session host: a claude resume `start` resolves from `created.sessionRef` with no prompt (fails today). | `boot.mjs`. `protocol: 2`. The commands catalog comes from `initializationResult()` and is published only on change. `created.sessionRef` is set for resume creates. The bridge emits `Started` once. | + +### PR-C: launch, genesis, history (G15, G01, G09) + +| # | Commit | Red first | Notes and tests that change | +|---|---|---|---| +| C11 | `refactor(freshclaude): one launch spec and one establish path (G15)` | A create-line parity test: the create, attach-resume, handoff-resume and rollback lines all carry `plugins` and `hostedMcp` (the simulator records the `query()` options). A ledger round trip of `BindingRow.plugins`. The existing kill/claim/commit race tests run unchanged against `establish_runtime`. | `claude_launch.rs`. `FreshAgentSettings.plugins`. `ClaudeCreateOutcome` (I1). `TranscriptLocation` (I8). | +| C12 | `feat(freshclaude): genesis in the binding row; genesis-aware snapshot and reconcile` | `pane_ledger_tests`: the field round-trips, an old row parses, and the genesis is cleared lazily. `claude_snapshot`: `Fresh` gives empty; `Fork` gives the parent prefix through `at` with turn ids preserved; unreadable gives Io. Reconcile: Absent + genesis + never on disk gives `respawn`; Absent + once-observed gives `dead_session`. REST: a bound pending id gives 200. | Inert until C13 writes a genesis. | +| C13 | `fix(freshclaude): undo/redo through a boot-verified fork runtime (G01)` | `freshagent_claude_rollback.rs` moved to RealSidecarSim **fails today**, because no init ever arrives. New tests: undo answers within the budget with `materialized A→C`; the next send materializes C; redo works; undo twice without a send works (source collapse); first-turn undo works; a guard refusal gives `ROLLBACK_REFUSED` and A is still live and accepts a send; a spawn failure leaves A live; a send parked on the lock lands on C through the ledger chain; restart after an undo restores the pending fork. Anchor-predicate tests on real `/cost`, `/compact` and task-notification lines. Real-CLI lane: undo, then send, then redo. e2e: `fresh-agent-rollback-rust.spec.ts` on the real sidecar plus simulator, plus "undo, restart before sending, the prefix is kept". | Deletes `read_session_init`, `rollback_spawn_create` and the init pre-read. The busy gate is still the tracker plus quiesce until C17. C is registered as already adopted (key = broadcast id = cli id = C). | +| C14 | `feat(freshclaude): fork a conversation (G09)` | `freshagent_claude_fork.rs`: `forked` arrives on the requesting connection; the child snapshot is the parent prefix; the child's first send materializes the same id; the parent transcript is untouched; busy gives `BUSY_TURN`. Session-host fork test. Client capability test. e2e `freshclaude-fork-rust.spec.ts` (freshclaude and kilroy). | The refusal-table test drops the claude case. | + +### PR-D: turns and alerts owned by Rust (G08, G21, G24, final G03/G06, G27) + +| # | Commit | Red first | Notes and tests that change | +|---|---|---|---| +| C15 | `refactor(freshagent): shared quiet_deadman and attention modules` | Unit: `observe(T)` then `mint()` with `now <= T` returns T+1. Direct `QuietDeadman` API tests. Codex deadman tests stay green and unchanged. | A behavior-preserving codex refactor. | +| C16 | `feat(freshclaude): uuid-stamped sends and lifecycle facts` | Sidecar harness: each send's uuid is on the `SDKUserMessage`; `sdk.command`/`sdk.session.state`/`sdk.heartbeat`/`sdk.status null`/enriched `sdk.result`/`stillQueued` are forwarded; a bad image gives `sdk.send.rejected` and the process survives. WS: `send.accepted.submittedTurnId` equals the snapshot `turnId`. | Legacy `sdk.turn.complete`/`turn.waiting`/idle are **still emitted** (staging rule). `ClaudeSendOutcome` (I2). | +| C17 | `feat(freshclaude): per-command turn ledger in Rust (G08, G23, G21)` | Ledger table tests from the recorded traces (`design-probe` H I J K L M N, `final-probe` CLI turn). Property tests: random interleavings consistent with T8–T10 end idle exactly when the CLI is idle, with one verdict per result. WS: an absorbed send from a second connection leaves both idle, configure is accepted and the next turn rings; a queued send keeps the client `running` between results (`sessionBusy`); `running` at accept and before any assistant frame; a CLI-initiated turn is busy. Client: `turnResult` honors `sessionBusy`. | Deletes `TurnTracker`, the compact candidate, `absorb_unstarted_compact_debt`, `fold_terminal_edge`, the quiesce protocol and their tests (`claude.rs` ≈20774–23113, `rollback-quiesce.test.ts`). Each protected property is re-expressed as a ledger test. The rollback busy gate becomes `ledger.busy()`. | +| C18 | `feat(freshclaude): Rust mints every attention edge; Stop latch (G03, G06, G24)` | WS: a warm Stop gives no `turn.complete` and no `freshAgent.error`. A cold Stop with receipt `[]` (simulator knob) is aborted and silent; with receipt `[uuid]` it is aborted and silent; with case-B ordering it is aborted on the replay. A Stop racing completion rings. A Stop with another device's queued send: the queued send runs and rings. An idle Stop is a no-op. A CLI-initiated turn rings. An API-error result rings with no banner; `error_max_turns` rings with a banner. A death after a result in the same millisecond gives a strictly larger `at`. The waiting edge is minted in Rust. `subscribe_turn_events` delivers `Settled` with `retired_request_ids`. | `claude_edges.rs` (hub, observer, I3). Deletes the sidecar gate, `sdk.turn.complete`/`turn.waiting`/idle/`exit` emissions, the `finally` mint, `emitWaitingEdge`, and `pendingResults`/`turnOpen`/`handedCompactLikely`. `interrupt-idle-gate.test.ts` now asserts the sidecar emits **no** edges. | +| C19 | `perf(freshclaude): per-session stdin writer (G27)` | A RealSidecarSim session whose sidecar stops reading stdin (`FRESHELL_CLAUDE_SIM_STALL_STDIN`): another session's send, snapshot and kill complete within 1 s, and the stalled session is killed after `FRESHELL_FRESHCLAUDE_WRITE_TIMEOUT_MS=500` through the EOF path. | `claude_sidecar_io.rs` | + +### PR-E: recovery and stuck (G05 transparent resume, G22, G07) + +| # | Commit | Red first | Notes and tests that change | +|---|---|---|---| +| C20 | `feat(freshclaude): transparent resume on send, attach, configure, compact (G05)` | After a crash, a send from a second connection is accepted on a respawned runtime (`resume: D`) and its turn completes. A zero-turn death followed by a send relaunches `Fresh`. Configure on a dead id is stored and applied at the next establish. Unresolvable ids give the nested lost frame. | `ensure_runtime` replaces C09's interim lost frames for resolvable ids. | +| C21 | `fix(freshclaude): visible control failures (G22)` | `freshagent_claude_kill_interrupt.rs`: configure or interrupt on an unknown id gives nested `INVALID_SESSION_ID`; an interrupt settle `ok:false` or write failure gives nested `CLAUDE_INTERRUPT_FAILED` with the requestId; interrupt on a dead id re-broadcasts `exited`. | The existing top-level `SESSION_NOT_FOUND` assertions change. | +| C22 | `feat(freshclaude): stuck deadman and recovery stop (G07)` | Integration with `W=200 ms`, simulator `HANG`: `stuck` plus exactly one ring. A pending approval pauses it. A tool heartbeat under the cap feeds; one over the cap does not. Activity after `stuck` gives `running`. `recovery.stop` stops the process, keeps the row bound, and the attach resumes. **Real-CLI lane:** a hung stdio MCP tool with `W=5 s`, `C=1 s` reaches `stuck` plus one ring. Client: `restartStuckSidecar` sends `recovery.stop` for claude/kilroy and falls back to kill plus resume on `UNSUPPORTED_CAPABILITY` (hosted-mode test). e2e stuck lane. | `terminal.rs` recovery-stop routing. | + +### PR-F: identity (G32, G16, G21 acknowledgements) + +| # | Commit | Red first | Notes and tests that change | +|---|---|---|---| +| C23 | `feat(freshclaude): mint the durable session id at create (G32)` | New `freshagent_claude_identity.rs`: `created.sessionId` is a UUID equal to `sessionRef`; the binding row exists at create with genesis `Fresh`; the snapshot before the first send is 200 and empty; the first send's init id equals it; there is no `materialized`. Kill and reopen of a zero-turn session. Restart then attach resumes zero-turn with boot `fresh`. A create re-driven after a restart resumes through `lookup_by_create_request_id`. Kilroy variants. Session host: a fresh `start` resolves from `created`. | T2 placeholder-shape tests (`t2-invariants.test.ts`, `harness-06/kilroy-runtime.test.ts`) assert a UUID. `rest_claude_identity.rs`, `freshclaude-identity-persistence-rust.spec.ts`, `unified-agent-names-freshclaude.spec.ts` and `freshclaude-zero-turn-restart-rust.spec.ts` move to create-time identity. Ownership is claimed on D (no `pending-create-` key, no `rekey_starting`). Naming binds at create (kilroy is skipped). | +| C24 | `feat(freshclaude): conversation reset (/clear) re-keys the session` | Simulator `/clear` gives `materialized A→B`, a binding for B `supersedes A`, later frames stamped B, and a ledger that retires the `/clear` command under B. An unannounced init mismatch is logged at error and re-keyed. Real-CLI lane `/clear`. | Init becomes verify-only. The per-init re-adoption (`claude.rs:7806-7812`) is deleted. | +| C25 | `feat(freshclaude): attach acknowledgements go to the requester with effective status (G21, G16)` | Rewritten `claude_session_rebind.rs`: two observers on the durable id both receive status, stream, error, permission and `turn.complete`; an attach mid-thinking acknowledges `running` to the requester only; the other device's streaming is not reset. | `handle_attach(msg, reply)`. `try_rebind_to_live`'s flip is deleted. | +| C26 | `feat(freshclaude): migrate legacy placeholder panes` | A client hydration unit test (L1 re-key; L2 untouched). A Rust attach with placeholder plus `sessionRef` gives `materialized`, a resume and the acknowledgement. A kill by placeholder after a restart resolves through the tombstone read. e2e: a seeded legacy pane reloads and works. | – | +| C27 | `refactor(freshclaude): delete placeholder machinery (G32)` | – (pure deletion; suite green) | Deletes `cli_index`, `broadcast_id`, deferred and per-turn adoption, `naming_handles`, `close_pending`, tombstone **writes**, the adoption test seams and their tests. Keeps and tests the tombstone **read**. | + +### PR-G: finish + +| # | Commit | Red first | Notes | +|---|---|---|---| +| C28 | `feat(freshclaude): notice for a turn cut off by a server restart (G25 part 2)` | WS: a busy session, then shutdown, then a new state on the same ledger, then attach: `TURN_INTERRUPTED_BY_RESTART` once, with no edge. An idle session at shutdown gets no notice. | §5.10 (client plan I-5). | +| C29 | `test(freshclaude): complete the real-CLI WS lane and the alert e2e matrix (G04)` | §11.5 and §11.6 | – | +| C30 | `test(freshclaude): retire the protocol-level fakes (G04)` | – | Move the remaining in-crate tests and e2e specs (including the API plan's) to the real sidecar plus simulator. Delete `FAKE_CLAUDE_SIDECAR_SOURCE` (`claude.rs` ≈11271), `test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs` and `monotonic-clock.mjs`. A guard test fails if any test spawns a claude sidecar other than `index.mjs`; the `exec-profile` and crash-probe fixtures are exempt. | +| C31 | `docs: freshclaude identity, alerts, stuck and fork` | – | Rewrite the claude sentences in the AGENTS.md "Agent Status Indicators" section (Rust-minted edges, single-target Stop latch, EOF death, deadman with the heartbeat cap, recovery stop, `exited` and Resume). Rewrite the sidecar protocol header. Add the fork affordance on a freshclaude turn to `docs/index.html`. | + +--- + +## 11. Test plan + +### 11.1 Sidecar unit tests (vitest; real `index.mjs` over the simulator unless pure) + +- **Pure modules.** + - `bootOptions`: fresh, resume and fork map to the right options, and resume never sets `sessionId`. + - `classifyBootError`: the exact T4/T5 strings, plus an unknown string. + - `resultErrorText`: diagnostics are stripped. + - Mapper functions: one case per SDK message kind. +- **Boot.** + - `created` arrives only after `initializationResult()`, with `cliPid` and `protocol: 2`. + - A refused boot produces `create.failed{code}`, **no `sdk.result`**, and exit 2. + - The commands catalog comes from the init result and is published only on change. + - A second `create` is refused. +- **Frames.** + - The uuid is stamped on every send, and the checkpoint attempt reuses it. + - Lifecycle, `replayed`, session state, `status null` and heartbeats are forwarded; heartbeats are rate-limited. + - `conversation_reset` becomes `sdk.session.rekeyed`. + - A bad image produces `sdk.send.rejected` and the process survives. + - From C18, **no** `sdk.turn.complete`, `sdk.turn.waiting`, `turnFailure` or idle frame is ever emitted. +- **Stream end.** A crash, error or EOF produces `sdk.stream.ended` and exit 3. Abort, shutdown or stdin close exits 0 with nothing alert-related. +- **Interrupt.** The settle echoes `stopId` and `stillQueued`. Pending cards are cancelled with deny. + +### 11.2 Rust unit tests + +- **`ClaudeTurnLedger`:** + - a table test per recorded trace (H, I, J, K, L, M, N; `/clear`; CLI turn); + - idle reconciliation; + - lifecycle and turn-evidence fallbacks; + - the head-retire fallback; + - unknown uuids are ignored; + - undo of an arm; + - EOF `owed`; + - property tests (§C17). +- **`StopLatch`:** + - target selection (Started, else oldest Accepted/Queued, else CliTurn, else no-op); + - case A and case B in both arrival orders; + - the re-send cap; + - `ok:false` disarms; + - clearing on retire, idle and EOF; + - an older CLI with no lifecycle. +- **Result verdict:** every row of §5.3, including the non-target command that rings. +- **`AttentionClock`:** strictly increasing across result, death and deadman mints within one millisecond and after a backward clock step; it carries across rekey and death/resume. +- **`decide_boot`:** every row of §4.2, including Unreadable giving Transient and each typed-retry rule. +- **Genesis:** lazy clearing (cleared only after the file is observed; kept across a crash in the flush window). +- **Snapshot source:** `Fresh`, `Fork` with and without `at` (raw chain, sidechain and compaction lines), revision floors. +- **Anchor predicate:** real CLI lines for prompt, ``, stdout/stderr wrapper, task notification, meta and compact summary. +- **`effective_status`:** precedence. +- **Deadman:** arm, feed, pause, fire; a stale generation never fires; the heartbeat cap; the env windows. +- **Writer:** timeout kills the runtime; order is kept per session. +- **Stderr logger:** levels, truncation, rate limit, hosted redaction. + +### 11.3 Rust integration tests (`crates/freshell-ws/tests`; RealSidecarSim unless noted) + +- `freshagent_claude_identity.rs` (C23, C26) +- `freshagent_claude_turns.rs` (C16–C18): absorbed and queued sends, every Stop shape, CLI turn, API error, waiting edge, `submittedTurnId`, status pushes, `sessionBusy`, turn events +- `freshagent_claude_lifecycle.rs` (C08–C10, C19–C22, C28): CLI death, Node death (busy and idle), mid-compact death, dead snapshot, transparent resume, writer isolation, control failures, deadman, recovery stop, restart notice, boot failures +- `freshagent_claude_rollback.rs` (rewritten, C13) and `freshagent_claude_fork.rs` (C14) +- `claude_session_rebind.rs` (rewritten, C25) +- `freshagent_claude_attach.rs`, `freshagent_claude_kill_interrupt.rs`, `freshagent_claude_kill_after_restart.rs`, `claude_restore_unavailable.rs`, `rest_claude_identity.rs` (moved to the rig and updated where noted) +- `pane_reconcile_freshagent.rs`: zero-turn and pending-fork presence (C12) +- Session host: claude start (resume and fresh) resolves from `created`; fork routing (C10, C14, C23) + +### 11.4 Real-CLI contract tests (vitest, default suite, `test/integration/providers`) + +- `claude-cli-contract.test.ts`: T1–T19 as named facts, so a CLI upgrade that breaks one fails by name. +- `claude-cli-conformance.test.ts`: the same scenarios on `real` and `sim`, compared as normalized traces. +- `claude-checkpoint-uuid-replay.test.ts`: unchanged. + +These are the upgrade gate for any pinned-CLI bump: a failure means the simulator or the design changes first. + +### 11.5 Real-CLI Rust lane (`freshagent_claude_real_cli.rs`, RealSidecarCli) + +This lane drives the real `index.mjs` and the pinned CLI against the local fake endpoint. It is run by rust-gate. It never contacts the real API: `ANTHROPIC_BASE_URL` points at localhost and the key is a dummy. It fails, rather than skips, when the CLI is missing. + +1. Create, send, one `turn.complete`. +2. A warm Stop is silent, with no banner. +3. A cold Stop (create with an initial prompt, Stop at 0 ms) is aborted and silent. +4. An absorbed send (`TOOL:sleep=3`) from a second connection leaves both idle; the next turn rings. +5. Undo, send, redo; undo twice without a send. +6. Fork at a turn, then send. +7. `/clear`, then send (re-keyed). +8. SIGKILL of the CLI mid-turn: one ring, `exited`, Resume. +9. A hung stdio MCP tool reaches `stuck` plus one ring (short W and C). +10. Zero-turn restart (boot `fresh` with the same id). + +### 11.6 Browser e2e (Playwright `rust-chromium`; real `index.mjs` plus simulator; parametrized over freshclaude and kilroy; none in `CLOUD_SKIP_SPECS`) + +New `freshclaude-alerts-rust.spec.ts`. Each lane asserts `turnCompletion.seq`, the tab highlight, the sidebar row mark, the pane-header mark and the ring count (through the existing ring-queue test hook): + +| Lane | Expected | +|---|---| +| Finish (background) | seq +1, every mark, one bell | +| Error (`error_max_turns`) | Same as finish, plus a clean banner | +| API error | One bell, no banner (the transcript shows the error) | +| Waiting | One bell. freshclaude uses an approval in default mode; kilroy uses a question under bypass. | +| Crash mid-turn | One bell, the ended card with Resume, and Resume restores a working composer with no reload | +| Stuck (short window) | The amber card plus one bell; "Restart sidecar" recovers; no stuck card while an approval is pending | +| User Stop (watched and background) | No seq change, **no "Agent error" banner**, no mark; the pane returns to idle | +| Cold Stop (pane created with an initial prompt) | Silent, and no assistant text after the Stop | +| Two pages, second page sends during a tool | Both panes return to idle; the next turn rings once per page | +| Watched finish | A tab-strip-only mark, no sound | + +Updated specs: +- `fresh-agent-error-turn-attention.spec.ts` +- `fresh-agent-control-rust.spec.ts` +- `fresh-agent-rollback-rust.spec.ts` (undo, redo, undo twice, undo then restart) +- new `freshclaude-fork-rust.spec.ts` +- `freshclaude-zero-turn-restart-rust.spec.ts` +- `freshclaude-restart-parity-rust.spec.ts` +- `freshclaude-identity-persistence-rust.spec.ts` +- a legacy-migration lane (seeded placeholder pane) + +All of them must pass on the configured `FRESHELL_E2E_BACKEND` before any PR is filed. + +### 11.7 Client unit tests + +- **`FreshAgentView`:** + - Resume appears for claude/kilroy when exited. + - `restartStuckSidecar` sends recovery stop for claude and falls back on `UNSUPPORTED_CAPABILITY`. + - A failed claude send restores the composer. + - Fork affordances follow `capabilities.fork`. +- **`freshAgentSlice`:** `sessionBusy` keeps `running`; `exited` replaces `compacting`. +- **Hydration:** the placeholder migration. +- **`fresh-agent-snapshot-thread`:** a UUID `sessionId` from create resolves the thread id. + +--- + +## 12. Every judge must-fix, and where it is resolved + +| Must-fix (judge) | Resolution | +|---|---| +| Deadman fed forever by tool heartbeats (J1); 1-hour cap too long (J2) | A 12-minute heartbeat cap, so a hung MCP tool is flagged at about 22 minutes. A real-CLI hung-MCP lane (§5.6, C22, §11.5 item 9). | +| Stop re-send for both receipt shapes and either arrival order; no contract test pins `[]` (J1, J2) | §5.4 cases A and B; C01 asserts outcomes only; C18 uses simulator knobs for both shapes. | +| Stop must not abort another device's queued send (J2) | Single target (§5.4); C18 test. | +| /clear crash (J2) | §4.6 rekey; C24; real-CLI lane item 7. | +| Hosted session host waits for init (J1) | `created.sessionRef` / `created.sessionId`; the bridge emits `Started` once; C10/C23 tests (§7.3). | +| Keep the persisted alias-tombstone read path (J1) | L3 (§4.7); C26/C27 tests. | +| No 10-minute in-memory forward; resolve through the ledger chain (J1) | §4.5 and §4.8; C13 parked-send test. | +| Drops-turn refusal must not retry without the guard (J1, J2) | `ROLLBACK_REFUSED`, with A still live (§4.2); C13 test. | +| Boot-refusal result before `initializationResult` rejects (J1, J2) | §4.2 "Boot results never reach the turn machinery"; C10 tests on both sides. | +| Reconcile turns zero-turn/pending-fork ids into dead or fresh after a restart (J1, J2) | §4.4 genesis presence rule; `has_live_session` is Live-only; C12 tests plus the C13 and C23 restart tests. | +| Genesis cleared before the transcript is flushed (J1) | Lazy clearing after the file is observed (§4.1). | +| EOF double ring (J1); C08 interim double ring (J2) | A single minter, retire-at-result, and `turn_open` closed at the result (§5.2, §5.5). Interim rule in §5.7; C08 asserts exactly one ring per phase. | +| `publish_status` versus the client's forced idle (J2) | `sessionBusy` plus a forced re-broadcast after a result (§5.2); C17 test. | +| Hosted recovery stop refused by the proxy (J2) | Client fallback to kill plus resume; hosted test in C22 (§8). | + +--- + +## 13. Per-gap table (all 32 audit gaps) + +| Gap | Sev. | Owner | Resolution | Commit(s) | +|---|---|---|---|---| +| G01 undo/redo wait for an init that never comes | high | core | A boot-verified fork runtime with a child UUID and `Fork` genesis; spawn before teardown; `ROLLBACK_REFUSED`; source collapse; restart-safe through reconcile; anchors fixed | C10–C13 | +| G02 zombie session when the SDK stream ends | high | core | The sidecar exits on stream end; the Rust EOF path runs; the dead record leads to transparent resume | C08, C09, C20 | +| G03 red banner on every Stop | high | core | Interim: the sidecar suppresses `turnFailure` for an interrupted turn and strips diagnostics. Final: Rust verdict (§5.3) | C05, C18 | +| G04 fakes diverge from the real CLI | high | core | Contract suite, simulator, real `index.mjs` everywhere, real-CLI Rust lane, kilroy and crash bell e2e, fakes deleted | C01–C04, C29, C30 | +| G05 dead pane looks usable; no Resume; `compacting` stays | med | core | `exited` status, dead record, Resume, composer restore, transparent resume | C09, C20 | +| G06 cold-start Stop misses and swallows the bell | med | core | Interim: only an aborted result is silenced. Final: single-target latch with the re-send rule | C06, C18 | +| G07 no stuck detection | med | core | Shared deadman with pause and heartbeat cap; `stuck` plus a ring; recovery stop; client restart and fallback | C15, C22 | +| G08 absorbed sends wedge busy | med | core | Per-command ledger; `sessionBusy`; single-target Stop | C16, C17 | +| G09 no fork | med | core | `handle_fork` with an eager boot-verified child; routing; capability; session host | C14 | +| G10 no live in-turn rendering | med | transcript plan | – (core provides N1–N6) | – | +| G11 saved default model ignored | med | client-alerts plan | – | – | +| G12 dropped SDK message types | med | transcript plan (client plan takes the permission-mode part) | Core forwards `sdk.status null` and `compactResult`, and turns retries into heartbeats. Banner policy in §5.3. | C16 | +| G13 transcript projection gaps | med | transcript plan | Core shares the anchor predicate | C13 | +| G14 stderr discarded; edges not logged | med | core | `log.mjs`, stderr logger, edge and decision logs | C07, C18 | +| G15 resume paths drop plugins and hostedMcp | med | core | One `create_request()` builder; plugins persisted | C11 | +| G16 second-device attach redirects broadcasts | med | core | One durable id from create; no stamp flip; legacy migration | C23, C25, C26 | +| G17 mobile has no attention mark | med | client-alerts plan | – | – | +| G18 REST/MCP cannot drive freshclaude | med | API plan | Core provides I1–I7 | C11, C16, C18, C20 | +| G19 no Freshell MCP in freshclaude panes | low | API plan | Core provides I4 | C11 | +| G20 approval suggestions dropped | low | client-alerts plan | The simulator supports I-3 | C02 | +| G21 stale idle in acknowledgements | low | core | Effective status everywhere; `running` at accept; acknowledgement to the requester only | C17, C25 | +| G22 invisible control failures | low | core | Nested errors with requestIds; configure on a dead id is stored | C09, C21 | +| G23 result+idle pair assumption | low | core | Latch deleted; ledger plus stream end | C08, C17 | +| G24 two alert clocks | low | core | One `AttentionClock` per record, used by every mint | C15, C18 | +| G25 alerts lost while disconnected; restart notice | low | client-alerts plan (part 1); **core** (part 2) | Part 1 uses the I-1/I-2 observer hooks. Part 2 is §5.10. | C07, C18, C28 | +| G26 bell queue and hidden-pane approvals | low | client-alerts plan | – | – | +| G27 global mutex held across stdin writes | low | core | Per-session writer with a timeout | C19 | +| G28 transcript locator IO and Windows home | low | API plan (PR1, ideally before C11) | Core consumes `TranscriptLocation` (I8) | C11 | +| G29 hosted configure routing | low | API plan | Core configure semantics apply in the host | – | +| G30 catalog probe environment | low | API plan | – | – | +| G31 teardown and process tree | low | API plan | Core's silent requested exit composes with their graceful wait; `claude_spawn.rs` is theirs | – | +| G32 placeholder-id complexity | low | core | Mint at create, genesis, establish path, verify-only init, rekeys, deletion | C10–C13, C23–C27 | + +--- + +## 14. Risks and containment + +| Risk | Containment | +|---|---| +| **Undocumented CLI signals.** `command_lifecycle` is @internal, and `session_state_changed` needs an environment flag. | Both are pinned by the contract suite, which is the upgrade gate. Fallbacks: replay for lifecycle; turn evidence for session state; head-retire for unknown commands. The deadman and the idle reconciliation backstop any wedge. Every fallback is logged. | +| **A mistaken Stop re-send** (case B) aborts the next queued command | Narrow (it needs another device's queued command to start within milliseconds), capped at 2, and logged. The aborted command is not a target, so it rings (§5.3). No alert is lost. | +| **Deadman false positives** (a legitimate MCP tool longer than about 22 min) | The window and cap are env-tunable. Activity clears `stuck`. Restart recovers. A later real result rings its own edge. | +| **Ledger compatibility** (new binding fields, old rows, downgrade) | `plugins`, `genesis` and `turn_interrupted_at` are serde-optional under `LEDGER_VERSION` 1. `BindingRow` does not deny unknown fields. A downgraded server ignores the genesis, so pending forks and zero-turn ids become recreatable rather than resumable; transcript-backed sessions are unaffected. The rollback record schema is unchanged. | +| **Restart mid-operation** | Create: the binding is written only after the boot verdict; a re-driven create maps through `lookup_by_create_request_id`. Rollback: the record is pre-written, and the swap batch is atomic. Pending forks re-boot from the genesis (T6), and zero-turn ids from `Fresh`. | +| **Two CLIs alive during a rollback or fork** | The original is idle under `turn_lock`. The fork writes nothing until its first prompt. Ownership keys and leases differ. The original is torn down before the child can receive a send. | +| **Boot handshake latency** (+0.6–1.5 s per create, resume, rollback or fork) | The pane already shows "starting". In exchange, create failures are truthful (bad `CLAUDE_CMD`, missing transcript), and most of the cold-Stop window disappears. | +| **Stop re-send latency** (a Rust round trip) | A few ms, against ≥30 ms of turn warm-up (P4: started, then requesting 32 ms later, then replay 80 ms later). | +| **Refactoring a 24k-line file** (ownership fences, kill races) | New logic goes into small modules. Guards are moved, not rewritten, and the existing race tests run against `establish_runtime`. Deletions (C17, C18, C27) come only after the replacements are green. PRs ship independently. | +| **Merge friction with the three workstreams** | Interface contract in §9. The shared mapper module with one function per kind. Ownership of each sidecar message kind is stated (N6). | +| **Kilroy differences** | Same runtime. Naming is skipped at the create-time bind. Approvals are rare under bypass, so kilroy's waiting tests use questions. Every alert and lifecycle e2e lane is parametrized over kilroy. | +| **Simulator drift** | The simulator is valid only while the conformance suite is green. Any new behavior the design relies on gets a contract scenario first. | + +--- + +## 15. Decisions for the product owner + +1. **Stop stops the running turn only.** Messages another device queued behind it still run and ring. This is today's semantics and codex's. "Stop everything" would need the CLI's `cancel_queued`, which the SDK's public `interrupt()` does not expose. +2. **Undo refuses honestly when Claude folded another message into the turn being undone.** It is not retried without the guard, which would silently drop the folded message. +3. **Stuck defaults:** 10 minutes of silence, and tool heartbeats trusted for up to 12 minutes, so a hung MCP tool is flagged at about 22 minutes. Both are env-tunable. +4. **Pane creation, resume, undo and fork wait about 0.6–1.5 s for the CLI to confirm it booted.** In exchange, failures are truthful and early Stops are reliable. +5. **Fork opens a live child right away,** like codex and opencode. It does not wait for the child's first message. diff --git a/docs/plans/2026-10-22-freshclaude-transcript-live.md b/docs/plans/2026-10-22-freshclaude-transcript-live.md new file mode 100644 index 0000000000..594512ca83 --- /dev/null +++ b/docs/plans/2026-10-22-freshclaude-transcript-live.md @@ -0,0 +1,315 @@ +# Plan: freshclaude/kilroy transcript and live rendering (audit gaps G10, G12, G13) + +Repo: Freshell. Worktree: `/home/dan/code/freshell/.worktrees/freshclaude-parity` (branch `feat/freshclaude-parity`, identical to `origin/main` 1c7e4dff7 when this plan was written). This document is design only; no repo file was changed. + +Scope: freshclaude and kilroy (provider `claude`, one runtime). Shared code changes only where a freshclaude problem is best fixed there. No new codex/opencode features. The core-runtime workstream (G01–G09, G14–G16, G21–G24, G27, G32) is planned separately; section 7 lists the interfaces between the two. + +--- + +## 1. Summary + +Today a visible freshclaude pane shows a running turn only through a 3-second poll. Streamed text is folded into Redux but never rendered, and every token rewrites the `freshAgent.sessions` map that six app-wide components subscribe to. The sidecar drops retries, rate limits, compaction failures, permission-mode changes and tool results. The snapshot drops compaction markers, subagent work, diffs, images, command output and token usage. + +The plan has four parts: + +1. **One projection for durable content (Rust).** The Claude transcript file stays the single source of truth for anything the CLI persists. `claude_snapshot.rs` gains a stateful projector that emits the item shapes codex/opencode already use: `context_compaction`, `task_delegation` (with activity rows read from the subagent transcript), turn `error`, `[image]` placeholders, slash-command output, a `diffs` summary built from `structuredPatch`, and `tokenUsage` from the same parser the session indexer uses. +2. **Per-message invalidation (sidecar → Rust → client).** The sidecar announces every message the CLI will persist (`sdk.session.changed {reason:'transcript', uuid}`). The client already refetches on that event type (250 ms debounce, single-flight). A small landing check re-polls if the CLI's ~100 ms write delay made the refetch miss the line. An incremental parse cache keeps per-message refetches cheap. +3. **A client-only live tail for what the file cannot hold yet.** Streaming text/thinking blocks, a "tool starting" row, and API retries live in a new `freshAgentLive` slice, fed by a 100 ms stream batcher. The tail is rendered by one small component that reuses the transcript's own turn renderer. Token deltas never touch `freshAgent.sessions`. +4. **Status surfaces.** Retries render as the existing `retry` activity row. Compaction success or failure, rate limits and informational messages arrive as a new non-terminal `freshAgent.notice`. Plan-mode exits converge the pane's permission mode through `freshAgent.session.metadata`. + +No schema change is needed in `shared/fresh-agent-contract.ts`. Every new item uses an existing kind and an existing renderer. + +--- + +## 2. Verified current behavior (file:line in the worktree) + +### 2.1 Sidecar (`crates/freshell-claude-sidecar/index.mjs`) +- `handleSdkMessage` forwards only: replayed user frames (for checkpoint identity, :243-263), `system/init` (:265-278), `commands_changed` (:279-285), `status:'compacting'` (:286-287), `compact_boundary` (:288-299, relayed as `sdk.compact_boundary`, which Rust consumes and never broadcasts), `assistant` (:302-312), `result` (:314-339) and `stream_event` (:340-342). Everything else falls into `default` and is dropped (:344-346). +- Dropped as a result: `system/api_retry`, `rate_limit_event`, `status` frames carrying `compact_result`, `compact_error` or `permissionMode`, non-replay `user` frames (tool results, compact summary), `task_*` system messages, `informational`. +- The `assistant` frame is reduced to `{content, model}` with no `uuid`, `message.id` or `parent_tool_use_id` (:303-311). +- Every `stream_event` is forwarded verbatim, including subagent (`parent_tool_use_id`) events, `input_json_delta`, `signature_delta`, `message_delta` and `message_stop` (:340-342). `includePartialMessages: true` (:447). +- `resultErrorMessage` returns `undefined` for `subtype:'success'`, so a success result with `is_error:true` (API error) gets no banner (`session-settings.mjs:47-48`). + +### 2.2 Rust (`crates/freshell-freshagent/src/claude.rs`, `claude_snapshot.rs`) +- `normalize_sdk_type` (claude.rs:9338-9362) has no entry for retry, notice or compact frames. `sdk_line_to_frame` returns `None` for unknown types (:9318-9320), so they are never broadcast. +- The consumer (claude.rs:8307 `spawn_consumer`; arms at :8445-8650) folds `last_status` from `sdk.assistant`, `sdk.result` and `sdk.status` (:8666-8676). An `sdk.session.changed` without reason `session-commands` falls through to the broadcast unchanged (:8445-8451), so a new announcement reason needs no Rust change. +- `parse_transcript_turn_indexed` keeps only `type: user|assistant` (claude_snapshot.rs:455-458). It skips `isMeta`, `isSidechain`, `isCompactSummary` and `isVisibleInTranscriptOnly` lines (:460-470). It projects only `text`, `thinking`, `tool_use` and `tool_result` blocks; image blocks hit `_ => {}` (:549). `tool_result_text` keeps text parts only, so image results become `""` (:587-600). +- `build_claude_snapshot_json` hard-codes `tokenUsage` to zero (:1159), `diffs: []` (:1163), `childThreads: []`, `worktrees: []` and `extensions: {}` (:1166). `capabilities.diffs` is absent. +- `get_claude_snapshot` re-reads and re-parses the whole JSONL on the async runtime thread for every GET (:1190-1230). There is no cache. +- `native_history.rs:398-431` reuses `parse_transcript_turn_indexed` per record, with bounded retention. +- `session_metadata_frame` states only `model` and `effort` (`session_metadata.rs:44-63`). + +### 2.3 Client +- `fresh-agent-ws.ts:427-452`: `freshAgent.assistant` goes to `addAssistantMessage`. `freshAgent.stream` goes to `setStreaming(true)` on every `content_block_start`, `appendStreamDelta` on every `text_delta`, and `setStreaming(false)` on every `content_block_stop`. +- `freshAgentSlice.ts:611-652`: all four reducers write into `state.freshAgent.sessions[key]`. No component reads `streamingText`, `session.turns` or `historyItems` (grep: the only readers are the slice and its types). `pane-activity.ts:72` reads `streamingActive` for busy. +- Six components select the whole sessions map, so every token re-renders them: `TabBar.tsx:249`, `PaneContainer.tsx:205`, `MobileTabStrip.tsx:33`, `TabSwitcher.tsx:54`, `ContextMenuProvider.tsx:198`, and `Sidebar.tsx:396,462` (through its selector). +- `FreshAgentView.tsx:148-164`: `freshAgent.assistant` and `freshAgent.stream` are not in `SNAPSHOT_INVALIDATING_FRESH_AGENT_EVENTS`. They are in `TRANSCRIPT_INVALIDATING` (:170-182), which only marks a hidden or already-dirty pane (:3147-3155). A visible pane therefore sees new content only through the 3000 ms busy poll (:4029-4042). `freshAgent.session.changed` IS snapshot-invalidating, and the shared scheduler debounces `event` triggers by 250 ms with single-flight and trailing coalescing (`fresh-agent-snapshot-scheduler.ts:30-34`). +- `turnResult` adds up `costUsd` (`freshAgentSlice.ts:585-597`), but the SDK's `total_cost_usd` is cumulative. +- `sessionMetadataReceived` has no `permissionMode` (`freshAgentSlice.ts:255-287`). The per-send `permissionMode` comes from pane content (`FreshAgentView.tsx:520, 1912, 2051`). So a mode change made by the CLI would be reverted by the next send. +- Renderers that already exist: `context_compaction` card (`FreshAgentItemCard.tsx:476-480`), `task_delegation` block with `↳` activity rows and optional "Open session" (:172-220), `retry` row (:222-229), activity-strip delegation and retry rows plus the "Retrying" reel (`FreshAgentTranscript.tsx:140-160, 666-677`), the durable turn error module (:990-1000), and the diff panel that fetches `git diff` per path (`FreshAgentDiffPanel.tsx:47-58`). +- `tool-preview.ts:1-34` has no case for `Agent`/`Task`, `TodoWrite`, `ExitPlanMode`, `MultiEdit` or `NotebookEdit`; they fall back to `JSON.stringify`. +- `openDelegationSession` hardcodes opencode (`FreshAgentView.tsx:4304-4314`). This is harmless for claude under this plan, because claude delegation items never carry `childSessionId` (section 4.3). + +--- + +## 3. Real-CLI facts this plan depends on (pinned CLI 2.1.237 / SDK 0.3.237, local fake endpoint) + +All probes are new and live under `/tmp/claude-1000/-home-dan-code-freshell/1374fc0b-889d-437e-b369-0aa473c2880f/scratchpad/live-probe/`: +- `probe-tools.mjs [sync] [slow]` +- `probe-compact.mjs ok|fail|retry|plan` +- `probe-image.mjs` + +Plus `/tmp/claude-1000/-home-dan-code-freshell/1374fc0b-889d-437e-b369-0aa473c2880f/scratchpad/verify/probe-apierr.mjs` (run with `PROBE_STATUS=400`). Each run's output was printed to the terminal; the tools and compact probes also save a summary file (`*.out.jsonl`) next to the script. None of them contacts the real API. + +| # | Fact | Evidence | +|---|---|---| +| F1 | An SDK `assistant`/`user` message `uuid` equals the JSONL line `uuid`. The SDK's `message.id` equals the line's `message.id`. | probe-tools: every uuid matched | +| F2 | One SDK `assistant` message per content block (thinking, text and tool_use each separate), all sharing `message.id`. One JSONL line per block. | probe-tools | +| F3 | Tool results arrive live as `type:'user'` (not replay) with `tool_use_result` equal to the JSONL `toolUseResult`. | probe-tools | +| F4 | Write `toolUseResult = {type:'create'\|'update', filePath, content, structuredPatch:[], originalFile}`. Edit `toolUseResult = {filePath, oldString, newString, originalFile, structuredPatch:[{oldStart,oldLines,newStart,newLines,lines:['-one','+two']}], replaceAll}`. | probe-tools | +| F5 | The subagent tool is named `Agent` on the wire and in the JSONL, but `Task` in `system/init.tools`. Live: `task_started`, `task_progress`, `task_updated`, `task_notification`, plus subagent `assistant`/`user` frames with `parent_tool_use_id`. Disk: `///subagents/agent-.jsonl` (lines `isSidechain:true`) and `agent-.meta.json {agentType, description, toolUseId, spawnDepth}`, written at spawn. | probe-tools sync and async | +| F6 | A synchronous Agent result has `toolUseResult = {status:'completed', agentId, agentType, content:[text], totalDurationMs, totalTokens, totalToolUseCount, usage, toolStats}`. An async/background Agent gets `{isAsync:true, status:'async_launched', agentId, outputFile}`. Later a USER line with `origin:{kind:'task-notification'}` and XML content (``, ``, ``, ``) arrives and **starts a new unsolicited turn and result**. | probe-tools (async, the default when the model does not set `run_in_background:false`) | +| F7 | JSONL writes lag SDK emission by **95–130 ms** and land progressively mid-turn, in both the main and the subagent file. | probe-tools `slow` | +| F8 | Successful `/compact`: `status:'compacting'`, then `status:null, compact_result:'success'`, then `system/compact_boundary {compact_metadata:{trigger, pre_tokens, post_tokens, duration_ms}}`, then a non-replay user summary, then a replayed user `Compacted `, then the result. JSONL: `system/compact_boundary` (uuid equals the live uuid; `compactMetadata` in camelCase), a user `isCompactSummary` line, an `isMeta` caveat, a user `/compact…` line and a user `` line. | probe-compact ok | +| F9 | Failed compaction: `status:null, compact_result:'failed', compact_error:'Error during compaction: API Error: 400 …'`, then an `assistant` frame with that text, then `result success`. JSONL: `system/local_command` containing `…` (uuid equals the assistant frame's uuid). "Not enough messages to compact." takes the same path. | probe-compact fail / ok (first run) | +| F10 | `/cost` (recorded as `/usage`) and `/context` arrive live as `assistant` text. The JSONL stores `system/local_command` with ``. `/context` output is markdown (headings and tables). | probe-compact ok, verify/probe-local | +| F11 | `system/api_retry {attempt, max_retries:10, retry_delay_ms, error_status:529, error:'overloaded'}` is live only and never persisted. | probe-compact retry | +| F12 | After an `ExitPlanMode` approval: `system/status {status:null, permissionMode:'default'}`. | probe-compact plan | +| F13 | A user image attachment is stored inline as base64 in the user line. A `Read` of an image gives a `tool_result.content:[{type:'image', source:{base64…}}]` and `toolUseResult {type:'image', file:{base64,type,dimensions}}`. | probe-image | +| F14 | An API 400 gives an assistant line `{isApiErrorMessage:true, error:'unknown', apiErrorStatus:400, content:[text 'API Error: 400 …']}`, then `result {subtype:'success', is_error:true, terminal_reason:'api_error'}`. | verify/probe-apierr | +| F15 | `result.usage` adds up the turn's API calls. `modelUsage[model].contextWindow` is 200000. `getContextUsage()` returns camelCase `{totalTokens, maxTokens, percentage, categories…}`, which differs from the snake_case `SDKContextUsage` typing. | probe-tools | +| F16 | New noise types are emitted: `system/status {status:'requesting'}` before each API call, `command_lifecycle`, `thinking_tokens` and `background_tasks_changed`. | all probes | + +--- + +## 4. Target behavior and design + +### 4.1 Principles +- **P1. The transcript file is the authority for durable content.** Rust projects it with one projector. The client renders snapshot turns. No second projection of durable content exists in the client. +- **P2. The live tail holds only what the file cannot hold yet:** in-flight streaming text/thinking blocks, a "tool starting" row (name only), and the current API retry. Each tail block is bound to its SDK `uuid` and hidden as soon as a snapshot contains that uuid. This avoids flicker and duplicates. +- **P3. Token-rate state never enters `freshAgent.sessions`.** The sessions map changes at most a few times per turn (running, result), not per token. +- **P4. Non-terminal provider events (retry, rate limit, compaction outcome) never end a turn** and never use the error/`sessionError` path, which flips the session to idle. +- **P5. Existing item kinds and renderers only.** No contract schema change. + +### 4.2 Wire contract changes (sidecar → Rust → client) + +| Sidecar frame (stdout) | Rust | Client event | Purpose | +|---|---|---|---| +| `sdk.assistant {sessionId, content, model, uuid, messageId, parentToolUseId}` (fields added) | unchanged forward | `freshAgent.assistant` | Binds a tail block to its uuid | +| `sdk.stream {sessionId, event, parentToolUseId:null, messageId}`. Only top-level events of type `message_start`, `content_block_start`, `content_block_delta` (`text_delta`/`thinking_delta` only) and `content_block_stop`. Subagent, `input_json_delta`, `signature_delta`, `message_delta` and `message_stop` are dropped. | unchanged forward | `freshAgent.stream` | Live tail | +| **new** `sdk.session.changed {sessionId, reason:'transcript', uuid, parentToolUseId}`, once per SDK message the CLI persists: `assistant`, non-replay `user`, replayed `user` prompt or local-command stdout, `system/compact_boundary`, `system/local_command_output` (if a future CLI emits it) | unchanged forward (only `session-commands` is folded) | `freshAgent.session.changed` (already snapshot-invalidating) | Per-message refetch plus landing check | +| **new** `sdk.retry {sessionId, attempt, maxRetries, delayMs, errorStatus, error}` from top-level `system/api_retry` | normalize → `freshAgent.retry`; `tracing::info!` | `freshAgent.retry` | Retry row in the tail | +| **new** `sdk.notice {sessionId, level:'info'\|'warning'\|'error', code, message, rateLimit?}`. Sources: `status.compact_result==='failed'` → `COMPACTION_FAILED`; `rate_limit_event` with `allowed_warning` → `RATE_LIMIT_WARNING` or `rejected` → `RATE_LIMITED` (status `allowed` emits nothing); `system/informational` → `INFORMATIONAL` | normalize → `freshAgent.notice`; `tracing::warn!/info!` | `freshAgent.notice` | Pane notice banner (no idle flip) | +| `sdk.status {status:'running'}` when a `status:null` frame (compaction finished) arrives while `pendingResults > 0` | existing `last_status` fold | `freshAgent.status` | A pane stops saying "compacting" after an auto/manual compaction finishes mid-turn | +| **new** `sdk.session.metadata {sessionId, permissionMode}` when `status.permissionMode` differs from `st.settings.permissionMode`. The sidecar folds it into `st.settings` and `st.permissionMode` first. | consumer folds `permission_mode` into `configuration.settings`, then broadcasts | `freshAgent.session.metadata {permissionMode}` (key absent = "no statement", as for model/effort) | Plan-mode exit converges the pane | +| `session_metadata_frame(..., permission_mode: Option<&str>)`: claude configure/send paths state it; codex/opencode pass `None` | — | `freshAgent.session.metadata` | Multi-device permission-mode convergence | + +Ignored on purpose: `status:'requesting'`, `thinking_tokens`, `command_lifecycle`, `background_tasks_changed`, `task_progress`, `task_started`, `task_updated` and `tool_progress`. Subagent progress reaches the UI through the subagent-file activity rows, which refresh on the `parentToolUseId` announcements. These announcements cover sidechain messages too: the subagent's own assistant and tool-result frames, which carry a `parentToolUseId`. + +### 4.3 Rust: one projector for durable content + +**Layer 1: per-line projection** (`parse_transcript_turn_indexed`, pure; also used by `native_history.rs`): +- `system/compact_boundary` → turn `{role:'system', items:[{id, kind:'context_compaction'}], summary:'Context compacted'}` (codex parity: `contextCompaction` is role `system`, `codex.rs:9411`). +- `system/local_command`, and `user` lines whose whole text is a `` wrapper → role `system`. stdout becomes a `text` item with the wrapper stripped (renders as markdown because `markdown = !isUser`). stderr becomes `items:[]` plus `error:{name:'CommandError', message}`, which uses the existing durable error module. +- `user` lines starting with `` → user `text` item `"/name args"`. +- `user` image blocks → `{kind:'text', text:'[image]'}` (exact codex parity, `codex.rs:26549-26562`). Image parts inside `tool_result` content flatten to `"[image]"`. +- `assistant` lines with `isApiErrorMessage:true` → `items:[]` plus `error:{name:'ApiError', message:}`. The turn is kept even though it has no items. +- `tool_use` named `Agent` or `Task` → `task_delegation {id, status:'running', title, description, subagent, background: input.run_in_background===true}`. The title comes from a shared `task_delegation_title`, extracted from `lib.rs:3888`. +- `user` lines with `origin.kind==='task-notification'` → `None`. Layer 2 consumes them. +- `summarize` gains `context_compaction` → "Context compacted", `task_delegation` → its title, and an error-only turn → the error message (the session-host text capture `render_snapshot_text` falls back to the summary). + +**Layer 2: `ClaudeTranscriptProjector`** (new, `claude_projection.rs`). It is `Clone`, and has `push_line(&Value)` and `finish(&dyn SubagentSource)`. +- An index maps each `tool_use_id` to its item location. +- A `tool_result` for a delegation id folds into the item: `completed` or `failed` (`is_error`); `background:true` plus `running` for `async_launched`; `durationMs` from `totalDurationMs`; `result` from the content text. The `tool_result` item is removed, and its turn is dropped if it becomes empty. Without this, `buildActivity` (`FreshAgentTranscript.tsx:162-188`) would show an orphan "Result" row, because delegation rows are never registered in `toolIndexById`. +- A task-notification line updates the matching delegation's `status` (completed, or failed/stopped → failed), `result` and `durationMs`. +- Edit, Write, MultiEdit and NotebookEdit results with a `toolUseResult` and no `is_error` produce one diff row per path, in a `BTreeMap` (codex `metadata.rs` parity). Fields: `id` and `path` (= `toolUseResult.filePath` or `input.file_path`/`notebook_path`), and `status` = `"created"` for a Write create, otherwise `"+A −D"` summed from the `structuredPatch` lines. Sets `capabilities.diffs = !diffs.is_empty()`. The panel's `git diff` fetch works with these absolute paths. +- Token usage comes from a `ClaudeUsageAccumulator` refactored out of `freshell-sessions/src/parse/claude.rs:495-570` (the indexer's own dedupe and context-window logic), so the snapshot and the context meter share one definition. It maps to `{inputTokens, outputTokens, cachedTokens, totalTokens, contextTokens, compactPercent}`. `costUsd` is omitted because the transcript does not persist it. `getContextUsage()` is deliberately not used: it has different semantics (whole-prompt `percentage` versus the indexer's compact-threshold percent) and costs a control round-trip. +- `recent_line_uuids`: a ring buffer of the last 64 uuid-bearing lines, including dropped ones. It is exposed as `extensions.claude.transcriptLineUuids`, which the client's landing check uses. +- `childThreads` stays `[]`. Claude subagents are not openable sessions, so no `childSessionId` is set and the delegation block shows no "Open session" link. + +**Subagent activity** (`SubagentSource` trait; filesystem implementation in `get_claude_snapshot`): the directory is `/subagents/`. A `meta.json` `toolUseId` maps to its agent file, which covers delegations that are still running (F5). Rows are `{tool, status, preview}` from the agent file's `tool_use` blocks (paired `tool_result` → completed/failed, otherwise running), capped at 200, with the preview from the shared `delegation_activity_preview` (both `file_path` and `filePath` spellings). The opencode code is refactored onto the same helpers, with its tests unchanged. + +**Incremental cache** (`claude_projection_cache.rs`): an LRU of 8 entries keyed by path. Each entry holds the file identity (length, mtime, inode on unix), the parsed offset, a fingerprint of the 4 KiB before that offset, and a projector clone. If the file grew and the fingerprint matches, only the complete lines after the offset are parsed. A shrink, a fingerprint mismatch or a new identity triggers a full parse. An unterminated trailing line is never consumed. Subagent files use the same cache. Parsing runs in `spawn_blocking`. + +### 4.4 Client +- **New slice `freshAgentLive`** (`src/store/freshAgentLiveSlice.ts`, a separate root key so `freshAgent.sessions` keeps its identity). Its state is `bySession[sessionKey] = { blocks: LiveBlock[], retry?, expectedTurnIds: string[], notice?: {seq, level, code, message, rateLimit?} }`, where `LiveBlock = {messageId, index, kind:'text'|'thinking'|'tool_use', text, name?, toolUseId?, uuid?}`. + - Reducers: `streamOpsApplied`, `assistantBlockBound`, `transcriptAnnounced`, `transcriptLanded`, `liveRetrySet`, `liveNoticeReceived`, `liveTailCleared`. + - `extraReducers` mirror `materializeSession`, `removeSession`, `sessionExited`, `markSessionLost` and `setSessionStatus`(exited/stuck). + - Rules: deltas for a block whose start was not observed are dropped (mid-stream attach). A `content_block_start` clears `retry`. Binding goes to the first unbound block of the same `messageId` and kind. +- **Stream batcher** (`src/lib/fresh-agent-stream-batcher.ts`) buffers stream ops per session and flushes every `LIVE_STREAM_FLUSH_MS = 100` with an injectable scheduler. It flushes immediately before `freshAgent.assistant`, `freshAgent.result` and `freshAgent.error`. +- **`fresh-agent-ws.ts` changes:** + - `stream`: dispatch `setStreaming(true)` only on a `content_block_start` while the session is not already streaming (one sessions write per turn), then enqueue the op. + - Remove `appendStreamDelta`, the per-block `setStreaming(false)` and `addAssistantMessage`. + - `assistant`: flush the batcher, then `assistantBlockBound` (top-level only). + - `session.changed` with reason `transcript` and no `parentToolUseId`: `transcriptAnnounced`. + - New cases: `freshAgent.retry` → `liveRetrySet`; `freshAgent.notice` → `liveNoticeReceived`. + - `session.metadata`: pass `permissionMode`. +- **`freshAgentSlice.ts` changes:** + - Delete `streamingText`, `appendStreamDelta`, `clearStreaming`, `addAssistantMessage` and `addUserMessage` (all dead). + - `streamingActive` now means "a stream opened this turn". It is set once and cleared by `turnResult`, `setSessionStatus` (idle/exited/stuck) and `sessionError` as today, so `isFreshAgentBusy` is unchanged. + - `turnResult` assigns `totalCostUsd = costUsd`, because the value is cumulative. + - `sessionMetadataReceived` folds `permissionMode`. +- **`FreshAgentLiveTail.tsx`** (new) selects only its own session's tail. It builds one synthetic turn `{id:'__live-tail', role:'assistant', source:'live', items}`: an optional `{kind:'retry', attempt, error:'overloaded (529) · retrying in 1s'}`, then `thinking`, `text` and `tool_use` items for blocks whose `uuid` is not in `landedTurnIds`. It renders through the transcript's own `buildTranscriptLayout` and `FreshAgentTurnArticle` (exported), so the thinking strip, the SlotReel "Retrying" reel, markdown text and the tool row all look identical to durable content. On growth it calls `requestFollow()` from a new `TranscriptFollowContext`. +- **`FreshAgentTranscript.tsx` changes:** + - New `tail?: ReactNode` slot rendered after the turns. + - `TranscriptFollowContext`: pins to the bottom when `atBottom`. + - Export the article and layout primitives. + - FreshAgentView passes `isStreaming={isBusy && !liveTailActive}` (`liveTailActive` is a boolean selector), so only one live reel shows. +- **`FreshAgentView.tsx` changes:** + - Do NOT subscribe to tail text. + - Add `useTranscriptLandings(sessionKey, snapshot, requestSnapshotRefresh)`. On each committed snapshot it dispatches `transcriptLanded(present = turnIds ∪ extensions.claude.transcriptLineUuids)`. If announced ids remain, it re-polls with trigger `'event'`, at most `LANDING_RECHECK_MAX = 4` times, then drops them (the busy poll and the result refetch still cover). + - `liveTailCleared` when a committed snapshot is idle and the pane is not busy. + - Notice effect: `notice.seq` → `setNotice(message)`, with a locally formatted reset time for rate-limit codes, shown through the existing `FreshAgentApprovalBanner` (:4645). + - Permission-mode convergence: when `agentSession.permissionMode` changes and differs from `paneContent.permissionMode`, dispatch `mergePaneContent({permissionMode})`, so the next send does not re-enter plan mode. +- **`tool-preview.ts`** gains `Agent`/`Task` (description), `TodoWrite` (`N todos` or the in-progress item), `ExitPlanMode` (first plan line), `MultiEdit` (file path) and `NotebookEdit` (`notebook_path`). + +### 4.5 User-visible target behavior (acceptance) +1. Streamed text and thinking appear within about 100 ms of the token, in place, with no flicker or duplicate when the durable turn lands. +2. A tool's name appears when its block starts. Its result row appears within about 250–600 ms of the tool finishing (not up to 3 s). +3. "Retrying (attempt N) — overloaded (529)" shows while the CLI retries and clears on the next output. +4. `/compact`: a "Context compaction" card; the pane leaves "compacting" when compaction ends. On failure, a notice plus a durable red command error. +5. `/cost` and `/context` output render as markdown "System" rows. +6. Agent/Task subagents: a delegation block with live `↳ Tool preview` rows, a spinner while running (including background agents), then a result and duration. No raw tool row and no orphan "Result" row. +7. The diff panel lists files changed by Edit/Write/MultiEdit with `created` or `+A −D`. +8. Image attachments show `[image]`. API errors show the durable error module. +9. After approving ExitPlanMode, the gear shows the new permission mode and the next send keeps it. +10. Token deltas cause no render of TabBar, PaneContainer, MobileTabStrip, TabSwitcher, Sidebar or ContextMenuProvider. +11. Kilroy behaves identically. + +--- + +## 5. File-by-file changes + +**Sidecar** +- `crates/freshell-claude-sidecar/sdk-message-frames.mjs` (new): a pure `framesForSdkMessage(msg, sessionState) → frame[]` plus the stream filter, holding per-session `streamMessageId`. `handleSdkMessage` keeps only the side effects: the turn gate, `pendingResults`, `cancelPending` and `emit`. +- `crates/freshell-claude-sidecar/index.mjs`: delegate to the mapper; fold `status.permissionMode`; structured `logEvent(severity, event, fields)` for retry/notice/compaction; extend the header protocol comment. +- `crates/freshell-claude-sidecar/session-settings.mjs`: unchanged. The API-error banner belongs to the core runtime (G03); the transcript gets the durable error module instead. + +**Rust** +- `crates/freshell-freshagent/src/claude_snapshot.rs`: layer 1 rules; `summarize`; `build_claude_snapshot_json` takes a `ClaudeProjection` (turns, diffs, tokenUsage, `transcriptLineUuids`); `get_claude_snapshot` uses the cache and the filesystem `SubagentSource`; `restored_slice_turns` projects through the projector. +- `crates/freshell-freshagent/src/claude_projection.rs` (new): projector, `SubagentSource`, diff aggregation. +- `crates/freshell-freshagent/src/claude_projection_cache.rs` (new): incremental cache. +- `crates/freshell-freshagent/src/delegation.rs` (new): shared `task_delegation_title`, `delegation_activity_preview` and the row cap. `lib.rs` (opencode) calls them; a pure refactor. +- `crates/freshell-freshagent/src/claude.rs`: `normalize_sdk_type` maps `sdk.retry` and `sdk.notice`; a consumer arm for `sdk.session.metadata` folds `permission_mode`; tracing for retry/notice; configure/send metadata frames state `permissionMode`. +- `crates/freshell-freshagent/src/session_metadata.rs`: optional `permission_mode` (serialized only when `Some`). +- `crates/freshell-freshagent/src/native_history.rs`: no code change (it inherits layer 1); add a test. +- `crates/freshell-sessions/src/parse/claude.rs`: extract `ClaudeUsageAccumulator`; `parse_session_content` uses it (the parity tests in `crates/freshell-sessions/tests/claude_fixture_parity.rs` must stay green). + +**Client** +- `src/store/freshAgentLiveSlice.ts` (new), `src/store/freshAgentLiveTypes.ts` (new), `src/store/store.ts` (register the slice). +- `src/lib/fresh-agent-stream-batcher.ts` (new). +- `src/lib/fresh-agent-ws.ts` (routing as in section 4.4, with typed parsers for the new events). +- `src/store/freshAgentSlice.ts`, `src/store/freshAgentTypes.ts` (removals, `permissionMode`, cost). +- `src/components/fresh-agent/FreshAgentLiveTail.tsx` (new), `FreshAgentTranscript.tsx` (slot, follow context, exports), `FreshAgentView.tsx` (landing hook, notice, permission convergence, `isStreaming` gate, tail mount). +- `src/components/fresh-agent/useTranscriptLandings.ts` (new hook). +- `src/components/fresh-agent/shared/tool-preview.ts`. +- `shared/fresh-agent-contract.ts`: comment only (turn `error` is no longer opencode-only). No schema change. + +**Fixtures and tests** (see section 6) +- `test/fixtures/fresh-agent/claude/real-cli-2.1.237/` (new): captured JSONL plus `subagents/` plus `meta.json` for tools/edits, Agent sync, Agent async with task-notification, compaction ok/fail, `/cost`, `/context`, API error and image; recorded SDK message sequences (`*.sdk.jsonl`) for sidecar tests and the e2e script. +- `test/integration/providers/claude-capture-fixtures.mjs` (new; a manual capture tool reusing the probe approach, local fake endpoint only). +- `test/fixtures/fresh-agent/claude-snapshot-rich-golden.json` (new); `claude-snapshot-golden.json` (regenerated: tokenUsage and extensions). +- `docs/index.html`: add a streaming tail and a delegation block to the freshclaude mock (AGENTS.md rule for significant UI changes). + +--- + +## 6. Ordered TDD commits (each: failing test → minimal code → refactor; tests exercise behavior, none assert on prose or config text) + +### Phase A: durable projection (server); useful on its own after reload +- **A1** Capture the fixtures (no test yet; the fixtures are inputs). RED: `claude_snapshot` unit tests over the real-CLI fixtures: + - a compact boundary yields a `context_compaction` system turn; + - local-command stdout yields system text; + - stderr yields a `CommandError` error turn; + - a `` line yields `/compact`; + - images yield `[image]`; + - `isApiErrorMessage` yields an `ApiError` turn with no items. + GREEN: layer 1. REFACTOR: the `summarize` arms. +- **A2** Extract `delegation.rs` from the opencode code (pure refactor; opencode tests in `lib.rs:11216+` stay green). +- **A3** RED: projector tests for Agent sync (completed, result, `durationMs`, the `tool_result` item absorbed so no orphan row), Agent async (background running, then completed via task-notification; the notification line is not rendered) and a failed Agent. GREEN: `ClaudeTranscriptProjector`; `parse_transcript_turns` and `restored_slice_turns` use it. The rollback tests (`claude_snapshot` rollback tests and `crates/freshell-ws/tests/freshagent_claude_rollback.rs`) must stay green. +- **A4** RED: activity rows from the fixture's `subagents/` (map-backed `SubagentSource` in unit tests, plus one filesystem test); a running delegation (`meta.json`, no result yet) shows running rows. GREEN. +- **A5** RED: the diffs summary (Write create, Edit `+1 −1`, a failed Edit excluded, one row per path, `capabilities.diffs`). GREEN. +- **A6** RED: the snapshot's `tokenUsage` equals the TokenSummary from `freshell_sessions::parse::claude::parse_session_content` for the same fixture (cross-crate parity). GREEN: extract `ClaudeUsageAccumulator` (the indexer's parity suite stays green). +- **A7** RED: `extensions.claude.transcriptLineUuids` holds the last 64 uuid-bearing lines, including absorbed ones. GREEN. +- **A8** Goldens: regenerate the simple golden; add the rich golden (Rust asserts the builder equals the golden; kilroy re-run leg). Extend `test/unit/contracts/rust-claude-snapshot-contract.test.ts` to parse the rich golden with the strict zod schema. RED: a component test rendering the rich golden in `FreshAgentTranscript` (delegation block with `↳` rows, compaction card, error module, system command output, no orphan Result row) and `FreshAgentDiffPanel` rows. GREEN (expected to pass with the existing renderers; any failure is a real rendering gap to fix here). +- **A9** RED: `native_history` read of the rich fixture includes the compaction marker and `[image]`. GREEN (inherited). +- **A10** RED: `tool-preview` cases. GREEN. + +### Phase B: sidecar frames +- **B1** Characterization: unit tests for the new `sdk-message-frames.mjs` that feed recorded real SDK sequences and assert today's frames. Then move the code (pure refactor; the existing `test/unit/claude-sidecar/*` tests stay green). +- **B2** RED: transcript announcements (`uuid`, `parentToolUseId`) for assistant, tool_result, replayed prompt, compact boundary and local command; `sdk.assistant` carries `uuid`, `messageId` and `parentToolUseId`. GREEN. +- **B3** RED: stream filtering (drop sidechain, `input_json_delta`, `signature_delta`, `message_delta`, `message_stop`; stamp `messageId`). GREEN. +- **B4** RED: `api_retry` → `sdk.retry`; `rate_limit_event` warning/rejected → `sdk.notice` (allowed → none); `informational` → notice. GREEN. +- **B5** RED (spawn-level, with the fake query module): compaction failed → `sdk.notice COMPACTION_FAILED`, plus `sdk.status running` only while a send is pending; success → `running`; `permissionMode` fold → `sdk.session.metadata`, and a following `configure` with the same mode applies nothing (a `setPermissionMode` spy is not called). GREEN. +- **B6** Real-CLI contract (new `test/integration/providers/claude-live-frames.{mjs,test.ts}`, copying the setup of `claude-checkpoint-uuid-replay`): spawn the REAL `index.mjs` with `CLAUDE_CMD` set to the pinned CLI and a fake endpoint. Scenarios: text, Write/Edit, Agent sync, retry (529 twice), `/compact` ok and fail, plan exit. Assert the frame sequences, and that every announced top-level uuid appears in the JSONL within 2 s. + +### Phase C: Rust wiring +- **C1** RED (claude.rs unit): `sdk.retry` and `sdk.notice` broadcast as `freshAgent.retry` and `freshAgent.notice` with the broadcast-id stamp; a transcript `sdk.session.changed` passes through; `sdk.session.metadata {permissionMode}` updates `configuration.settings.permission_mode` (asserted through a following `configure_for_send` that sends no configure line for the same mode). GREEN; tracing events added. +- **C2** RED: a configure that changes `permissionMode` broadcasts metadata stating it; codex/opencode frames are unchanged (their existing metadata tests). GREEN. +- **C3** RED, new `crates/freshell-ws/tests/freshagent_claude_live_rendering.rs`, running the real `index.mjs` with a scripted SDK module (section 6, E1 module; `FRESHELL_CLAUDE_NODE=node`). Over WS: stream frames carry `messageId`; `freshAgent.assistant` carries `uuid`; there is one `session.changed{reason:'transcript'}` per message; a mid-turn REST snapshot already contains the delegation item with running activity rows; after the turn, the snapshot carries `diffs` and `tokenUsage`; retry, notice and metadata frames are broadcast. GREEN. +- **C4** RED: projecting the rich fixture from appended chunks (split at every line boundary and mid-line) equals a full parse; rewrite or shrink triggers a full re-parse; an unterminated last line is ignored until complete; subagent file growth is reflected. GREEN: cache plus `spawn_blocking`. + +### Phase D: client live rendering +- **D1** RED (store test): 200 `freshAgent.stream` text deltas through `handleFreshAgentTransportEvent` leave `state.freshAgent.sessions` referentially unchanged after the first `content_block_start`; text accumulates in `freshAgentLive`. GREEN: slice, batcher (fake timers) and ws routing; removals. Delete the tests of the removed reducers (`fresh-agent-ws.test.ts` and `freshAgentSlice.test.ts` cases for `appendStreamDelta`/`addAssistantMessage`) and add their behavioral replacements. +- **D2** RED: a render-count test (React ``) proving TabBar, PaneContainer, Sidebar, MobileTabStrip and TabSwitcher commit zero times across a burst of deltas. GREEN (expected after D1; if not, narrow the selectors). +- **D3** RED, live-slice reducer tests: uuid binding by `messageId` and kind; landed blocks are removed; deltas without a seen start are dropped; retry is cleared on block start; tails re-key on `materializeSession` and clear on exit/lost/kill. GREEN. +- **D4** RED, `FreshAgentLiveTail` component tests: streaming markdown text; a live thinking strip; a tool-starting row; the "Retrying" reel; blocks hidden when their uuid is in `landedTurnIds`; `requestFollow` called on growth; and FreshAgentTranscript pins to the bottom only when at the bottom. GREEN. +- **D5** RED (FreshAgentView tests): a claude pane refetches within `SNAPSHOT_DEBOUNCE_MS` after `session.changed{reason:'transcript'}`; the landing hook re-polls when an announced uuid is missing and stops after `LANDING_RECHECK_MAX`; the tail is cleared on an idle snapshot commit; FreshAgentView itself does not re-render per delta (Profiler). GREEN. +- **D6** RED: `freshAgent.notice` shows the banner without changing session status (it stays running); the rate-limit reset time is formatted; `COMPACTION_FAILED` is shown. GREEN. +- **D7** RED: `session.metadata {permissionMode:'default'}` updates pane content, and the next `freshAgent.send` carries `permissionMode:'default'`. GREEN. +- **D8** RED: `turnResult` with cumulative `costUsd` assigns rather than sums. GREEN. +- **D9** jsdom flow test `test/e2e/freshclaude-live-rendering-flow.test.tsx`: real store, real ws handler and a mocked snapshot API. Stream text appears, then the snapshot containing the uuid commits, and the text is shown exactly once throughout (no duplicate and no gap frame). + +### Phase E: browser e2e (rust-chromium, local and cloud) +- **E1** `test/e2e-browser/fixtures/providers/claude-sdk-script.mjs`: a scripted `query()` module for the REAL sidecar. It replays the recorded real-CLI sequences with timing (token pacing, tool latency, 529 retries) and appends the matching JSONL lines with about 100 ms lag, as in F7. Plus a harness helper to launch with `FRESHELL_CLAUDE_SIDECAR=/crates/freshell-claude-sidecar/index.mjs` and `FRESHELL_CLAUDE_SDK_QUERY_MODULE=