diff --git a/AGENTS.md b/AGENTS.md index 36f1316bef..963efb8523 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,7 +281,7 @@ stderr note). Provisioning, rotation, and revocation live in **Rename scope contract:** Claude, Codex, and OpenCode coding-agent sessions (terminal CLI panes and fresh* panes) have ONE durable saved name per session, shared by the pane header, the tab (when it takes its name from the session), the sidebar/history rows, the terminal presentation, and every API projection; renaming from any surface (UI inline editors and menus, the session/terminal/pane/tab routes, CLI verbs, MCP actions) writes that same record everywhere, automation defaults to a non-permanent automatic suggestion, and only Rename is exposed (no generate/reset/custom-label control). Everything else keeps the legacy four-scope model (layout-local pane/tab labels, terminal-scoped titles, provider-specific session overrides). The unified contract, native sync status semantics (finite unsynced recovery; no native human-intent provenance; serial generation queuing), and the migration recovery procedure (immutable `name-migration-v1` evidence + a deliberate Rename; never reactivate aliases) live in [docs/development/rename-scope-contract.md](docs/development/rename-scope-contract.md). -**Agent Status Indicators:** Blue/busy status is derived from provider activity slices through `resolvePaneActivity`; the unified needs-attention signal (green highlight + one bell) flows through `recordTurnComplete`/`recordTerminalIdle` and `useTurnCompletionNotifications`. The signal is server-authoritative everywhere and outcome-uniform: any turn end the user did not witness — finished, errored, max-turns, crashed (sidecar death with a turn in flight), mid-turn stream exception, wedged/stuck (codex deadman), or waiting on an approval/question — produces the identical attention treatment (tab highlight, sidebar row highlight, pane-header mark, and a single bell), with no distinction by outcome; only a USER-initiated interrupt is silent. Terminal CLI panes keep today's behavior unchanged (a per-event source discriminator partitions the hook — `recordTerminalIdle` stamps `source:'terminal'`, `recordTurnComplete` stamps `source:'freshAgent'`): terminals mark tab+pane attention on the truly-idle `terminal.idle` edge, suppress only the sound when watched, and coalesce a batch into one chime, while fresh-agent panes (freshclaude/kilroy/freshcodex/freshopencode) ring a discrete `freshAgent.turn.complete` edge emitted for every turn end except a user-initiated interrupt — freshclaude/kilroy for ANY SDK `result` subtype unless the sidecar's interrupt gate holds a pending accepted user-interrupt mark (armed only while a result is pending, set before the interrupt RPC, disarmed on RPC failure, consumed by the interrupted turn's own result — ordering-scoped, deliberately no reset-at-send), plus the consumeStream-finally mint when a pending turn ends with no result frame (stream exception or natural EOF) and a Rust-synthesized edge on an unrequested sidecar death mid-turn (requested teardown and idle deaths stay silent); freshcodex for `completed`/`failed`/absent statuses and non-user `interrupted` (the interrupt lane arms a per-session marker before the RPC and disarms it on RPC failure; the marker is consumed by the matching `interrupted` completion and cleared at the next `turn/started` — the opencode discipline, where `turn_aborted` clears at every turn dispatch — so only user-caused interrupts are silent); freshopencode for every settle except `turn_aborted`. Every fresh-agent edge carries a finite numeric strictly-monotonic per-session `at` (the client silently drops a malformed `at`) and folds in via `applyFreshAgentCompletion` under the `at`-monotonic dedupe regime (wall-clock `at`, no per-session counter, so a resumed durable session can't swallow completions across a server restart — a real restart also clears the per-terminal baselines). Attention flags are no longer persisted across reloads: a reload witnesses nothing (idle/busy state rehydrates; no highlight or bell ever replays a pre-load turn end). A watched fresh-agent ending (window focused + tab active) sets a tab-strip-only watched mark (`watchedCompletionByTab` — no sidebar row, no pane-header mark, no sound) that clears when the user navigates away and back. The fresh-agent bell rings audibly once per event through a serialized unbounded ring queue (a fresh chime per ring; the next ring starts when the previous chime ends or is paused, or the built-in fallback tone completes; an unplayable or aborted chime file substitutes a quiet built-in tone; a 5 s per-ring watchdog (`RING_WATCHDOG_MS` in `src/hooks/useNotificationSound.ts`, logged as the `notification_ring_watchdog_fired` warn event) releases a ring whose completion signal never arrives, so one dead ring cannot silence later bells; with sound disabled the highlights still appear). The waiting-for-approval edge is ALSO server-authoritative: freshclaude/kilroy emit a discrete `freshAgent.turn.waiting` edge on the 0→≥1 pending permission/question transition, and codex's approval/question controls emit the same edge on their own 0→≥1 pending transition (Claude/kilroy and codex both raise approvals/questions); the client folds it in via `applyFreshAgentWaiting` under a distinct `${provider}:${sessionId}#waiting` dedupe namespace so it can never poison (or be poisoned by) the turn-complete bucket. The fragile client-side busy→idle derivation AND the client-side waiting-edge hook (`useAgentSessionTurnCompletion`) were both removed — all attention edges are server-emitted. freshcodex additionally self-heals a crashed/disconnected codex sidecar by consuming the runtime `onExit` hook in `subscribe()`, broadcasting the terminal `exited` status to clear BLUE while the exit watcher's crash arm rings the same unified edge when a turn was in flight (an idle crash stays silent). freshcodex also runs a wedged-sidecar deadman: after a bounded quiet window (default 10 min, env `FRESHELL_FRESHCODEX_QUIET_WINDOW_MS`) with a turn in flight and no sidecar events, the server stops asserting busy, marks the pane `stuck`, and fires the same unified edge; the client shows an amber "Agent appears stuck" card (`role="alert"`) with "Restart sidecar" (kill + resume re-mint) and "Start new conversation" actions as pane-level inline content. `freshopencode` still runs on a shared long-lived `opencode serve` sidecar and uses server-pushed `session.idle`/`session.status` events to drive busy. A freshAgent.send arriving while a native /compact drive is in flight — or while older queued sends are still pending — is queued server-side (FIFO, immediate send.accepted) and auto-driven when the compact settles; kill drops the queue, and the drain re-validates a captured observed fence through the lane op guard (stale pairs refuse typed, request-correlated), releasing the guard at the prompt's dispatch boundary. The runtime self-heals a shared-daemon death (the 2026-09-20 incident class): daemon loss fans a typed `freshAgent.error{code:"OPENCODE_DAEMON_LOST"}` edge out to exactly one frame per materialized session (the client's generic `sessionError` banner + busy-clear; the typed edge itself carries no chime — a daemon death mid-turn is a crashed turn end and rings the unified edge through the turn's own non-clean settle), the manager respawns the daemon on a backoff ladder (fresh-incident reset, crash-loop escalation), every successful cold start drives a level-triggered revival pass that restarts dead session bridges and pushes `freshAgent.session.snapshot{status:"idle"}` (the client's transcript-refetch trigger) while respecting the ownership coordinator (retired, transitioned, or terminal-owned sessions are never revived), and a generation-fenced `freshAgent.attach` is itself a recovery verb that respawns the daemon before re-bridging. Client-side, a snapshot GET that still answers the typed 409 `RESTORE_UNAVAILABLE` for the pane's own stale fresh-agent claim (e.g. the pane loaded while the server was restarting) does not dead-end on the dismiss-only banner: the pane drives the documented recovery ONCE per pane identity — it refreshes the observed owner fence from the refusal's own `ownerGeneration` (preserving the record's epoch), sends one generation-fenced `freshAgent.attach`, and refetches through the reveal path when reveal-dirty (so the "Refreshing conversation" overlay can clear) or via `manual` otherwise; a suppressed attach restores the once-guard, and repeated 409s fall through to the honest error banner — terminal-owned refusals stay out of this path (their recovery door is the session-directory handoff). Gemini and Kimi terminal modes are status-in... [truncated] Separately, the sidebar shows cross-device remote status rings around a session row's icon: a green ring means the session is open on another device, a blue ring means it is busy on another device (blue wins over green), and rings are suppressed entirely when the session is open on this device (derived from `tabs.sync` registry snapshots — producing clients stamp pane payloads with `sessionKeys`/`busySessionKeys`, consumers re-query remote snapshots on a 30s interval, and the server partitions same-device records into `sameDeviceOpen`, which never produces rings). +**Agent Status Indicators:** Blue/busy status is derived from provider activity slices through `resolvePaneActivity`; the unified needs-attention signal (green highlight + one bell) flows through `recordTurnComplete`/`recordTerminalIdle` and `useTurnCompletionNotifications`. The signal is server-authoritative everywhere and outcome-uniform: any turn end the user did not witness — finished, errored, max-turns, crashed (sidecar death with a turn in flight), mid-turn stream exception, wedged/stuck (codex deadman), or waiting on an approval/question — produces the identical attention treatment (tab highlight, sidebar row highlight, pane-header mark, and a single bell), with no distinction by outcome; only a USER-initiated interrupt is silent. Terminal CLI panes keep today's behavior unchanged (a per-event source discriminator partitions the hook — `recordTerminalIdle` stamps `source:'terminal'`, `recordTurnComplete` stamps `source:'freshAgent'`): terminals mark tab+pane attention on the truly-idle `terminal.idle` edge, suppress only the sound when watched, and coalesce a batch into one chime, while fresh-agent panes (freshclaude/kilroy/freshcodex/freshopencode) ring a discrete `freshAgent.turn.complete` edge emitted for every turn end except a user-initiated interrupt — freshclaude/kilroy for ANY SDK `result` subtype except the stopped turn's own result: the sidecar's interrupt gate silences a result only when it holds a pending accepted user-interrupt mark AND the result is aborted (`terminal_reason` aborted_streaming/aborted_tools), so a Stop that missed its turn (sent while the CLI was still starting) never silences it (the mark is armed only while a result is pending, set before the interrupt RPC, disarmed on RPC failure, and consumed by the next result either way — ordering-scoped, deliberately no reset-at-send; a stopped turn shows no error banner), plus the consumeStream-finally mint when a pending turn ends with no result frame (stream exception or natural EOF; a pending mark never silences it) and a Rust-synthesized edge on an unrequested sidecar death mid-turn (requested teardown and idle deaths stay silent); freshcodex for `completed`/`failed`/absent statuses and non-user `interrupted` (the interrupt lane arms a per-session marker before the RPC and disarms it on RPC failure; the marker is consumed by the matching `interrupted` completion and cleared at the next `turn/started` — the opencode discipline, where `turn_aborted` clears at every turn dispatch — so only user-caused interrupts are silent); freshopencode for every settle except `turn_aborted`. Every fresh-agent edge carries a finite numeric strictly-monotonic per-session `at` (the client silently drops a malformed `at`) and folds in via `applyFreshAgentCompletion` under the `at`-monotonic dedupe regime (wall-clock `at`, no per-session counter, so a resumed durable session can't swallow completions across a server restart — a real restart also clears the per-terminal baselines). Attention flags are no longer persisted across reloads: a reload witnesses nothing (idle/busy state rehydrates; no highlight or bell ever replays a pre-load turn end). A watched fresh-agent ending (window focused + tab active) sets a tab-strip-only watched mark (`watchedCompletionByTab` — no sidebar row, no pane-header mark, no sound) that clears when the user navigates away and back. The fresh-agent bell rings audibly once per event through a serialized unbounded ring queue (a fresh chime per ring; the next ring starts when the previous chime ends or is paused, or the built-in fallback tone completes; an unplayable or aborted chime file substitutes a quiet built-in tone; a 5 s per-ring watchdog (`RING_WATCHDOG_MS` in `src/hooks/useNotificationSound.ts`, logged as the `notification_ring_watchdog_fired` warn event) releases a ring whose completion signal never arrives, so one dead ring cannot silence later bells; with sound disabled the highlights still appear). The waiting-for-approval edge is ALSO server-authoritative: freshclaude/kilroy emit a discrete `freshAgent.turn.waiting` edge on the 0→≥1 pending permission/question transition, and codex's approval/question controls emit the same edge on their own 0→≥1 pending transition (Claude/kilroy and codex both raise approvals/questions); the client folds it in via `applyFreshAgentWaiting` under a distinct `${provider}:${sessionId}#waiting` dedupe namespace so it can never poison (or be poisoned by) the turn-complete bucket. The fragile client-side busy→idle derivation AND the client-side waiting-edge hook (`useAgentSessionTurnCompletion`) were both removed — all attention edges are server-emitted. freshcodex additionally self-heals a crashed/disconnected codex sidecar by consuming the runtime `onExit` hook in `subscribe()`, broadcasting the terminal `exited` status to clear BLUE while the exit watcher's crash arm rings the same unified edge when a turn was in flight (an idle crash stays silent). freshcodex also runs a wedged-sidecar deadman: after a bounded quiet window (default 10 min, env `FRESHELL_FRESHCODEX_QUIET_WINDOW_MS`) with a turn in flight and no sidecar events, the server stops asserting busy, marks the pane `stuck`, and fires the same unified edge; the client shows an amber "Agent appears stuck" card (`role="alert"`) with "Restart sidecar" (kill + resume re-mint) and "Start new conversation" actions as pane-level inline content. `freshopencode` still runs on a shared long-lived `opencode serve` sidecar and uses server-pushed `session.idle`/`session.status` events to drive busy. A freshAgent.send arriving while a native /compact drive is in flight — or while older queued sends are still pending — is queued server-side (FIFO, immediate send.accepted) and auto-driven when the compact settles; kill drops the queue, and the drain re-validates a captured observed fence through the lane op guard (stale pairs refuse typed, request-correlated), releasing the guard at the prompt's dispatch boundary. The runtime self-heals a shared-daemon death (the 2026-09-20 incident class): daemon loss fans a typed `freshAgent.error{code:"OPENCODE_DAEMON_LOST"}` edge out to exactly one frame per materialized session (the client's generic `sessionError` banner + busy-clear; the typed edge itself carries no chime — a daemon death mid-turn is a crashed turn end and rings the unified edge through the turn's own non-clean settle), the manager respawns the daemon on a backoff ladder (fresh-incident reset, crash-loop escalation), every successful cold start drives a level-triggered revival pass that restarts dead session bridges and pushes `freshAgent.session.snapshot{status:"idle"}` (the client's transcript-refetch trigger) while respecting the ownership coordinator (retired, transitioned, or terminal-owned sessions are never revived), and a generation-fenced `freshAgent.attach` is itself a recovery verb that respawns the daemon before re-bridging. Client-side, a snapshot GET that still answers the typed 409 `RESTORE_UNAVAILABLE` for the pane's own stale fresh-agent claim (e.g. the pane loaded while the server was restarting) does not dead-end on the dismiss-only banner: the pane drives the documented recovery ONCE per pane identity — it refreshes the observed owner fence from the refusal's own `ownerGeneration` (preserving the record's epoch), sends one generation-fenced `freshAgent.attach`, and refetches through the reveal path when reveal-dirty (so the "Refreshing conversation" overlay can clear) or via `manual` otherwise; a suppressed attach restores the once-guard, and repeated 409s fall through to the honest error banner — terminal-owned refusals stay out of this path (their recovery door is the session-directory handoff). Gemini and Kimi terminal modes are status-in... [truncated] Separately, the sidebar shows cross-device remote status rings around a session row's icon: a green ring means the session is open on another device, a blue ring means it is busy on another device (blue wins over green), and rings are suppressed entirely when the session is open on this device (derived from `tabs.sync` registry snapshots — producing clients stamp pane payloads with `sessionKeys`/`busySessionKeys`, consumers re-query remote snapshots on a 30s interval, and the server partitions same-device records into `sameDeviceOpen`, which never produces rings). **Fresh-Agent Orchestration:** The Rust REST agent API (`/api/tabs`, `/api/panes/:id/split`, `/api/panes/:id/send-keys`, `/api/panes/:id/capture`, `/api/panes/:id/wait-for`) and the standalone Node MCP client accept `agent`/`model`/`effort` parameters where the Rust contract supports them. The Rust orchestration layer dispatches to the registered fresh-agent runtimes. On MCP `new-tab`, resume sugar (`resume`/`resumeSessionId`) is honored for `agent: "opencode"`; terminal-mode resume uses an explicit provider-matched `sessionRef` (raw Codex resume IDs are rejected because they are not sufficient restore identity). Unsupported legacy actions return a deterministic unavailable result instead of contacting a removed backend route. diff --git a/crates/freshell-claude-sidecar/checkpoint-identity.mjs b/crates/freshell-claude-sidecar/checkpoint-identity.mjs new file mode 100644 index 0000000000..e3d3c7a7bd --- /dev/null +++ b/crates/freshell-claude-sidecar/checkpoint-identity.mjs @@ -0,0 +1,52 @@ +// Checkpoint identity at the SDK replay boundary (pure; used by index.mjs). +// +// A checkpointed send carries a v4 uuid on its SDKUserMessage. The CLI echoes +// a message it takes back as a user row with `isReplay: true` and that uuid +// (the sidecar opts in with `replay-user-messages`). Only that replay binds the +// attempt to its native turn id: assistant, tool and result frames, ordinary +// user rows, and replays of uuids we did not send are never identity evidence. + +export const MAX_PENDING_CHECKPOINT_ATTEMPTS = 128 + +export function isUuid(value) { + return typeof value === 'string' + && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) +} + +/** The checkpoint attempt of a `send` request, or undefined when it is absent or incomplete. */ +export function checkpointAttemptOf(raw) { + return raw && isUuid(raw.uuid) + && typeof raw.logicalAttemptId === 'string' && raw.logicalAttemptId.length > 0 + && typeof raw.requestId === 'string' && raw.requestId.length > 0 + ? { uuid: raw.uuid, logicalAttemptId: raw.logicalAttemptId, requestId: raw.requestId } + : undefined +} + +/** + * Remember an attempt until its replay arrives. The set is bounded: an evicted + * attempt simply never gets an identity, which is never approximated by + * request order or prompt text. + */ +export function rememberCheckpointAttempt(pending, attempt) { + pending.delete(attempt.uuid) + pending.set(attempt.uuid, attempt) + while (pending.size > MAX_PENDING_CHECKPOINT_ATTEMPTS) { + pending.delete(pending.keys().next().value) + } +} + +/** The `sdk.checkpointTurnIdentity` frame this SDK message proves, consuming its attempt; else undefined. */ +export function takeCheckpointIdentity(pending, sessionId, msg) { + if (msg?.type !== 'user' || msg.isReplay !== true || msg.message?.role !== 'user' || !isUuid(msg.uuid)) return undefined + const attempt = pending.get(msg.uuid) + if (!attempt) return undefined + pending.delete(msg.uuid) + return { + type: 'sdk.checkpointTurnIdentity', + sessionId, + logicalAttemptId: attempt.logicalAttemptId, + requestId: attempt.requestId, + nativeTurnId: msg.uuid, + displayTurnId: msg.uuid, + } +} diff --git a/crates/freshell-claude-sidecar/exec-profile.mjs b/crates/freshell-claude-sidecar/exec-profile.mjs index d6aa400bb1..84591e95cd 100644 --- a/crates/freshell-claude-sidecar/exec-profile.mjs +++ b/crates/freshell-claude-sidecar/exec-profile.mjs @@ -4,6 +4,7 @@ import { spawn } from 'node:child_process' import { openSync, readSync, closeSync, statSync, accessSync, constants } from 'node:fs' import { endianness } from 'node:os' import path from 'node:path' +import { usesHostedClaudeRuntime } from './hosted.mjs' const fail = kind => { throw new Error(`provider exec profile ${kind}: repair inputs before dispatch`) } function readBounded(file, limit) { @@ -79,7 +80,7 @@ export function checkSdkExec(options) { } export function managedSdkSpawn(stderr, environment = process.env) { - if (process.platform !== 'linux' || environment.FRESHELL_HOSTED_FRESH_AGENT !== 'claude') return undefined + if (process.platform !== 'linux' || !usesHostedClaudeRuntime(environment)) return undefined return options => { checkSdkExec(options) const child = spawn(options.command, options.args, { diff --git a/crates/freshell-claude-sidecar/hosted-mcp.mjs b/crates/freshell-claude-sidecar/hosted-mcp.mjs index 79ad74ed02..c06fc7abf0 100644 --- a/crates/freshell-claude-sidecar/hosted-mcp.mjs +++ b/crates/freshell-claude-sidecar/hosted-mcp.mjs @@ -1,6 +1,8 @@ +import { usesHostedClaudeRuntime } from './hosted.mjs' + // The managed launch context selects one fixed recipe. Scoped authentication // stays in the inherited child environment and never enters SDK argv/IPC. export function hostedMcpServers(selected, env = process.env) { - if (selected !== true || env.FRESHELL_HOSTED_FRESH_AGENT !== 'claude') return undefined + if (selected !== true || !usesHostedClaudeRuntime(env)) return undefined return { freshell: { command: 'node', args: ['/opt/freshell-mcp/server.js'] } } } diff --git a/crates/freshell-claude-sidecar/hosted.mjs b/crates/freshell-claude-sidecar/hosted.mjs new file mode 100644 index 0000000000..fc7d558528 --- /dev/null +++ b/crates/freshell-claude-sidecar/hosted.mjs @@ -0,0 +1,25 @@ +// hosted.mjs — whether this sidecar runs inside a hosted session host, and +// which hosted rules apply. The ONE reader of FRESHELL_HOSTED_FRESH_AGENT: +// the session host sets it to the provider it hosts, which for this runtime +// is `claude` (freshclaude) or `kilroy` (crates/freshell-supervisor/src/ +// backend.rs). Two rules read it, and they differ on purpose: +// - every hosted sidecar logs CLI output by size only (isHostedSidecar); +// - the managed spawn check and the fixed hosted MCP recipe apply to hosted +// claude alone (usesHostedClaudeRuntime). Extending them to hosted kilroy +// is an explicit item of the api-hosted-ops workstream +// (docs/plans/2026-10-22-freshclaude-api-hosted-ops.md §2.4). + +/** The provider the session host hosts, or undefined outside a session host. */ +export function hostedProvider(env = process.env) { + return env.FRESHELL_HOSTED_FRESH_AGENT || undefined +} + +/** Any hosted sidecar, freshclaude or kilroy: its CLI output is logged by size only. */ +export function isHostedSidecar(env = process.env) { + return hostedProvider(env) !== undefined +} + +/** Hosted claude: the SDK spawn is checked against the managed exec profile and the fixed MCP recipe is offered. */ +export function usesHostedClaudeRuntime(env = process.env) { + return hostedProvider(env) === 'claude' +} diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index fb664b5e96..ad6deddeaf 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -32,20 +32,25 @@ // { type:'sdk.stream', sessionId, event, parentToolUseId } // { type:'sdk.result', sessionId, result, durationMs, costUsd, usage } // { type:'sdk.turn.complete', sessionId, at } // EVERY result (any subtype) except -// the interrupted turn's own (an accepted user interrupt's mark consumes -// it — turn-complete-gate.mjs); also minted from consumeStream's finally +// the stopped turn's own aborted result (an accepted user interrupt's mark +// silences only that — turn-complete-gate.mjs); also minted from consumeStream's finally // when a turn ends with NO result frame (stream exception or natural EOF // with a send still pending) — the abort/sdk.exit requested-teardown path // stays silent // { type:'sdk.turn.waiting', sessionId, at } // 0->>=1 pending edge (written by this sidecar; codex controls emits its own waiting edge outside the sidecar lane) // { type:'sdk.status', sessionId, status } // compacting / idle (stream end) -// { type:'sdk.error', sessionId, message } +// { type:'sdk.error', sessionId, message, turnFailure? } // turnFailure: a failed turn's result +// (sanitized text, no [ede_diagnostic] lines); never for the user's own Stop // { type:'sdk.exit', sessionId } // { type:'sdk.permission.request', sessionId, requestId, subtype:'can_use_tool', tool:{name,input}, toolUseID, suggestions, blockedPath, decisionReason } // { type:'sdk.question.request', sessionId, requestId, questions } // { type:'sdk.permission.cancelled', sessionId, requestId } // { type:'sdk.question.cancelled', sessionId, requestId } // +// sidecar → Rust (stderr): log records only, one JSON object per line +// ({ severity, component, event, ...ids }, log.mjs). Rust re-emits each +// through `tracing` at its severity (claude_sidecar_io.rs). +// // The Rust side normalizes `sdk.* -> freshAgent.*` (a port of // server/fresh-agent/sdk-events.ts) and wraps each in a `freshAgent.event` // envelope — so this sidecar is the faithful analog of server/sdk-bridge.ts's @@ -53,8 +58,8 @@ // // A sidecar death mid-turn can therefore NEVER produce a false completion // from this process's own stdout: `sdk.turn.complete` rides every SDK -// `result` (the unified needs-attention edge — the interrupted turn's own -// result is consumed silently by the accepted-interrupt mark) plus the +// `result` (the unified needs-attention edge — only the stopped turn's own +// aborted result is silenced, by the accepted-interrupt mark) plus the // consumeStream finally mint for a result-less turn end (stream exception or // natural EOF with a send pending; never the aborted/sdk.exit teardown). If // the PROCESS dies mid-turn, stdout simply ends and no completion is ever @@ -75,11 +80,15 @@ import { createInterface } from 'node:readline' import { randomBytes } from 'node:crypto' -import { configureSession, userMessageContent, resultErrorMessage } from './session-settings.mjs' +import { configureSession, userMessageContent } from './session-settings.mjs' +import { resultFrames } from './sdk-message-frames.mjs' import { createTurnCompleteGate } from './turn-complete-gate.mjs' +import { checkpointAttemptOf, rememberCheckpointAttempt, takeCheckpointIdentity } from './checkpoint-identity.mjs' import { nextMonotonic } from './monotonic-clock.mjs' import { managedSdkSpawn } from './exec-profile.mjs' import { hostedMcpServers } from './hosted-mcp.mjs' +import { cliStderrLogger, errorText, log } from './log.mjs' +import { isHostedSidecar } from './hosted.mjs' import { createClaudeSdkCleanEnv } from './sdk-env.mjs' import { canUseTool as routeCanUseTool, @@ -94,13 +103,10 @@ import { const sdkModule = await import(process.env.FRESHELL_CLAUDE_SDK_QUERY_MODULE || '@anthropic-ai/claude-agent-sdk') const { query } = sdkModule -// ── stdout writer (newline-JSON; stderr is the only log sink) ──────────────── +// ── stdout writer (newline-JSON; stderr is the only log sink: log.mjs) ─────── function emit(msg) { process.stdout.write(`${JSON.stringify(msg)}\n`) } -function logerr(msg) { - process.stderr.write(`${JSON.stringify({ severity: 'warn', component: 'claude-sidecar', message: msg })}\n`) -} // ── nanoid()-compatible bare id: 21 url-safe chars ([A-Za-z0-9_-]) ─────────── // Faithful shape of server/sdk-bridge.ts's `const sessionId = nanoid()` (the @@ -237,22 +243,9 @@ function handleSdkMessage(sessionId, msg) { case 'user': { // The SDK replays actual persisted user messages when this query opts in. // Only the unique UUID that this sidecar supplied for a checkpointed send - // can resolve its attempt; assistant/tool/result frames and unrelated - // user rows are never identity evidence. - if (msg.isReplay === true && msg.message?.role === 'user' && isUuid(msg.uuid)) { - const attempt = st.pendingCheckpointAttempts.get(msg.uuid) - if (attempt) { - st.pendingCheckpointAttempts.delete(msg.uuid) - emit({ - type: 'sdk.checkpointTurnIdentity', - sessionId, - logicalAttemptId: attempt.logicalAttemptId, - requestId: attempt.requestId, - nativeTurnId: msg.uuid, - displayTurnId: msg.uuid, - }) - } - } + // can resolve its attempt (checkpoint-identity.mjs). + const identity = takeCheckpointIdentity(st.pendingCheckpointAttempts, sessionId, msg) + if (identity) emit(identity) break } case 'system': { @@ -309,22 +302,14 @@ function handleSdkMessage(sessionId, msg) { // The awaited turn's terminal frame arrived: drop one pending send // (clamped at 0 — an unsolicited result must not underflow the counter). st.pendingResults = Math.max(0, st.pendingResults - 1) - const usage = msg.usage - ? { - input_tokens: msg.usage.input_tokens, - output_tokens: msg.usage.output_tokens, - cache_creation_input_tokens: msg.usage.cache_creation_input_tokens, - cache_read_input_tokens: msg.usage.cache_read_input_tokens, - } - : undefined - emit({ type: 'sdk.result', sessionId, result: msg.subtype, durationMs: msg.duration_ms, costUsd: msg.total_cost_usd, usage }) - const failure = resultErrorMessage(msg) - if (failure) emit({ type: 'sdk.error', sessionId, message: failure, turnFailure: true }) - // Unified attention edge: any turn end rings unless this result is the - // interrupted turn's own (the SDK contract orders the settle receipt - // before it). No outcome rides the wire — the client treats all ends - // identically. - if (st.turnCompleteGate.resultEmitsAttention()) { + // The gate's verdict comes first: the stopped turn's own (aborted) + // result is the user's Stop, so it carries neither a bell nor a failure + // banner (G03). Any other turn end rings, including one a Stop missed + // (G06); a failed one also reports why. No outcome rides the edge — the + // client treats all ends identically. + const rings = st.turnCompleteGate.resultEmitsAttention(msg) + for (const frame of resultFrames(sessionId, msg, { userStopped: !rings })) emit(frame) + if (rings) { const at = nextMonotonic(st.lastTurnCompleteAt, Date.now()) st.lastTurnCompleteAt = at emit({ type: 'sdk.turn.complete', sessionId, at }) @@ -343,11 +328,18 @@ function handleSdkMessage(sessionId, msg) { async function consumeStream(sessionId, sdkQuery) { const st = sessions.get(sessionId) + let failure try { for await (const msg of sdkQuery) handleSdkMessage(sessionId, msg) } catch (err) { + failure = err emit({ type: 'sdk.error', sessionId, message: `SDK error: ${err?.message || 'Unknown error'}` }) } finally { + // A requested teardown (abort) is routine; any other error end is the CLI + // dying or failing under the session. + if (st?.abort.signal.aborted) log('info', 'stream.ended', { sessionId, reason: 'aborted' }) + else if (failure) log('warn', 'stream.ended', { sessionId, reason: 'stream_error', error: errorText(failure) }) + else log('info', 'stream.ended', { sessionId, reason: 'stream_eof' }) // Stream ended (natural end, error, or abort). The transport is closing: // clear any parked cards WITHOUT resolving their promises (LB-04: a late // resolve lands inside the SDK's floating promise chain → unhandled @@ -360,9 +352,10 @@ async function consumeStream(sessionId, sdkQuery) { // witness: ring the unified edge (the abort/sdk.exit path is a // REQUESTED teardown and deliberately stays silent). pendingResults // covers the accept→first-message window turnOpen cannot see, and - // counts queued sends a boolean would lose. The gate consult is LAST in - // the guard so a no-mint path never consumes an interrupt mark. - if (st && st.pendingResults > 0 && !st.abort.signal.aborted && st.turnCompleteGate.resultEmitsAttention()) { + // counts queued sends a boolean would lose. A pending Stop mark does not + // silence it: only an aborted result is the stopped turn's own + // (turn-complete-gate.mjs), and this end has no result. + if (st && st.pendingResults > 0 && !st.abort.signal.aborted) { const at = nextMonotonic(st.lastTurnCompleteAt, Date.now()) st.lastTurnCompleteAt = at emit({ type: 'sdk.turn.complete', sessionId, at }) @@ -413,9 +406,7 @@ function handleCreate(req) { // events (macrotasks) cannot interleave inside that finally. sessions.set(sessionId, state) - const sdkStderr = process.env.FRESHELL_HOSTED_FRESH_AGENT === 'claude' - ? data => process.stderr.write(`${JSON.stringify({ severity: 'warn', component: 'claude-sidecar', event: 'provider.stderr', bytes: Buffer.byteLength(String(data)) })}\n`) - : data => logerr(`sdk stderr: ${String(data).trimEnd()}`) + const sdkStderr = cliStderrLogger(log, { hosted: isHostedSidecar(), fields: { sessionId } }) const sdkQuery = query({ prompt: iterable, options: { @@ -467,14 +458,15 @@ function handleCreate(req) { if (commands === undefined) return state.commandCatalog = commands publishCommands(sessionId, state) - }).catch((error) => logerr(`session commands unavailable: ${error?.message || error}`)) + }).catch((error) => log('warn', 'commands.unavailable', { sessionId, error: errorText(error) })) } // Return the placeholder before the native system/init identity arrives. emit({ type: 'created', requestId, sessionId }) - consumeStream(sessionId, sdkQuery).catch((err) => logerr(`consume error: ${err?.message}`)) + consumeStream(sessionId, sdkQuery).catch((err) => log('error', 'stream.consume_failed', { sessionId, error: errorText(err) })) } catch (err) { if (sessionId) sessions.delete(sessionId) + log('warn', 'create.failed', { requestId, error: errorText(err) }) emit({ type: 'create.failed', requestId, message: err?.message || String(err) }) } } @@ -489,12 +481,7 @@ function handleSend(req) { // the quiesce handler can drain never-handed compacts from the input queue // and so a same-tick handoff arms the BUSY-forcing handed flag. const isCompact = /^\s*\/compact(\s|$)/.test(String(req.text ?? '')) - const attempt = req.checkpointAttempt - const checkpointAttempt = attempt && isUuid(attempt.uuid) - && typeof attempt.logicalAttemptId === 'string' && attempt.logicalAttemptId.length > 0 - && typeof attempt.requestId === 'string' && attempt.requestId.length > 0 - ? { uuid: attempt.uuid, logicalAttemptId: attempt.logicalAttemptId, requestId: attempt.requestId } - : undefined + const checkpointAttempt = checkpointAttemptOf(req.checkpointAttempt) const userMessage = { type: 'user', message: { role: 'user', content: userMessageContent(req.text, req.images) }, @@ -502,15 +489,7 @@ function handleSend(req) { session_id: st.cliSessionId || 'default', ...(checkpointAttempt ? { uuid: checkpointAttempt.uuid } : {}), } - if (checkpointAttempt) { - // Bound stale/missing-replay entries. Eviction means unavailable identity, - // never an approximate join by request order or prompt text. - st.pendingCheckpointAttempts.delete(checkpointAttempt.uuid) - st.pendingCheckpointAttempts.set(checkpointAttempt.uuid, checkpointAttempt) - while (st.pendingCheckpointAttempts.size > 128) { - st.pendingCheckpointAttempts.delete(st.pendingCheckpointAttempts.keys().next().value) - } - } + if (checkpointAttempt) rememberCheckpointAttempt(st.pendingCheckpointAttempts, checkpointAttempt) st.inputStream.push(userMessage, isCompact) // Unified attention bookkeeping: this send is now ACCEPTED (the input-stream // push succeeded) and awaits exactly one terminal frame — a COUNTER, not a @@ -522,11 +501,6 @@ function handleSend(req) { st.pendingResults += 1 } -function isUuid(value) { - return typeof value === 'string' - && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) -} - async function handleConfigure(req) { const st = sessions.get(req.sessionId) if (!st) { @@ -539,7 +513,7 @@ async function handleConfigure(req) { }) emit({ type: 'sdk.configured', sessionId: req.sessionId, requestId: req.requestId, ok: true, settings }) } catch (err) { - logerr(`session settings failed: ${err?.message || err}`) + log('warn', 'configure.failed', { sessionId: req.sessionId, requestId: req.requestId, error: errorText(err) }) emit({ type: 'sdk.configured', sessionId: req.sessionId, requestId: req.requestId, ok: false, settings: { ...st.settings }, message: String(err?.message || err) }) } } @@ -598,11 +572,12 @@ function handleInterrupt(req) { // the NEXT unrelated turn's result, silently suppressing exactly the ring // this feature exists to produce. While a turn IS pending, the settle // receipt (sdk.d.ts:3765) lands BEFORE the interrupted turn's own result, - // so the mark deterministically consumes exactly that result (including the - // crash-order case where the result wins the race to the wire — the mark - // was armed at request time and is still there to consume it). A REJECTED - // interrupt (the turn kept running) clears the mark; noteInterruptSettled - // stays in both promise arms — settle(true) without a mark is a no-op. + // so the mark is consumed by exactly that result, and silences it only if + // the Stop landed (the result was aborted). A Stop that missed — the CLI was + // still starting (T13), or the turn finished first — leaves a result that + // rings. A REJECTED interrupt (the turn kept running) clears the mark; + // noteInterruptSettled stays in both promise arms — settle(true) without a + // mark is a no-op. if (st.pendingResults > 0) st.turnCompleteGate.noteInterruptRequest() st.query.interrupt() .then(() => { @@ -611,7 +586,7 @@ function handleInterrupt(req) { }) .catch((err) => { st.turnCompleteGate.noteInterruptSettled(false) - logerr(`interrupt failed: ${err?.message || err}`) + log('warn', 'interrupt.failed', { sessionId: req.sessionId, error: errorText(err) }) emit({ type: 'sdk.interrupt_settled', sessionId: req.sessionId, ok: false, message: String(err?.message || err) }) }) } @@ -632,7 +607,8 @@ rl.on('line', (line) => { const trimmed = line.trim() if (!trimmed) return let req - try { req = JSON.parse(trimmed) } catch { logerr(`unparseable request: ${trimmed.slice(0, 200)}`); return } + // A request line may carry prompt text: log its size, never its content. + try { req = JSON.parse(trimmed) } catch { log('warn', 'request.unparseable', { bytes: Buffer.byteLength(trimmed) }); return } switch (req?.type) { case 'create': handleCreate(req); break case 'send': handleSend(req); break @@ -652,13 +628,13 @@ rl.on('line', (line) => { // no-op; the entry stays parked for a later valid respond (mirrors the // coerced-answers asymmetry one arm below). if (req.decision == null) { - logerr(`permission.respond: missing decision for request ${req.requestId} (session ${req.sessionId})`) + log('warn', 'respond.invalid', { kind: 'permission', sessionId: req.sessionId, requestId: req.requestId }) break } // Unknown requestId: lose-safely (Rust validates its pending set first) — // stderr log only, no frame. if (!respondPermission(st, String(req.requestId), req.decision)) { - logerr(`permission.respond: no pending request ${req.requestId} for session ${req.sessionId}`) + log('warn', 'respond.unknown', { kind: 'permission', sessionId: req.sessionId, requestId: req.requestId }) } break } @@ -667,12 +643,12 @@ rl.on('line', (line) => { if (!st) break const answers = req.answers && typeof req.answers === 'object' ? req.answers : {} if (!respondQuestion(st, String(req.requestId), answers)) { - logerr(`question.respond: no pending request ${req.requestId} for session ${req.sessionId}`) + log('warn', 'respond.unknown', { kind: 'question', sessionId: req.sessionId, requestId: req.requestId }) } break } case 'shutdown': shutdown(); process.exit(0); break - default: logerr(`unknown request type: ${req?.type}`) + default: log('warn', 'request.unknown_type', { requestType: String(req?.type) }) } }) // stdin closed (Rust reaped us): abort every query and exit so no claude CLI @@ -684,11 +660,11 @@ rl.on('close', () => { shutdown(); process.exit(0) }) // the SDK query is aborted, and nothing stops the claude CLI: it runs on until // it next writes into its own closed pipe or Rust kills the process tree. process.stdout.on('error', (err) => { - logerr(`stdout closed (${err?.code || err}); shutting down`) + log('warn', 'stdout.closed', { code: err?.code ?? null, error: errorText(err) }) shutdown() process.exit(0) }) process.on('SIGTERM', () => { shutdown(); process.exit(0) }) process.on('SIGINT', () => { shutdown(); process.exit(0) }) -logerr('ready') +log('info', 'sidecar.ready', { pid: process.pid }) diff --git a/crates/freshell-claude-sidecar/log.mjs b/crates/freshell-claude-sidecar/log.mjs new file mode 100644 index 0000000000..d28968a211 --- /dev/null +++ b/crates/freshell-claude-sidecar/log.mjs @@ -0,0 +1,75 @@ +// log.mjs — the claude sidecar's structured log (G14). +// --------------------------------------------------------------------------- +// stdout is the protocol, so stderr is the only log sink: one JSON record per +// line, `{ severity, component, event, ...fields }`. The Rust server reads the +// sidecar's stderr and re-emits each record through `tracing` at its severity, +// labelled with the session it serves (crates/freshell-freshagent/src/ +// claude_sidecar_io.rs). A record carries ids, sizes and error text only: +// prompt text and transcript content are never logged. + +/** The severities a record may carry, lowest first. */ +export const SEVERITIES = Object.freeze(['debug', 'info', 'warn', 'error']) + +/** The most bytes of one CLI stderr chunk a `cli.stderr` record keeps. */ +export const CLI_STDERR_MAX_BYTES = 2048 + +/** + * A `log(severity, event, fields)` function for one component. `write` gets + * each finished line (newline included); it defaults to stderr. + */ +export function createLog(component, write = (line) => process.stderr.write(line)) { + return function log(severity, event, fields = {}) { + const valid = SEVERITIES.includes(severity) + const record = { severity: valid ? severity : 'warn', component, event } + if (!valid) record.invalidSeverity = String(severity) + for (const [key, value] of Object.entries(fields ?? {})) { + if (!(key in record)) record[key] = value + } + let line + try { + line = JSON.stringify(record) + } catch (error) { + // A field that cannot be serialized must not cost the record itself. + line = JSON.stringify({ severity: record.severity, component, event, unserializable: errorText(error) }) + } + write(`${line}\n`) + } +} + +/** The sidecar's own log. */ +export const log = createLog('claude-sidecar') + +/** An error (or anything thrown) as text for a record's `error` field. */ +export function errorText(error) { + return String(error?.message || error) +} + +/** + * The SDK's `stderr` callback: each chunk the CLI writes becomes one + * `cli.stderr` info record. Its text is cut to CLI_STDERR_MAX_BYTES (a cut + * record also carries the chunk's full size); a hosted sidecar records the + * size alone. + */ +export function cliStderrLogger(logFn, { hosted, fields = {} } = {}) { + return (data) => { + const chunk = String(data) + const bytes = Buffer.byteLength(chunk) + if (hosted) { + logFn('info', 'cli.stderr', { ...fields, bytes }) + return + } + const text = chunk.trimEnd() + const kept = truncateUtf8(text, CLI_STDERR_MAX_BYTES) + logFn('info', 'cli.stderr', kept === text ? { ...fields, text } : { ...fields, text: kept, truncated: true, bytes }) + } +} + +/** `text` cut to at most `maxBytes` of UTF-8, never inside a character. */ +function truncateUtf8(text, maxBytes) { + const encoded = Buffer.from(text, 'utf8') + if (encoded.length <= maxBytes) return text + let end = maxBytes + // Back up over continuation bytes (10xxxxxx) to the start of the cut character. + while (end > 0 && (encoded[end] & 0xc0) === 0x80) end -= 1 + return encoded.subarray(0, end).toString('utf8') +} diff --git a/crates/freshell-claude-sidecar/model-catalog.mjs b/crates/freshell-claude-sidecar/model-catalog.mjs index 88c216044f..230b5bcbb4 100644 --- a/crates/freshell-claude-sidecar/model-catalog.mjs +++ b/crates/freshell-claude-sidecar/model-catalog.mjs @@ -1,17 +1,17 @@ import { realpathSync } from 'node:fs' import { fileURLToPath, pathToFileURL } from 'node:url' import { managedSdkSpawn } from './exec-profile.mjs' +import { cliStderrLogger, createLog, errorText } from './log.mjs' +import { isHostedSidecar } from './hosted.mjs' import { createClaudeSdkCleanEnv } from './sdk-env.mjs' -const log = (message, details = {}) => process.stderr.write(`${JSON.stringify({ severity: 'warn', component: 'claude-model-catalog', message, ...details })}\n`) +const catalogLog = createLog('claude-model-catalog') /** A short-lived SDK control query; an empty stream never starts an agent turn. */ -export async function probeModelCatalog(query, { env = process.env, timeoutMs = 10_000 } = {}) { +export async function probeModelCatalog(query, { env = process.env, timeoutMs = 10_000, log = catalogLog } = {}) { const cleanEnv = createClaudeSdkCleanEnv(env) const abortController = new AbortController() - const stderr = env.FRESHELL_HOSTED_FRESH_AGENT === 'claude' - ? data => log('Claude capability probe stderr', { bytes: Buffer.byteLength(String(data)) }) - : data => log('Claude capability probe stderr', { data: String(data).trimEnd() }) + const stderr = cliStderrLogger(log, { hosted: isHostedSidecar(env) }) const session = query({ prompt: (async function* () {})(), options: { @@ -36,7 +36,7 @@ export async function probeModelCatalog(query, { env = process.env, timeoutMs = ]) } finally { clearTimeout(timer) - try { await session.close() } catch (error) { log('Claude capability query close failed', { error: String(error) }) } + try { await session.close() } catch (error) { log('warn', 'catalog.close_failed', { error: errorText(error) }) } } } @@ -60,7 +60,7 @@ if (isMainModule()) { const models = await probeModelCatalog(query) process.stdout.write(`${JSON.stringify(models)}\n`) } catch (error) { - log('Claude model catalog unavailable', { error: String(error) }) + catalogLog('error', 'catalog.unavailable', { error: errorText(error) }) process.exitCode = 1 } } diff --git a/crates/freshell-claude-sidecar/sdk-message-frames.mjs b/crates/freshell-claude-sidecar/sdk-message-frames.mjs new file mode 100644 index 0000000000..4a0995166f --- /dev/null +++ b/crates/freshell-claude-sidecar/sdk-message-frames.mjs @@ -0,0 +1,76 @@ +// The pure mapping from an SDK message to the sidecar's stdout frames +// (docs/plans/2026-10-22-freshclaude-core-runtime.md §3.2 and §9.1 N6). One +// function per SDK message kind; index.mjs keeps the side effects (the turn +// gate, pendingResults, the attention clock, emit). The core runtime owns the +// `result` mapping below; the transcript work adds the content kinds here. +// +// Result shapes are the pinned CLI's (test/integration/providers/ +// claude-cli-contract.test.ts): a Stop ends the turn with +// `error_during_execution`, `terminal_reason: aborted_streaming|aborted_tools` +// and an `[ede_diagnostic] …` error (T11); an API error is a `success` with +// `is_error` (T18), which the transcript shows, so it gets no failure text. + +/** terminal_reason values of a turn that was aborted (a Stop landed on it). */ +export const ABORTED_TERMINAL_REASONS = new Set(['aborted_streaming', 'aborted_tools']) + +const DIAGNOSTIC_PREFIX = '[ede_diagnostic]' + +const ABORTED_COPY = 'Claude stopped the turn before it finished.' +const SUBTYPE_COPY = { + error_max_turns: 'Claude reached its turn limit. Send a message to continue.', + error_max_budget_usd: 'Claude reached the configured spending limit.', + error_max_structured_output_retries: 'Claude could not produce the requested response format.', +} +const DEFAULT_COPY = 'Claude could not complete this turn. Try sending your message again.' + +/** The CLI ended this turn because it was aborted (its terminal_reason says so). */ +export function isAbortedResult(result) { + return ABORTED_TERMINAL_REASONS.has(result?.terminal_reason) +} + +/** + * The text a failed turn shows the user, or undefined for a successful result. + * The CLI's internal `[ede_diagnostic]` lines are never shown; when nothing + * else is left, the text explains the result kind in plain words. + */ +export function resultErrorText(result) { + if (result.subtype === 'success') return undefined + const errors = Array.isArray(result.errors) ? result.errors : [] + const messages = errors + .filter((message) => typeof message === 'string') + .map((message) => message.trim()) + .filter((message) => message && !message.startsWith(DIAGNOSTIC_PREFIX)) + if (messages.length) return messages.join('\n') + if (isAbortedResult(result)) return ABORTED_COPY + return SUBTYPE_COPY[result.subtype] ?? DEFAULT_COPY +} + +function resultUsage(usage) { + if (!usage) return undefined + return { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + cache_creation_input_tokens: usage.cache_creation_input_tokens, + cache_read_input_tokens: usage.cache_read_input_tokens, + } +} + +/** + * The frames for an SDK `result`: `sdk.result`, then, for a failed turn, the + * `sdk.error{turnFailure}` the client shows as its "Agent error" banner. + * `userStopped` is the turn gate's verdict that this result is the user's own + * Stop: that is not a failure, so it carries no error frame (G03). + */ +export function resultFrames(sessionId, result, { userStopped }) { + const frames = [{ + type: 'sdk.result', + sessionId, + result: result.subtype, + durationMs: result.duration_ms, + costUsd: result.total_cost_usd, + usage: resultUsage(result.usage), + }] + const failure = userStopped ? undefined : resultErrorText(result) + if (failure) frames.push({ type: 'sdk.error', sessionId, message: failure, turnFailure: true }) + return frames +} diff --git a/crates/freshell-claude-sidecar/session-settings.mjs b/crates/freshell-claude-sidecar/session-settings.mjs index da24c6e1c2..8d2cd6c3f6 100644 --- a/crates/freshell-claude-sidecar/session-settings.mjs +++ b/crates/freshell-claude-sidecar/session-settings.mjs @@ -43,15 +43,3 @@ export function userMessageContent(text, images = []) { } return content } - -export function resultErrorMessage(result) { - if (result.subtype === 'success') return undefined - const messages = Array.isArray(result.errors) ? result.errors.filter((message) => typeof message === 'string' && message.trim()) : [] - if (messages.length) return messages.join('\n') - switch (result.subtype) { - case 'error_max_turns': return 'Claude reached its turn limit. Send a message to continue.' - case 'error_max_budget_usd': return 'Claude reached the configured spending limit.' - case 'error_max_structured_output_retries': return 'Claude could not produce the requested response format.' - default: return 'Claude could not complete this turn. Try sending your message again.' - } -} diff --git a/crates/freshell-claude-sidecar/turn-complete-gate.mjs b/crates/freshell-claude-sidecar/turn-complete-gate.mjs index 1f7bf89784..07d2a57399 100644 --- a/crates/freshell-claude-sidecar/turn-complete-gate.mjs +++ b/crates/freshell-claude-sidecar/turn-complete-gate.mjs @@ -1,40 +1,61 @@ /** * Unified "needs attention" turn-complete gate (freshell-claude-sidecar). + * Interim: the Rust Stop latch replaces it (docs/plans/ + * 2026-10-22-freshclaude-core-runtime.md §5.3-5.4, C18). * * Every SDK `result` — success, error_during_execution, error_max_turns, * error_max_budget_usd, error_max_structured_output_retries, and * success-with-is_error — ends a turn the user may not have been watching, - * so every result emits `sdk.turn.complete`. The ONE exception is a - * user-initiated interrupt on a turn in flight: per the SDK contract the - * `interrupt_settled` receipt lands BEFORE the interrupted turn's own - * terminal result, so the arm site (index.mjs handleInterrupt) arms the mark - * ONLY while a turn is plausibly awaiting a terminal frame — - * `if (st.pendingResults > 0) st.turnCompleteGate.noteInterruptRequest()` — - * and an accepted interrupt's mark then consumes (and suppresses) exactly - * that result. The SDK RESOLVES an interrupt with nothing in flight - * (sdk.d.ts:2384-2394 — resolution, not rejection; no result ever follows), - * so an idle-session interrupt must arm NOTHING: a stray mark there would - * survive and eat the NEXT unrelated turn's result. A rejected interrupt - * (ok:false — the turn kept running) clears the mark. + * so every result emits `sdk.turn.complete`. The ONE exception is the + * result of the turn the user stopped. The arm site (index.mjs + * handleInterrupt) arms the mark ONLY while a turn is plausibly awaiting a + * terminal frame — `if (st.pendingResults > 0) + * st.turnCompleteGate.noteInterruptRequest()` — because the SDK RESOLVES an + * interrupt with nothing in flight (sdk.d.ts:2384-2394; no result follows). + * A rejected interrupt (ok:false — the turn kept running) clears the mark. + * + * The next result consumes the mark, and it is silenced only when it was + * aborted (terminal_reason aborted_streaming/aborted_tools, T11; or, on a + * CLI that reports no terminal_reason, error_during_execution after at least + * one turn — a boot refusal (T5, num_turns 0) is never a stopped turn). A Stop that + * missed its turn — one sent while the CLI was still starting, whose receipt + * comes back ok while the turn then runs to success/completed (T13) — must + * not silence that turn (G06): the user believes it stopped, and it did not. + * Consuming the mark on every result also means a stale mark (armed for a + * send the CLI absorbed into an earlier turn, T10) never swallows a later + * turn's bell. * * There is DELIBERATELY no reset-on-send: the SDK serializes turns within * one query (a queued send is input pushed onto the same stream, and - * results arrive in turn order), so the interrupted turn's result is - * deterministically the FIRST result after the accepted settle. A - * reset-at-send would race a queued send against the interrupted turn's - * late result and let the user's own interrupt ring. Staleness is bounded - * by the session lifecycle instead — the mark dies with the session state - * when consumeStream's finally deletes it. + * results arrive in turn order), so the stopped turn's result is the FIRST + * result after the accepted settle. A reset-at-send would race a queued send + * against the stopped turn's late result and let the user's own Stop ring. */ + +import { isAbortedResult } from './sdk-message-frames.mjs' + +/** The result is the turn a Stop landed on. */ +function wasStopped(result) { + if (isAbortedResult(result)) return true + // A CLI that predates terminal_reason reports a stopped turn only as + // error_during_execution. A boot refusal has the same shape on the pinned + // CLI (T5) but ran no turn (num_turns 0), so no Stop ended it: it rings and + // keeps its refusal text. + return result?.terminal_reason == null + && result?.subtype === 'error_during_execution' + && result?.num_turns > 0 +} + export function createTurnCompleteGate() { let userInterruptPending = false return { noteInterruptRequest() { userInterruptPending = true }, noteInterruptSettled(ok) { if (!ok) userInterruptPending = false }, - resultEmitsAttention() { - const interrupted = userInterruptPending + /** Whether this SDK `result` rings; consumes any pending Stop mark. */ + resultEmitsAttention(result) { + const stopped = userInterruptPending && wasStopped(result) userInterruptPending = false - return !interrupted + return !stopped }, } } diff --git a/crates/freshell-freshagent/Cargo.toml b/crates/freshell-freshagent/Cargo.toml index 00e2e678b5..464c90953b 100644 --- a/crates/freshell-freshagent/Cargo.toml +++ b/crates/freshell-freshagent/Cargo.toml @@ -27,6 +27,10 @@ publish.workspace = true [features] default = [] provider-qualification = ["freshell-agent-runtime/provider-qualification"] +# Test seams for other crates' integration tests (never enabled in a production +# build): FreshClaudeState::with_sidecar_environment, used by the freshell-ws +# claude rigs (crates/freshell-ws/tests/common/claude_rig.rs). +test-support = [] [dependencies] # kata b8ke Task 3: the ONE server-wide runtime-ownership coordinator this diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index a2197214a6..83af2a42ca 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -118,6 +118,13 @@ pub struct FreshClaudeState { /// Host-only selection of the fixed Freshell stdio MCP recipe. Its endpoint /// and grant remain in the child environment, never in create IPC. hosted_mcp: bool, + /// Test seam (`test-support` feature, C04 rigs): the complete environment of + /// every claude sidecar this state spawns, replacing the inherited one (the + /// ownership tag is still layered on). `None` in production: the sidecar + /// inherits the server's environment. Lets a rig point the real sidecar at + /// the CLI simulator or a local endpoint with an isolated HOME, without + /// mutating this process's environment. + sidecar_environment: Option>, /// The shared WS broadcast bus (pre-serialized frames), fanned out by every /// `freshell-ws` connection so the oracle's capture socket records /// `freshAgent.created` / `freshAgent.send.accepted` / `freshAgent.event`. @@ -788,6 +795,7 @@ impl FreshClaudeState { pub fn new(broadcast_tx: Arc>) -> Self { Self { hosted_mcp: false, + sidecar_environment: None, broadcast_tx, checkpoint_event_sink: crate::CheckpointEventSink::default(), sessions: Arc::new(TokioMutex::new(HashMap::new())), @@ -827,6 +835,24 @@ impl FreshClaudeState { self } + /// Test seam (see [`Self::sidecar_environment`]): every sidecar this state + /// spawns gets exactly `environment` (plus its ownership tag) instead of + /// inheriting the server's environment. + #[cfg(any(test, feature = "test-support"))] + pub fn with_sidecar_environment( + mut self, + environment: impl IntoIterator, + ) -> Self { + self.sidecar_environment = Some(environment.into_iter().collect()); + self + } + + /// Spawn one claude sidecar process (create, attach-resume and rollback all + /// come through here) for a session of `session_type`. + async fn spawn_sidecar_process(&self, session_type: &str) -> Result { + spawn_sidecar(self.sidecar_environment.as_deref(), session_type).await + } + pub fn set_checkpoint_event_persister( &self, persister: crate::CheckpointEventPersister, @@ -1119,31 +1145,59 @@ impl FreshClaudeState { &self.leases } - /// The coordinator key that fences an operation on the session behind - /// `map_key`, addressed on the wire as `wire_id`. The coordinator keys a - /// claude session by its durable id — the map key and a new pane's wire id - /// are the lane's own placeholder — so once the CLI has reported the - /// session, its durable id is the key (the rule `handle_attach` and - /// `handle_kill` follow). Before the report (`system/init`, which the CLI - /// emits only once a prompt runs) the wire id is the key if the - /// coordinator holds a record for it; otherwise nothing owns the session - /// yet and there is nothing to fence: `None`. Such a session has no - /// durable id to hand off, reattach or roll back, and a close removes it - /// from the sessions map. - async fn fence_owner_key(&self, map_key: &str, wire_id: &str) -> Option { - let durable = self - .sessions - .lock() - .await - .get(map_key) - .and_then(|session| session.cli_session_id.clone()); - if let Some(durable) = durable { - return Some(self.resolve_ownership_key(&durable)); + /// The coordinator key a claude session is owned under. The coordinator + /// keys a claude session by its durable id (the map key and the pane's wire + /// id are the lane's own placeholder), so once the CLI has reported the + /// session (`system/init`, which it emits only once a prompt runs, T1) that + /// id is the key, as in `handle_attach`. Before the report, or when + /// `map_key` names no live session, the wire id is. Either resolves through + /// the coordinator's alias walk. Kill and send both address the owner + /// through this one rule. C23 (durable id minted at create) makes the + /// durable id known from the start and removes the wire-id fallback. + async fn claude_owner_key(&self, map_key: Option<&str>, wire_id: &str) -> ClaudeOwnerKey { + let durable = match map_key { + Some(map_key) => self + .sessions + .lock() + .await + .get(map_key) + .and_then(|session| session.cli_session_id.clone()), + None => None, + }; + match durable { + Some(durable) => ClaudeOwnerKey { + key: self.resolve_ownership_key(&durable), + reported: true, + }, + None => ClaudeOwnerKey { + key: self.resolve_ownership_key(wire_id), + reported: false, + }, } - let key = self.resolve_ownership_key(wire_id); - match self.ownership_snapshot(PROVIDER, &key).state { + } + + /// The coordinator key that fences a send to the session behind `map_key`, + /// or `None` when the send runs unfenced. A reported session is always + /// fenced on its durable id, and a supplied observed pair is always checked + /// (against a record the coordinator does not hold, it is stale and the + /// guard refuses it typed). The one unfenced case is a session the CLI has + /// not reported, with no record in the coordinator and no pair on the + /// send: a new pane's first message, which is what makes the CLI report + /// it. Such a session has no durable id to hand off, reattach or roll back, + /// and a close removes it from the map. + async fn send_owner_key( + &self, + map_key: &str, + wire_id: &str, + fence: Option, + ) -> Option { + let owner = self.claude_owner_key(Some(map_key), wire_id).await; + if owner.reported || fence.is_some() { + return Some(owner.key); + } + match self.ownership_snapshot(PROVIDER, &owner.key).state { freshell_ownership::OwnershipState::Vacant => None, - _ => Some(key), + _ => Some(owner.key), } } @@ -1903,6 +1957,45 @@ impl FreshClaudeState { } } + /// The one way a claude attention edge leaves the server: every site that + /// forwards or mints a `freshAgent.turn.complete` / `freshAgent.turn.waiting` + /// calls this (design §9.2 I-1; C18's `ClaudeEdgeHub::emit` replaces it). + /// It logs the edge at info (G14, design §5.5: ids, type, source and `at`; + /// never content), then broadcasts its client frame. `stamp` is the + /// envelope id clients address the session by; `sidecar_session_id` is the + /// id inside the event, as the sidecar names the session. + pub(crate) fn emit_attention_edge( + &self, + edge: AttentionEdge, + stamp: &str, + sidecar_session_id: &str, + session_type: &str, + at: i64, + source: AttentionEdgeSource, + ) { + let source = source.as_str(); + match edge { + AttentionEdge::TurnComplete => tracing::info!( + provider = PROVIDER, session_id = %stamp, session_type = %session_type, + source, at, "freshagent.turn.complete" + ), + AttentionEdge::Waiting => tracing::info!( + provider = PROVIDER, session_id = %stamp, session_type = %session_type, + source, at, "freshagent.turn.waiting" + ), + } + self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + event: json!({ + "type": edge.wire_type(), + "sessionId": sidecar_session_id, + "at": at, + }), + provider: PROVIDER.to_string(), + session_id: stamp.to_string(), + session_type: session_type.to_string(), + })); + } + async fn broadcast_checkpoint_frame( &self, frame: String, @@ -2318,8 +2411,15 @@ impl FreshClaudeState { .as_deref() .and_then(|sid| self.claim_dead_state_snapshot(sid)); - let (mut child, mut stdin, stdout, ownership_id, tree_guard) = match spawn_sidecar().await { - Ok(parts) => parts, + let SpawnedSidecar { + mut child, + mut stdin, + stdout, + ownership_id, + log_labels, + tree_guard, + } = match self.spawn_sidecar_process(session_type).await { + Ok(spawned) => spawned, Err(err) => { if let Some(mut g) = lease_guard.take() { g.fail(); @@ -2524,6 +2624,7 @@ impl FreshClaudeState { // pending-set fold live here). `Some(settings)` => the consumer records a // binding row at `sdk.session.init`. let broadcast_id = Arc::new(std::sync::Mutex::new(created.clone())); + log_labels.bind_session(&broadcast_id); let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); let in_turn = Arc::new(std::sync::atomic::AtomicBool::new(false)); let turn_tracker = Arc::new(std::sync::Mutex::new(TurnTracker::default())); @@ -3155,18 +3256,11 @@ impl FreshClaudeState { // lane resolution the stamp path uses (placeholder wire // ids resolve through the live session's recorded cli id; // a durable wire id is itself). - let canonical = match self.resolve_session_key(&session_id).await { - Some(mk) => { - let durable = self - .sessions - .lock() - .await - .get(&mk) - .and_then(|sess| sess.cli_session_id.clone()); - durable.unwrap_or_else(|| self.resolve_ownership_key(&session_id)) - } - None => self.resolve_ownership_key(&session_id), - }; + let live_key = self.resolve_session_key(&session_id).await; + let canonical = self + .claude_owner_key(live_key.as_deref(), &session_id) + .await + .key; let snap = registry.observe(PROVIDER, &canonical); match snap.state.clone() { freshell_ownership::OwnershipState::Live { @@ -4435,16 +4529,20 @@ impl FreshClaudeState { // Arm the coordinator guard only after the send has waited behind any // earlier turn. A lifecycle transition during that wait advances the // generation, so the captured fence is checked against the owner that - // will actually receive this prompt. Checkpointed sends always require - // the fence when the coordinator is wired; ordinary legacy sends keep - // their existing unfenced behavior, while any supplied fence is honored. - // The fence keys on the record that owns the session (its durable id - // once reported), never on a new pane's placeholder wire id. A lane - // without a coordinator (the session host's) has nothing to fence. + // will actually receive this prompt. Checkpointed sends go through the + // guard when the coordinator is wired, and any supplied fence is + // honored; ordinary legacy sends keep their existing unfenced + // behavior. The fence keys on the record that owns the session (its + // durable id once reported), never on a new pane's placeholder wire id + // (`send_owner_key`). The one exception is `send_owner_key`'s: an + // unfenced send to a session the CLI has not reported and the + // coordinator holds no record for (a new pane's first message) runs + // unguarded. A lane without a coordinator (the session host's) has + // nothing to fence. let fenced = msg.checkpoint_intent.is_some() || send_fence.is_some(); let ownership_guard = if fenced && self.ownership.is_some() { - match self.fence_owner_key(&map_key, &session_id).await { - Some(owner_key) => match crate::ownership_lane::arm_reclaimless_op_guard( + if let Some(owner_key) = self.send_owner_key(&map_key, &session_id, send_fence).await { + match crate::ownership_lane::arm_reclaimless_op_guard( &self.ownership, PROVIDER, &owner_key, @@ -4456,25 +4554,24 @@ impl FreshClaudeState { crate::ownership_lane::LaneOpGuard::Unwired => None, crate::ownership_lane::LaneOpGuard::Refused { message } => { tracing::warn!(target: "freshell_freshagent::claude", - session_id = %session_id, owner_key = %owner_key, - request_id = ?request_id, - "fresh_agent_send_refused: ownership guard refused"); + session_id = %session_id, owner_key = %owner_key, + request_id = ?request_id, + "fresh_agent_send_refused: ownership guard refused"); self.send_error(&request_id, "SESSION_RESERVED", &message); return; } - }, - None => { - // Nothing owns the session in the coordinator yet: the CLI - // reports the session only once a prompt runs, and this - // send is what makes it run. Refusing it would refuse - // every new pane's first message. - tracing::debug!(target: "freshell_freshagent::claude", - session_id = %session_id, request_id = ?request_id, - event = "freshagent.claude.send_before_session_report", - "the send runs unfenced: the CLI has not reported the session \ - yet and the coordinator holds no record for it"); - None } + } else { + // Nothing owns the session in the coordinator yet and the send + // names no owner: the CLI reports the session only once a + // prompt runs (T1), and this send is what makes it run. + // Refusing it would refuse every new pane's first message. + tracing::debug!(target: "freshell_freshagent::claude", + session_id = %session_id, request_id = ?request_id, + event = "freshagent.claude.send_before_session_report", + "the send runs unfenced: the CLI has not reported the session yet \ + and the coordinator holds no record for it"); + None } } else { None @@ -6421,7 +6518,7 @@ impl FreshClaudeState { } let create_req = Value::Object(create_req); match self - .rollback_spawn_create(&create_req, lease_guard.as_mut()) + .rollback_spawn_create(&create_req, session_type, lease_guard.as_mut()) .await { Ok(ok) => break Ok(ok), @@ -6455,6 +6552,7 @@ impl FreshClaudeState { stdin, reader, ownership_id, + log_labels, sidecar_session_id, preseeded_init, cli_id, @@ -6479,6 +6577,7 @@ impl FreshClaudeState { // the SAME lock regardless of when it resolved its handle). let (adopted_tx, adopted_rx) = tokio::sync::oneshot::channel(); let broadcast_id = Arc::new(std::sync::Mutex::new(op.session_id.clone())); + log_labels.bind_session(&broadcast_id); let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); // The respawn is fresh: nothing announced yet; the consumer's status // fold owns the tracked status from here. @@ -7228,7 +7327,15 @@ impl FreshClaudeState { // advances the durable tombstone past it and the commit below // REFUSES instead of undoing the newer close. let claim_dead_state = self.claim_dead_state_snapshot(durable); - let (mut child, mut stdin, stdout, ownership_id, tree_guard) = spawn_sidecar() + let SpawnedSidecar { + mut child, + mut stdin, + stdout, + ownership_id, + log_labels, + tree_guard, + } = self + .spawn_sidecar_process(session_type_str(msg.session_type)) .await .map_err(ResumeClaudeError::Transient)?; // Task 13: arm the lease's TTL tree-kill path now that the child + tag exist. @@ -7331,6 +7438,7 @@ impl FreshClaudeState { // attaches with the old id still resolve — no repeat-fire, V7 §4); `None` // records nothing (no laundered blank row under the new id, V7/A10). let broadcast_id = Arc::new(std::sync::Mutex::new(msg.session_id.clone())); + log_labels.bind_session(&broadcast_id); let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); let in_turn = Arc::new(std::sync::atomic::AtomicBool::new(false)); let turn_tracker = Arc::new(std::sync::Mutex::new(TurnTracker::default())); @@ -8792,7 +8900,26 @@ impl FreshClaudeState { // Task 10b: stamp the envelope from the SHARED handle (not the captured // map key) so an attach-by-durable rebind flips live event routing. let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); - if let Some(frame) = sdk_line_to_frame(&value, &stamp, &session_type) { + let line_type = value.get("type").and_then(Value::as_str).unwrap_or(""); + if let Some(edge) = AttentionEdge::from_sidecar_type(line_type) { + // The sidecar's attention edges leave through the one edge + // exit (logged there, G14). + match value.get("at").and_then(Value::as_i64) { + Some(at) => state.emit_attention_edge( + edge, + &stamp, + &sidecar_session_id, + &session_type, + at, + AttentionEdgeSource::Sidecar, + ), + // The client drops an edge without a numeric `at`; say so here. + None => tracing::warn!(target: "freshell_freshagent::claude", + provider = PROVIDER, session_id = %stamp, + session_type = %session_type, edge = edge.wire_type(), + "freshagent.claude.attention_edge_dropped_malformed_at"), + } + } else if let Some(frame) = sdk_line_to_frame(&value, &stamp, &session_type) { if value.get("type").and_then(Value::as_str) == Some("sdk.checkpointTurnIdentity") { @@ -8924,21 +9051,14 @@ impl FreshClaudeState { *guard = Some(at); at }; - // DIAG-01: the attention edge only — session id + source, - // never the turn's text/response content. - tracing::info!(provider = PROVIDER, session_id = %stamp, - source = "freshclaude/consumer-exit", "freshagent.turn.complete"); - let frame = ServerMessage::FreshAgentEvent(FreshAgentEvent { - event: json!({ - "type": "freshAgent.turn.complete", - "sessionId": sidecar_session_id, - "at": at, - }), - provider: PROVIDER.to_string(), - session_id: stamp.clone(), - session_type: session_type.clone(), - }); - state.broadcast(&frame); + state.emit_attention_edge( + AttentionEdge::TurnComplete, + &stamp, + &sidecar_session_id, + &session_type, + at, + AttentionEdgeSource::ConsumerExit, + ); } } }) @@ -9391,8 +9511,63 @@ fn frame_request_id(value: &Value) -> Option { .or_else(|| value.as_i64().map(|n| n.to_string())) } +/// The coordinator key a claude session is owned under +/// ([`FreshClaudeState::claude_owner_key`]). +struct ClaudeOwnerKey { + key: String, + /// The key is the durable id the CLI reported (else the pane's wire id). + reported: bool, +} + // ── sdk.* → freshAgent.event frame (port of sdk-events.ts normalizeFreshAgentProviderEvent) ─ +/// The two attention edges a claude session raises (AGENTS.md "Agent Status +/// Indicators"): a turn ended that the user did not witness, or the agent waits +/// on the user (a pending approval or question). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AttentionEdge { + TurnComplete, + Waiting, +} + +impl AttentionEdge { + /// The edge a sidecar stdout line carries, if it carries one. + fn from_sidecar_type(sdk_type: &str) -> Option { + match sdk_type { + "sdk.turn.complete" => Some(Self::TurnComplete), + "sdk.turn.waiting" => Some(Self::Waiting), + _ => None, + } + } + + /// The client event type. + pub(crate) fn wire_type(self) -> &'static str { + match self { + Self::TurnComplete => "freshAgent.turn.complete", + Self::Waiting => "freshAgent.turn.waiting", + } + } +} + +/// Where an attention edge was minted (logged as `source`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AttentionEdgeSource { + /// The sidecar minted it (a result, a result-less stream end, a pending + /// card) and the stdout consumer forwarded it. + Sidecar, + /// The stdout consumer minted it for an unrequested sidecar death mid-turn. + ConsumerExit, +} + +impl AttentionEdgeSource { + fn as_str(self) -> &'static str { + match self { + Self::Sidecar => "freshclaude/sidecar", + Self::ConsumerExit => "freshclaude/consumer-exit", + } + } +} + /// Map an `sdk.*` event line from the sidecar to a `freshAgent.event` wire frame. Renames /// the inner `type` `sdk.X → freshAgent.X` (only the known set — matching sdk-events.ts, /// which passes unknown types through unchanged and thus never surfaces them as fresh-agent @@ -9436,8 +9611,9 @@ fn normalize_sdk_type(sdk_type: &str) -> Option<&'static str> { // provider-originated cancellation). "sdk.question.cancelled" => "freshAgent.question.cancelled", "sdk.status" => "freshAgent.status", - "sdk.turn.complete" => "freshAgent.turn.complete", - "sdk.turn.waiting" => "freshAgent.turn.waiting", + // `sdk.turn.complete` / `sdk.turn.waiting` are deliberately absent: the + // consumer routes them to `emit_attention_edge` (AttentionEdge), the one + // logged edge exit, so no other path can broadcast an edge. "sdk.error" => "freshAgent.error", "sdk.exit" => "freshAgent.exit", "sdk.killed" => "freshAgent.killed", @@ -9581,6 +9757,8 @@ struct RollbackSpawned { /// The stdout line reader, positioned AFTER the preread `sdk.session.init`. reader: tokio::io::Lines>, ownership_id: String, + /// The labels the respawn's stderr log carries (bound at registration). + log_labels: crate::claude_sidecar_io::SidecarLogLabels, /// The sidecar-keyed id (`created.sessionId`). sidecar_session_id: String, /// The preread `sdk.session.init` line (the consumer adopts it preseeded). @@ -9615,10 +9793,20 @@ impl FreshClaudeState { async fn rollback_spawn_create( &self, create_req: &Value, + session_type: &str, lease_guard: Option<&mut crate::FreshSessionLeaseGuard>, ) -> Result { - let (mut child, mut stdin, stdout, ownership_id, tree_guard) = - spawn_sidecar().await.map_err(RollbackSpawnError::Other)?; + let SpawnedSidecar { + mut child, + mut stdin, + stdout, + ownership_id, + log_labels, + tree_guard, + } = self + .spawn_sidecar_process(session_type) + .await + .map_err(RollbackSpawnError::Other)?; if let Some(g) = lease_guard { if let Some(pid) = child.id() { g.set_kill_handle(pid, &ownership_id); @@ -9651,6 +9839,7 @@ impl FreshClaudeState { stdin, reader, ownership_id, + log_labels, sidecar_session_id, preseeded_init: init, cli_id, @@ -9718,6 +9907,19 @@ async fn read_session_init( // ── Node sidecar spawn ────────────────────────────────────────────────────────────────── +/// A just-spawned claude sidecar: the owned child with its stdin and stdout, the +/// ownership tag the reaper finds its tree by, the labels its stderr log carries +/// (bound to the session once the sidecar names it), and the guard that owns the +/// child's whole process tree (G31). +struct SpawnedSidecar { + child: Child, + stdin: ChildStdin, + stdout: ChildStdout, + ownership_id: String, + log_labels: crate::claude_sidecar_io::SidecarLogLabels, + tree_guard: crate::claude_spawn::SidecarTreeGuard, +} + /// The session sidecar's launch recipe (G30: the model-catalog probe uses the same /// builder, see `model_capabilities::catalog_node_command`): run-as wrapper, managed /// secrets plus the OneCLI key flag, and the `/proc` ownership tag. @@ -9731,20 +9933,16 @@ pub(crate) fn sidecar_node_command( .with_ownership_tag(ownership_id) } -/// Spawn `node /index.mjs`, ownership-tagged, inheriting the server's isolated HOME -/// (so the SDK's `claude` CLI authenticates from + writes under `/.claude`). -/// Returns the owned child, its stdin, its stdout, the ownership tag, and the guard that -/// owns the child's whole process tree (G31). -async fn spawn_sidecar() -> Result< - ( - Child, - ChildStdin, - ChildStdout, - String, - crate::claude_spawn::SidecarTreeGuard, - ), - String, -> { +/// Spawn `node /index.mjs` for a session of `session_type`, ownership-tagged, +/// inheriting the server's isolated HOME (so the SDK's `claude` CLI authenticates from + +/// writes under `/.claude`), inside its process-tree guard (G31). Its +/// stderr is re-emitted through `tracing` (`claude_sidecar_io::spawn_stderr_logger`, G14). +/// `environment`: when set, the sidecar's complete environment (the +/// `test-support` seam); otherwise it inherits this process's. +async fn spawn_sidecar( + environment: Option<&[(String, String)]>, + session_type: &str, +) -> Result { let entry = sidecar_entry_path(); if !entry.exists() { return Err(format!( @@ -9756,11 +9954,13 @@ async fn spawn_sidecar() -> Result< // Inherit the parent env (HOME=, CLAUDE_HOME=/.claude) and layer the // ownership tag so the /proc reaper can find our sidecar AND the claude CLI grandchild // (the SDK's clean-env passes FRESHELL_CLAUDE_SIDECAR_ID through — it strips only - // CLAUDECODE + ANTHROPIC_API_KEY). + // CLAUDECODE + ANTHROPIC_API_KEY). A configured environment (test rigs) replaces the + // inherited one; the tag and the managed secrets still layer over it. let command = sidecar_node_command( crate::claude_spawn::ClaudeNodeCommand::new(&entry), &ownership_id, - ); + ) + .with_complete_environment(environment); let node = command.node().to_string(); let (mut child, tree_guard) = command.spawn_guarded().map_err(|error| match error { crate::claude_spawn::ClaudeSpawnError::ExecProfile(classification) => classification, @@ -9771,11 +9971,25 @@ async fn spawn_sidecar() -> Result< })?; let stdin = child.stdin.take().ok_or("sidecar stdin unavailable")?; let stdout = child.stdout.take().ok_or("sidecar stdout unavailable")?; - // Drain stderr so verbose SDK/CLI logs can never fill the pipe and stall the sidecar. - if let Some(err) = child.stderr.take() { - drain_reader(err); - } - Ok((child, stdin, stdout, ownership_id, tree_guard)) + let log_labels = + crate::claude_sidecar_io::SidecarLogLabels::new(&ownership_id, session_type, child.id()); + // Read stderr continuously (so verbose SDK/CLI output can never fill the pipe and + // stall the sidecar) and log it; the task ends when the sidecar does. + if let Some(stderr) = child.stderr.take() { + crate::claude_sidecar_io::spawn_stderr_logger( + stderr, + log_labels.clone(), + crate::claude_sidecar_io::hosted_sidecar(environment), + ); + } + Ok(SpawnedSidecar { + child, + stdin, + stdout, + ownership_id, + log_labels, + tree_guard, + }) } #[cfg(all(test, target_os = "linux"))] @@ -9797,8 +10011,8 @@ mod provider_exec_tests { .collect(), ) .unwrap(); - let mut result = spawn_sidecar().await; - if let Ok((child, _, _, _, _)) = &mut result { + let mut result = spawn_sidecar(None, "freshclaude").await; + if let Ok(SpawnedSidecar { child, .. }) = &mut result { let pid = child.id().unwrap(); child.kill().await.unwrap(); child.wait().await.unwrap(); @@ -9903,20 +10117,6 @@ async fn write_line(stdin: &mut ChildStdin, value: &Value) -> Result<(), String> stdin.flush().await.map_err(|e| e.to_string()) } -/// Drain an async child pipe to `/dev/null` so it never back-pressures the sidecar. -fn drain_reader(mut reader: R) { - tokio::spawn(async move { - use tokio::io::AsyncReadExt; - let mut buf = [0u8; 4096]; - loop { - match reader.read(&mut buf).await { - Ok(0) | Err(_) => break, - Ok(_) => {} - } - } - }); -} - // ── ownership / reaping (Linux /proc, mirrors freshell-codex) ─────────────────────────── /// Mint a unique sidecar ownership id (`claude-sidecar-`) — the codex analog is @@ -11093,14 +11293,10 @@ pub(crate) mod tests { ); assert_eq!(normalize_sdk_type("sdk.stream"), Some("freshAgent.stream")); assert_eq!(normalize_sdk_type("sdk.result"), Some("freshAgent.result")); - assert_eq!( - normalize_sdk_type("sdk.turn.complete"), - Some("freshAgent.turn.complete") - ); - assert_eq!( - normalize_sdk_type("sdk.turn.waiting"), - Some("freshAgent.turn.waiting") - ); + // The attention edges are not renamed here: the stdout consumer hands + // them to `emit_attention_edge`, the one edge exit (G14, design §9.2 I-1). + assert_eq!(normalize_sdk_type("sdk.turn.complete"), None); + assert_eq!(normalize_sdk_type("sdk.turn.waiting"), None); // Task 2 (g): the pending-state family is forwarded too — including // sdk.question.cancelled (fresh-eyes round-3 F3 REPLACED the earlier "keep it // dropped" plan: the client folds freshAgent.question.cancelled into @@ -11153,16 +11349,108 @@ pub(crate) mod tests { } #[test] - fn turn_complete_frame_carries_the_success_edge() { - // The sidecar's unified turn-complete attention edge (minted for EVERY - // result subtype except a user-interrupted turn's own), normalized to - // freshAgent.turn.complete. - let line = json!({ "type": "sdk.turn.complete", "sessionId": "s-1", "at": 42 }); - let frame = sdk_line_to_frame(&line, "s-1", "freshclaude").unwrap(); - let wire: Value = serde_json::from_str(&frame).unwrap(); - assert_eq!(wire["type"], "freshAgent.event"); - assert_eq!(wire["event"]["type"], "freshAgent.turn.complete"); - assert_eq!(wire["event"]["at"], 42); + fn attention_edge_lines_leave_only_through_the_edge_exit() { + // The sidecar's attention edges are classified as edges, and the plain + // frame mapping refuses them: only `emit_attention_edge` (logged, G14) + // can put a freshAgent.turn.complete / turn.waiting on the wire. Its + // frame shape is pinned by + // `emit_attention_edge_logs_each_edge_and_broadcasts_its_frame`. + for (line, edge) in [ + ( + json!({ "type": "sdk.turn.complete", "sessionId": "s-1", "at": 42 }), + AttentionEdge::TurnComplete, + ), + ( + json!({ "type": "sdk.turn.waiting", "sessionId": "s-1", "at": 43 }), + AttentionEdge::Waiting, + ), + ] { + let line_type = line["type"].as_str().unwrap(); + assert_eq!(AttentionEdge::from_sidecar_type(line_type), Some(edge)); + assert!( + sdk_line_to_frame(&line, "s-1", "freshclaude").is_none(), + "{line_type} must not become a plain frame" + ); + } + } + + /// G14 / design §9.2 I-1: every claude attention edge leaves through + /// `emit_attention_edge`, which logs it at info (provider, session, type, + /// source and `at`, never content) and broadcasts the client frame. + #[tokio::test] + async fn emit_attention_edge_logs_each_edge_and_broadcasts_its_frame() { + use crate::claude_sidecar_io::test_capture::{capture, with_message}; + let (events, _guard) = capture(); + let (st, mut rx) = state_with_bus(); + st.emit_attention_edge( + AttentionEdge::TurnComplete, + "envelope-1", + "sidecar-1", + "kilroy", + 41, + AttentionEdgeSource::Sidecar, + ); + st.emit_attention_edge( + AttentionEdge::Waiting, + "envelope-1", + "sidecar-1", + "kilroy", + 42, + AttentionEdgeSource::Sidecar, + ); + st.emit_attention_edge( + AttentionEdge::TurnComplete, + "envelope-1", + "sidecar-1", + "kilroy", + 43, + AttentionEdgeSource::ConsumerExit, + ); + + let frames: Vec = (0..3) + .map(|_| serde_json::from_str(&rx.try_recv().expect("an edge frame")).unwrap()) + .collect(); + for frame in &frames { + assert_eq!(frame["type"], "freshAgent.event"); + assert_eq!(frame["provider"], "claude"); + assert_eq!(frame["sessionId"], "envelope-1"); + assert_eq!(frame["sessionType"], "kilroy"); + } + assert_eq!( + frames + .iter() + .map(|frame| frame["event"].clone()) + .collect::>(), + vec![ + json!({ "type": "freshAgent.turn.complete", "sessionId": "sidecar-1", "at": 41 }), + json!({ "type": "freshAgent.turn.waiting", "sessionId": "sidecar-1", "at": 42 }), + json!({ "type": "freshAgent.turn.complete", "sessionId": "sidecar-1", "at": 43 }), + ] + ); + + let completes = with_message(&events, "freshagent.turn.complete"); + let waits = with_message(&events, "freshagent.turn.waiting"); + assert_eq!(completes.len(), 2, "{completes:?}"); + assert_eq!(waits.len(), 1, "{waits:?}"); + let logged: Vec<(Option<&str>, Option<&str>)> = completes + .iter() + .chain(&waits) + .map(|event| (event.field("source"), event.field("at"))) + .collect(); + assert_eq!( + logged, + vec![ + (Some("freshclaude/sidecar"), Some("41")), + (Some("freshclaude/consumer-exit"), Some("43")), + (Some("freshclaude/sidecar"), Some("42")), + ] + ); + for event in completes.iter().chain(&waits) { + assert_eq!(event.level, tracing::Level::INFO); + assert_eq!(event.field("provider"), Some("claude")); + assert_eq!(event.field("session_id"), Some("envelope-1")); + assert_eq!(event.field("session_type"), Some("kilroy")); + } } /// Delta-review round 5 (AGENT-06): the `sdk.question.request` → @@ -11227,29 +11515,27 @@ pub(crate) mod tests { json!({ "type": "sdk.assistant", "sessionId": "s", "content": [{ "type": "text", "text": "part" }] }), // …process is SIGKILLed here — stdout ends. NO sdk.result, NO sdk.turn.complete. ]; - let frames: Vec = death_stream + // The consumer's per-line split: an edge line goes to the edge exit, + // anything else through the plain frame mapping. + let edge_of = |line: &Value| AttentionEdge::from_sidecar_type(line["type"].as_str()?); + let edges: Vec = death_stream.iter().filter_map(edge_of).collect(); + assert!( + edges.is_empty(), + "a death-truncated stream must never yield an attention edge, got {edges:?}" + ); + let inner_types: Vec = death_stream .iter() .filter_map(|l| sdk_line_to_frame(l, "s", "freshclaude")) - .map(|f| serde_json::from_str(&f).unwrap()) - .collect(); - let inner_types: Vec<&str> = frames - .iter() - .map(|f| f["event"]["type"].as_str().unwrap()) + .map(|f| serde_json::from_str::(&f).unwrap()["event"]["type"].to_string()) .collect(); assert!( - !inner_types.contains(&"freshAgent.turn.complete"), + !inner_types.iter().any(|t| t.contains("turn.complete")), "a death-truncated stream must never yield a completion chime, got {inner_types:?}" ); // And a subsequent success stream DOES complete — the edge is real, not disabled. - let ok = sdk_line_to_frame( - &json!({ "type": "sdk.turn.complete", "sessionId": "s", "at": 1 }), - "s", - "freshclaude", - ) - .unwrap(); assert_eq!( - serde_json::from_str::(&ok).unwrap()["event"]["type"], - "freshAgent.turn.complete" + edge_of(&json!({ "type": "sdk.turn.complete", "sessionId": "s", "at": 1 })), + Some(AttentionEdge::TurnComplete) ); } @@ -11264,6 +11550,7 @@ pub(crate) mod tests { /// same as Task 3). #[tokio::test] async fn sidecar_death_with_turn_in_flight_rings_the_unified_edge() { + let (log_events, _log_guard) = crate::claude_sidecar_io::test_capture::capture(); let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); let (st, mut rx) = state_with_bus(); @@ -11336,6 +11623,29 @@ pub(crate) mod tests { second > first, "the second death cycle's at ({second}) must strictly exceed the first ({first})" ); + // G14: each death edge is logged as it leaves, with its source and `at`. + let logged: Vec<(Option, Option, Option)> = + crate::claude_sidecar_io::test_capture::with_message( + &log_events, + "freshagent.turn.complete", + ) + .iter() + .map(|event| { + ( + event.field("source").map(str::to_string), + event.field("session_type").map(str::to_string), + event.field("at").map(str::to_string), + ) + }) + .collect(); + let expected = |at: i64| { + ( + Some("freshclaude/consumer-exit".to_string()), + Some("freshclaude".to_string()), + Some(at.to_string()), + ) + }; + assert_eq!(logged, vec![expected(first), expected(second)]); drop(env); } @@ -11373,6 +11683,172 @@ pub(crate) mod tests { state().shutdown().await; } + /// C04 (`test-support` seam): a configured sidecar environment REPLACES the + /// inherited one (only the ownership tag is layered on), so a test rig can + /// point the real sidecar at the CLI simulator and an isolated HOME without + /// touching this process's environment. Unconfigured, the sidecar inherits it. + #[tokio::test] + async fn a_configured_sidecar_environment_replaces_the_inherited_one() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("print-env.mjs"); + std::fs::write( + &script, + "process.stdout.write(JSON.stringify(process.env) + '\\n')\n", + ) + .unwrap(); + std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); + std::env::set_var("FRESHELL_TEST_INHERITED_MARKER", "inherited"); + + async fn child_env(state: &FreshClaudeState) -> (HashMap, String) { + // The tree guard lives until the child is reaped: dropping it kills the tree. + let SpawnedSidecar { + mut child, + stdout, + ownership_id, + tree_guard: _tree_guard, + .. + } = state + .spawn_sidecar_process("freshclaude") + .await + .expect("spawn sidecar"); + let line = BufReader::new(stdout) + .lines() + .next_line() + .await + .expect("read sidecar stdout") + .expect("the sidecar prints its environment"); + let _ = child.wait().await; + (serde_json::from_str(&line).expect("env json"), ownership_id) + } + let configured = state().with_sidecar_environment([ + ( + "PATH".to_string(), + std::env::var("PATH").unwrap_or_default(), + ), + ( + "FRESHELL_TEST_SIDECAR_ENV".to_string(), + "chosen".to_string(), + ), + ]); + let (env, ownership_id) = child_env(&configured).await; + let (inherited, _) = child_env(&state()).await; + std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); + std::env::remove_var("FRESHELL_TEST_INHERITED_MARKER"); + + let mut keys: Vec<&str> = env.keys().map(String::as_str).collect(); + keys.sort_unstable(); + assert_eq!( + keys, + vec![ + CLAUDE_SIDECAR_OWNERSHIP_ENV, + "FRESHELL_TEST_SIDECAR_ENV", + "PATH" + ], + "the configured environment replaces the inherited one: {env:?}" + ); + assert_eq!(env[CLAUDE_SIDECAR_OWNERSHIP_ENV], ownership_id); + assert_eq!( + inherited + .get("FRESHELL_TEST_INHERITED_MARKER") + .map(String::as_str), + Some("inherited"), + "without a configured environment the sidecar inherits this process's" + ); + } + + /// G14: every spawned sidecar's stderr is logged, labelled with its + /// ownership id, pid and session type. A sidecar whose environment marks + /// it hosted (the session host sets FRESHELL_HOSTED_FRESH_AGENT, to + /// `claude` or `kilroy`) has its raw lines logged by size only. + #[tokio::test] + async fn a_spawned_sidecar_stderr_is_logged_and_redacted_when_hosted() { + use crate::claude_sidecar_io::test_capture::{capture, with_message}; + let (events, _log_guard) = capture(); + let _guard = CLAUDE_ENV_LOCK.lock().await; + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("stderr-sidecar.mjs"); + std::fs::write( + &script, + "process.stderr.write('secret raw output\\n')\n\ + process.stderr.write(JSON.stringify({ severity: 'info', component: 'claude-sidecar', event: 'sidecar.ready' }) + '\\n')\n", + ) + .unwrap(); + std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); + + async fn spawn_and_log( + state: &FreshClaudeState, + events: &Arc>>, + ) -> (String, Option) { + let before = with_message(events, "freshagent.claude.sidecar_log").len(); + // The tree guard lives until the child is reaped: dropping it kills the tree. + let SpawnedSidecar { + mut child, + ownership_id, + tree_guard: _tree_guard, + .. + } = state + .spawn_sidecar_process("kilroy") + .await + .expect("spawn sidecar"); + let pid = child.id(); + let _ = child.wait().await; + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while with_message(events, "freshagent.claude.sidecar_log").len() < before + 2 { + assert!( + tokio::time::Instant::now() < deadline, + "the sidecar's two stderr lines were never logged" + ); + tokio::time::sleep(Duration::from_millis(5)).await; + } + (ownership_id, pid) + } + let path = ( + "PATH".to_string(), + std::env::var("PATH").unwrap_or_default(), + ); + let ordinary = state().with_sidecar_environment([path.clone()]); + let hosted = state().with_sidecar_environment([ + path, + ( + "FRESHELL_HOSTED_FRESH_AGENT".to_string(), + "kilroy".to_string(), + ), + ]); + let (ordinary_owner, ordinary_pid) = spawn_and_log(&ordinary, &events).await; + let (hosted_owner, _) = spawn_and_log(&hosted, &events).await; + std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); + + let logs = with_message(&events, "freshagent.claude.sidecar_log"); + let of = |owner: &str| -> Vec<_> { + logs.iter() + .filter(|event| event.field("ownership_id") == Some(owner)) + .cloned() + .collect() + }; + let ordinary_logs = of(&ordinary_owner); + assert_eq!(ordinary_logs.len(), 2, "{logs:?}"); + assert_eq!(ordinary_logs[0].level, tracing::Level::WARN); + assert_eq!(ordinary_logs[0].field("raw"), Some("true")); + assert_eq!(ordinary_logs[0].field("text"), Some("secret raw output")); + assert_eq!(ordinary_logs[1].level, tracing::Level::INFO); + assert_eq!( + ordinary_logs[1].field("sidecar_event"), + Some("sidecar.ready") + ); + let ordinary_pid = ordinary_pid.map(|pid| pid.to_string()); + for event in &ordinary_logs { + assert_eq!(event.field("session_type"), Some("kilroy")); + assert_eq!(event.field("pid"), ordinary_pid.as_deref()); + } + let hosted_logs = of(&hosted_owner); + assert_eq!(hosted_logs.len(), 2, "{logs:?}"); + assert_eq!(hosted_logs[0].field("raw"), Some("true")); + assert_eq!(hosted_logs[0].field("bytes"), Some("17")); + assert_eq!(hosted_logs[0].field("text"), None, "hosted: size only"); + assert_eq!(hosted_logs[1].field("sidecar_event"), Some("sidecar.ready")); + } + // ── freshAgent.create requestId dedup (parity gap fix) ────────────────── /// Serializes every test in this file that mutates process-global env vars @@ -11890,6 +12366,78 @@ rl.on('line', (line) => { ); } + /// F4 (PR-A review): before the CLI reports a new pane's session the + /// coordinator holds no record for it, and an unfenced (checkpointed) + /// send runs: refusing it would lose every new pane's first message + /// (827d9395d). A send that DOES carry an observed owner pair is still + /// checked: the pair names an owner the coordinator does not hold, so it + /// is stale and refused typed, never run unguarded. + #[tokio::test(flavor = "multi_thread")] + async fn a_fenced_send_before_the_session_report_is_checked_not_skipped() { + let (mut st, mut rx) = state_with_bus(); + let session_id = "claude-send-before-report"; + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); + st.set_ownership(registry.clone()); + insert_fake_claude_session(&st, session_id).await; + let vacant = registry.observe(PROVIDER, session_id); + assert!( + matches!(vacant.state, freshell_ownership::OwnershipState::Vacant), + "fixture: nothing owns an unreported session" + ); + let checkpointed = |request_id: &str| { + let mut send = send_msg(session_id, "first message"); + send.request_id = Some(request_id.to_string()); + send.checkpoint_intent = Some(freshell_protocol::FreshAgentCheckpointIntent { + logical_attempt_id: format!("{request_id}-attempt"), + workspace: None, + }); + send + }; + let drain = |rx: &mut tokio::sync::broadcast::Receiver| { + let mut frames: Vec = Vec::new(); + while let Ok(raw) = rx.try_recv() { + if let Ok(frame) = serde_json::from_str(&raw) { + frames.push(frame); + } + } + frames + }; + + let mut fenced = checkpointed("fenced-before-report"); + fenced.observed_epoch = Some(vacant.epoch); + fenced.observed_generation = Some(vacant.generation); + st.handle_send(fenced).await; + let frames = drain(&mut rx); + assert!( + frames.iter().any(|frame| { + frame["requestId"] == json!("fenced-before-report") + && frame.to_string().contains("SESSION_RESERVED") + }), + "the supplied fence is honored and refuses typed: {frames:?}" + ); + assert!( + !st.sessions.lock().await[session_id] + .in_turn + .load(std::sync::atomic::Ordering::SeqCst), + "the refused send never reaches the sidecar" + ); + + st.handle_send(checkpointed("unfenced-before-report")).await; + let frames = drain(&mut rx); + assert!( + !frames + .iter() + .any(|frame| frame.to_string().contains("SESSION_RESERVED")), + "an unfenced first send of a new pane runs: {frames:?}" + ); + assert!( + st.sessions.lock().await[session_id] + .in_turn + .load(std::sync::atomic::Ordering::SeqCst), + "the unfenced first send reaches the sidecar" + ); + } + /// The checkpointed send a new pane makes before the CLI reported its /// session: the browser's send shape (a checkpoint intent, no owner pair), /// addressed by the lane's placeholder id. @@ -20112,6 +20660,41 @@ rl.on('line', (line) => { drop(env); } + /// G14: a sidecar spawned to resume a session on attach logs under the + /// attached session (its stderr log is bound to the envelope id the + /// client addresses), joined to the sidecar's ownership id. + #[tokio::test] + async fn an_attach_resumed_sidecar_logs_under_the_attached_session() { + use crate::claude_sidecar_io::test_capture::{capture, with_message}; + let (events, _log_guard) = capture(); + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let home = tempfile::tempdir().unwrap(); + const DURABLE: &str = "d1d1d1d1-dddd-4ddd-8ddd-d1d1d1d1d1d1"; + write_fake_transcript(home.path(), DURABLE); + std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); + + let (state, _rx) = state_with_bus(); + let mut msg = attach_msg_with_resume("client-nanoid-logs", DURABLE); + msg.session_type = SessionType::Kilroy; + state.handle_attach(msg).await; + std::env::remove_var("CLAUDE_CONFIG_DIR"); + + let ownership_id = state + .sessions + .lock() + .await + .get("client-nanoid-logs") + .map(|session| session.ownership_id.clone()) + .expect("the attach resumed a session under the client's id"); + let bound = with_message(&events, "freshagent.claude.sidecar_bound"); + assert_eq!(bound.len(), 1, "{bound:?}"); + assert_eq!(bound[0].field("session_id"), Some("client-nanoid-logs")); + assert_eq!(bound[0].field("session_type"), Some("kilroy")); + assert_eq!(bound[0].field("ownership_id"), Some(ownership_id.as_str())); + drop(env); + } + /// A restarted server has the transcript and settings record, but its /// ownership coordinator has no live entry yet. Attach must claim that /// vacant key and resume the sidecar before it can publish a live owner. diff --git a/crates/freshell-freshagent/src/claude/teardown_tests.rs b/crates/freshell-freshagent/src/claude/teardown_tests.rs index 2f87c94a6c..19ab7cdd21 100644 --- a/crates/freshell-freshagent/src/claude/teardown_tests.rs +++ b/crates/freshell-freshagent/src/claude/teardown_tests.rs @@ -343,11 +343,15 @@ async fn spawn_sidecar_runs_the_sidecar_inside_its_own_process_group() { let script = dir.path().join("idle-sidecar.mjs"); std::fs::write(&script, "setInterval(() => {}, 1000)\n").unwrap(); std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - let spawned = spawn_sidecar().await; + let spawned = spawn_sidecar(None, "freshclaude").await; std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); - let (mut child, stdin, _stdout, _ownership_id, mut tree_guard) = - spawned.expect("spawn the sidecar"); + let SpawnedSidecar { + mut child, + stdin, + mut tree_guard, + .. + } = spawned.expect("spawn the sidecar"); let pid = child.id().expect("a live sidecar") as i32; assert_eq!( // SAFETY: getpgid only reads the process table. diff --git a/crates/freshell-freshagent/src/claude_sidecar_io.rs b/crates/freshell-freshagent/src/claude_sidecar_io.rs new file mode 100644 index 0000000000..8bc7ad44eb --- /dev/null +++ b/crates/freshell-freshagent/src/claude_sidecar_io.rs @@ -0,0 +1,1019 @@ +//! The claude sidecar's process I/O beside its stdout protocol (core-runtime +//! design §3.2, §5.9): [`spawn_stderr_logger`] turns the sidecar's stderr into +//! server log events (G14). +//! +//! The sidecar writes one JSON log record per stderr line +//! (`crates/freshell-claude-sidecar/log.mjs`: `{severity, component, event, +//! ...fields}`). Each record is re-emitted through `tracing` as +//! `freshagent.claude.sidecar_log` at the record's own severity, labelled with +//! the sidecar's ownership id, pid, session type and (once the sidecar has +//! named it) the session id. A line that is not a JSON record (a Node crash +//! trace, a stray print) is logged at warn with `raw = true`. +//! +//! Bounds, per sidecar: a line is read up to [`STDERR_LINE_MAX_BYTES`] (the +//! rest of a longer line is counted, not kept); a raw line's text is cut to +//! [`RAW_TEXT_MAX_BYTES`]; at most [`LINES_PER_WINDOW`] lines are logged per +//! [`RATE_WINDOW`], and the lines dropped beyond that are reported in one +//! `freshagent.claude.sidecar_log_dropped` warning when their window closes. +//! A hosted sidecar (the session host sets `FRESHELL_HOSTED_FRESH_AGENT`) +//! has its raw lines logged by byte count only; its records already carry +//! CLI output by size only (log.mjs). + +use std::sync::{Arc, Mutex as StdMutex, OnceLock}; +use std::time::Duration; + +use serde_json::{Map, Value}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncRead, BufReader}; +use tokio::time::Instant; + +const PROVIDER: &str = "claude"; + +/// Set by the hosted session host in a hosted sidecar's environment (to the +/// provider it hosts: `claude` or `kilroy`). +const HOSTED_ENV: &str = "FRESHELL_HOSTED_FRESH_AGENT"; + +/// The process label (`ownership_id`) the model catalog probe's log events carry. +pub(crate) const MODEL_CATALOG_LABEL: &str = "claude-model-catalog"; + +/// The longest stderr line kept whole. Records are far shorter (log.mjs cuts +/// CLI output to 2 KiB); a longer line is cut here and counted. +const STDERR_LINE_MAX_BYTES: usize = 16 * 1024; +/// The most bytes of a raw (non-JSON) line's text kept in its log event. +const RAW_TEXT_MAX_BYTES: usize = 4 * 1024; +/// Lines logged per [`RATE_WINDOW`] per sidecar. +const LINES_PER_WINDOW: u32 = 50; +/// The rate-limit window. +const RATE_WINDOW: Duration = Duration::from_secs(1); + +/// The bounds one stderr logger applies (tests shrink them). +#[derive(Clone, Copy, Debug)] +struct StderrLogLimits { + line_max_bytes: usize, + raw_text_max_bytes: usize, + lines_per_window: u32, + window: Duration, +} + +impl Default for StderrLogLimits { + fn default() -> Self { + Self { + line_max_bytes: STDERR_LINE_MAX_BYTES, + raw_text_max_bytes: RAW_TEXT_MAX_BYTES, + lines_per_window: LINES_PER_WINDOW, + window: RATE_WINDOW, + } + } +} + +/// Whether a sidecar spawned with `environment` (its complete environment, or +/// `None` to inherit this process's) runs hosted. +pub(crate) fn hosted_sidecar(environment: Option<&[(String, String)]>) -> bool { + let value = match environment { + Some(environment) => environment + .iter() + .rev() + .find(|(key, _)| key == HOSTED_ENV) + .map(|(_, value)| value.clone()), + None => std::env::var(HOSTED_ENV).ok(), + }; + value.is_some_and(|value| !value.is_empty()) +} + +/// A record's severity, as log.mjs writes it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Severity { + Trace, + Debug, + Info, + Warn, + Error, +} + +impl Severity { + fn parse(text: &str) -> Option { + match text.to_ascii_lowercase().as_str() { + "trace" => Some(Self::Trace), + "debug" => Some(Self::Debug), + "info" => Some(Self::Info), + "warn" | "warning" => Some(Self::Warn), + "error" | "fatal" => Some(Self::Error), + _ => None, + } + } +} + +/// The identity a sidecar's log events carry. The session id is bound late: +/// the sidecar starts (and logs `sidecar.ready`) before it names its session. +#[derive(Clone)] +pub(crate) struct SidecarLogLabels { + inner: Arc, +} + +struct LabelsInner { + ownership_id: String, + /// `None` for a sidecar process that serves no session (the model catalog probe). + session_type: Option, + pid: Option, + /// The session's envelope id handle (the id clients address it by; an + /// attach by durable id may change it), once the sidecar has named it. + session: OnceLock>>, +} + +impl SidecarLogLabels { + pub(crate) fn new(ownership_id: &str, session_type: &str, pid: Option) -> Self { + Self::with(ownership_id, Some(session_type), pid) + } + + /// Labels for the model catalog probe (`model-catalog.mjs`): a short-lived + /// sidecar process that serves no session, named by [`MODEL_CATALOG_LABEL`]. + pub(crate) fn model_catalog(pid: Option) -> Self { + Self::with(MODEL_CATALOG_LABEL, None, pid) + } + + fn with(ownership_id: &str, session_type: Option<&str>, pid: Option) -> Self { + Self { + inner: Arc::new(LabelsInner { + ownership_id: ownership_id.to_string(), + session_type: session_type.map(str::to_string), + pid, + session: OnceLock::new(), + }), + } + } + + fn ownership_id(&self) -> &str { + &self.inner.ownership_id + } + + fn session_type(&self) -> Option<&str> { + self.inner.session_type.as_deref() + } + + fn pid(&self) -> Option { + self.inner.pid + } + + /// Bind the session this sidecar serves, by its shared envelope id + /// handle: every later event carries the handle's current value. Logged + /// once, as the join between the sidecar's ownership id and its session. + pub(crate) fn bind_session(&self, envelope: &Arc>) { + if self.inner.session.set(Arc::clone(envelope)).is_ok() { + let session_id = self.session_id(); + tracing::info!( + provider = PROVIDER, + session_id = session_id.as_deref(), + session_type = self.session_type(), + ownership_id = %self.inner.ownership_id, + pid = self.inner.pid, + "freshagent.claude.sidecar_bound" + ); + } + } + + /// The bound session's current envelope id. + fn session_id(&self) -> Option { + self.inner.session.get().map(|envelope| { + envelope + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + }) + } +} + +/// One stderr line as read: at most the line limit of its bytes (no +/// terminator), and its full length. +#[derive(Debug, PartialEq, Eq)] +struct StderrLine { + bytes: Vec, + total_bytes: usize, +} + +impl StderrLine { + fn truncated(&self) -> bool { + self.total_bytes > self.bytes.len() + } +} + +/// One stderr line, classified. +#[derive(Debug, PartialEq, Eq)] +enum StderrRecord { + /// A JSON log record. `fields` is the record's remaining keys as compact + /// JSON. A missing or unknown severity is logged at warn (the unknown + /// value stays in `fields`). + Structured { + severity: Severity, + component: Option, + event: Option, + detail: Option, + fields: Option, + }, + /// A line that is not a JSON object. `text` is `None` for a hosted sidecar. + Raw { + text: Option, + bytes: usize, + truncated: bool, + }, +} + +/// Classify one line. A line cut by the reader is never parsed as a record. +fn classify_line(line: &StderrLine, hosted: bool, raw_text_max_bytes: usize) -> StderrRecord { + if !line.truncated() { + if let Ok(Value::Object(mut record)) = serde_json::from_slice::(&line.bytes) { + let severity = record + .get("severity") + .and_then(Value::as_str) + .and_then(Severity::parse); + let component = string_field(&record, "component"); + let event = string_field(&record, "event"); + let detail = string_field(&record, "message"); + // The rest stays in the sidecar's order (`retain` keeps it). + record.retain(|key, value| match key.as_str() { + "severity" => severity.is_none(), + "component" | "event" | "message" => !value.is_string(), + _ => true, + }); + let fields = (!record.is_empty()).then(|| Value::Object(record).to_string()); + return StderrRecord::Structured { + severity: severity.unwrap_or(Severity::Warn), + component, + event, + detail, + fields, + }; + } + } + let kept = line.bytes.len().min(raw_text_max_bytes); + StderrRecord::Raw { + text: (!hosted).then(|| utf8_prefix(&line.bytes, kept)), + bytes: line.total_bytes, + truncated: line.truncated() || kept < line.bytes.len(), + } +} + +fn string_field(record: &Map, key: &str) -> Option { + record.get(key).and_then(Value::as_str).map(str::to_string) +} + +/// The first `max` bytes as text, never ending inside a character (invalid +/// bytes become U+FFFD). +fn utf8_prefix(bytes: &[u8], max: usize) -> String { + let mut end = max.min(bytes.len()); + if end < bytes.len() { + // Back up over continuation bytes (10xxxxxx) to the cut character's start. + while end > 0 && (bytes[end] & 0xc0) == 0x80 { + end -= 1; + } + } + String::from_utf8_lossy(&bytes[..end]).into_owned() +} + +/// Reads lines of at most `max_bytes`, counting (not keeping) the rest of a +/// longer line. Everything consumed is held in `self` between awaits, and the +/// only await is `fill_buf`, so a `next_line` dropped mid-line (cancelled by +/// a `select!`) loses nothing. +struct LineReader { + max_bytes: usize, + pending: Vec, + total: usize, + started: bool, +} + +impl LineReader { + fn new(max_bytes: usize) -> Self { + Self { + max_bytes, + pending: Vec::new(), + total: 0, + started: false, + } + } + + /// The next line without its terminator (`\n` or `\r\n`); `None` at the + /// end of the stream. + async fn next_line( + &mut self, + reader: &mut R, + ) -> std::io::Result> { + loop { + let (used, ended) = { + let available = reader.fill_buf().await?; + if available.is_empty() { + return Ok(self.started.then(|| self.finish())); + } + let newline = available.iter().position(|&byte| byte == b'\n'); + let chunk = &available[..newline.unwrap_or(available.len())]; + let room = self.max_bytes.saturating_sub(self.pending.len()); + self.pending + .extend_from_slice(&chunk[..chunk.len().min(room)]); + self.total += chunk.len(); + self.started = true; + ( + chunk.len() + usize::from(newline.is_some()), + newline.is_some(), + ) + }; + reader.consume(used); + if ended { + return Ok(Some(self.finish())); + } + } + } + + fn finish(&mut self) -> StderrLine { + let mut bytes = std::mem::take(&mut self.pending); + let mut total_bytes = std::mem::replace(&mut self.total, 0); + self.started = false; + if total_bytes == bytes.len() && bytes.last() == Some(&b'\r') { + bytes.pop(); + total_bytes -= 1; + } + StderrLine { bytes, total_bytes } + } +} + +/// Lines per window, with a count of the lines dropped beyond it. +struct LineRateLimiter { + per_window: u32, + window: Duration, + window_start: Instant, + admitted: u32, + dropped: u64, +} + +impl LineRateLimiter { + fn new(per_window: u32, window: Duration, now: Instant) -> Self { + Self { + per_window, + window, + window_start: now, + admitted: 0, + dropped: 0, + } + } + + /// Whether a line read at `now` is logged. A line beyond the window's + /// allowance is dropped and counted. Take a due summary first + /// ([`Self::take_due_summary`]): a new window starts here. + fn admit(&mut self, now: Instant) -> bool { + if now >= self.window_start + self.window { + self.window_start = now; + self.admitted = 0; + } + if self.admitted < self.per_window { + self.admitted += 1; + true + } else { + self.dropped += 1; + false + } + } + + /// When the summary of the dropped lines is due: the end of their window. + fn summary_due(&self) -> Option { + (self.dropped > 0).then(|| self.window_start + self.window) + } + + /// The number of dropped lines, once their window has ended. + fn take_due_summary(&mut self, now: Instant) -> Option { + if self.summary_due().is_some_and(|due| now >= due) { + Some(std::mem::take(&mut self.dropped)) + } else { + None + } + } + + /// The dropped lines not yet summarized (the stream ended). + fn take_remaining(&mut self) -> Option { + (self.dropped > 0).then(|| std::mem::take(&mut self.dropped)) + } +} + +/// Re-emit a sidecar's stderr through `tracing` (see the module docs) on a task +/// that ends with the stream, when the sidecar exits. The handle lets a caller +/// that waits for the process also wait for its last records. +pub(crate) fn spawn_stderr_logger( + stderr: R, + labels: SidecarLogLabels, + hosted: bool, +) -> tokio::task::JoinHandle<()> +where + R: AsyncRead + Unpin + Send + 'static, +{ + tokio::spawn(run_stderr_logger( + stderr, + labels, + hosted, + StderrLogLimits::default(), + )) +} + +async fn run_stderr_logger( + stderr: R, + labels: SidecarLogLabels, + hosted: bool, + limits: StderrLogLimits, +) where + R: AsyncRead + Unpin, +{ + let mut reader = BufReader::new(stderr); + let mut lines = LineReader::new(limits.line_max_bytes); + let mut limiter = LineRateLimiter::new(limits.lines_per_window, limits.window, Instant::now()); + loop { + let summary_due = limiter.summary_due(); + let read = tokio::select! { + biased; + read = lines.next_line(&mut reader) => read, + () = sleep_until_due(summary_due) => { + if let Some(dropped) = limiter.take_due_summary(Instant::now()) { + emit_dropped(&labels, dropped, limits.window); + } + continue; + } + }; + let line = match read { + Ok(Some(line)) => line, + Ok(None) => break, + Err(error) => { + tracing::warn!( + provider = PROVIDER, + ownership_id = %labels.ownership_id(), + error = %error, + "freshagent.claude.sidecar_log_read_failed" + ); + break; + } + }; + if !line.truncated() && line.bytes.iter().all(u8::is_ascii_whitespace) { + continue; + } + let now = Instant::now(); + if let Some(dropped) = limiter.take_due_summary(now) { + emit_dropped(&labels, dropped, limits.window); + } + if limiter.admit(now) { + emit_record( + &labels, + classify_line(&line, hosted, limits.raw_text_max_bytes), + ); + } + } + if let Some(dropped) = limiter.take_remaining() { + emit_dropped(&labels, dropped, limits.window); + } +} + +/// Resolves at `due`; never, when nothing is due. +async fn sleep_until_due(due: Option) { + match due { + Some(due) => tokio::time::sleep_until(due).await, + None => std::future::pending().await, + } +} + +/// Emit one `tracing` event at a [`Severity`] chosen at run time (the +/// `tracing` macros take the level as a constant). +macro_rules! event_at { + ($severity:expr, $($rest:tt)+) => { + match $severity { + Severity::Trace => tracing::trace!($($rest)+), + Severity::Debug => tracing::debug!($($rest)+), + Severity::Info => tracing::info!($($rest)+), + Severity::Warn => tracing::warn!($($rest)+), + Severity::Error => tracing::error!($($rest)+), + } + }; +} + +fn emit_record(labels: &SidecarLogLabels, record: StderrRecord) { + let session_id = labels.session_id(); + match record { + StderrRecord::Structured { + severity, + component, + event, + detail, + fields, + } => event_at!( + severity, + provider = PROVIDER, + session_id = session_id.as_deref(), + session_type = labels.session_type(), + ownership_id = %labels.ownership_id(), + pid = labels.pid(), + component = component.as_deref(), + sidecar_event = event.as_deref(), + detail = detail.as_deref(), + fields = fields.as_deref(), + "freshagent.claude.sidecar_log" + ), + StderrRecord::Raw { + text, + bytes, + truncated, + } => tracing::warn!( + provider = PROVIDER, + session_id = session_id.as_deref(), + session_type = labels.session_type(), + ownership_id = %labels.ownership_id(), + pid = labels.pid(), + raw = true, + bytes, + truncated, + text = text.as_deref(), + "freshagent.claude.sidecar_log" + ), + } +} + +fn emit_dropped(labels: &SidecarLogLabels, dropped: u64, window: Duration) { + let session_id = labels.session_id(); + tracing::warn!( + provider = PROVIDER, + session_id = session_id.as_deref(), + session_type = labels.session_type(), + ownership_id = %labels.ownership_id(), + pid = labels.pid(), + dropped, + window_ms = window.as_millis() as u64, + "freshagent.claude.sidecar_log_dropped" + ); +} + +/// A thread-local `tracing` capture for this crate's log tests (level, +/// message and fields of every event). +#[cfg(test)] +pub(crate) mod test_capture { + use std::collections::BTreeMap; + use std::sync::{Arc, Mutex as StdMutex}; + use tracing::field::{Field, Visit}; + use tracing::{Event, Level, Subscriber}; + use tracing_subscriber::layer::{Context, SubscriberExt}; + use tracing_subscriber::Layer; + + #[derive(Clone, Debug)] + pub(crate) struct Captured { + pub level: Level, + pub message: String, + pub fields: BTreeMap, + } + + impl Captured { + pub(crate) fn field(&self, name: &str) -> Option<&str> { + self.fields.get(name).map(String::as_str) + } + } + + #[derive(Default)] + struct Visitor { + message: String, + fields: BTreeMap, + } + + impl Visit for Visitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + let rendered = format!("{value:?}"); + if field.name() == "message" { + self.message = rendered; + } else { + self.fields.insert(field.name().to_string(), rendered); + } + } + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.message = value.to_string(); + } else { + self.fields + .insert(field.name().to_string(), value.to_string()); + } + } + } + + struct CaptureLayer(Arc>>); + + impl Layer for CaptureLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let mut visitor = Visitor::default(); + event.record(&mut visitor); + self.0.lock().expect("capture lock").push(Captured { + level: *event.metadata().level(), + message: visitor.message, + fields: visitor.fields, + }); + } + } + + /// Capture this thread's events until the guard drops. On a current-thread + /// runtime every task a test spawns emits on this thread. + pub(crate) fn capture() -> ( + Arc>>, + tracing::subscriber::DefaultGuard, + ) { + let events = Arc::new(StdMutex::new(Vec::new())); + let subscriber = tracing_subscriber::registry().with(CaptureLayer(Arc::clone(&events))); + (events, tracing::subscriber::set_default(subscriber)) + } + + /// The captured events with this message. + pub(crate) fn with_message( + events: &Arc>>, + message: &str, + ) -> Vec { + events + .lock() + .expect("capture lock") + .iter() + .filter(|event| event.message == message) + .cloned() + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::test_capture::{capture, with_message, Captured}; + use super::*; + use tokio::io::AsyncWriteExt; + use tracing::Level; + + fn sidecar_logs(events: &Arc>>) -> Vec { + with_message(events, "freshagent.claude.sidecar_log") + } + + fn dropped_summaries(events: &Arc>>) -> Vec { + with_message(events, "freshagent.claude.sidecar_log_dropped") + } + + fn labels() -> SidecarLogLabels { + SidecarLogLabels::new("claude-sidecar-test-owner", "kilroy", Some(4242)) + } + + /// Run the logger over `input` (then end of stream) to completion. + async fn log_stderr( + input: &[u8], + labels: SidecarLogLabels, + hosted: bool, + limits: StderrLogLimits, + ) { + let (mut writer, reader) = tokio::io::duplex(input.len() + 1); + writer.write_all(input).await.expect("write stderr"); + drop(writer); + run_stderr_logger(reader, labels, hosted, limits).await; + } + + #[test] + fn severities_parse_as_log_mjs_writes_them() { + assert_eq!(Severity::parse("debug"), Some(Severity::Debug)); + assert_eq!(Severity::parse("info"), Some(Severity::Info)); + assert_eq!(Severity::parse("warn"), Some(Severity::Warn)); + assert_eq!(Severity::parse("error"), Some(Severity::Error)); + assert_eq!(Severity::parse("WARNING"), Some(Severity::Warn)); + assert_eq!(Severity::parse("fatal"), Some(Severity::Error)); + assert_eq!(Severity::parse("trace"), Some(Severity::Trace)); + assert_eq!(Severity::parse("loud"), None); + } + + #[test] + fn a_hosted_sidecar_is_one_whose_environment_names_a_hosted_agent() { + let env = |value: &str| vec![(HOSTED_ENV.to_string(), value.to_string())]; + assert!(hosted_sidecar(Some(&env("claude")))); + assert!(hosted_sidecar(Some(&env("kilroy")))); + assert!(!hosted_sidecar(Some(&env("")))); + assert!(!hosted_sidecar(Some(&[( + "HOME".to_string(), + "/h".to_string() + )]))); + } + + #[test] + fn labels_carry_the_bound_session_and_follow_its_envelope() { + let labels = labels(); + assert_eq!( + labels.session_id(), + None, + "unbound until the sidecar names its session" + ); + let envelope = Arc::new(StdMutex::new("placeholder-id".to_string())); + labels.bind_session(&envelope); + assert_eq!(labels.session_id().as_deref(), Some("placeholder-id")); + *envelope.lock().unwrap() = "durable-id".to_string(); + assert_eq!(labels.session_id().as_deref(), Some("durable-id")); + // A second bind changes nothing: one sidecar serves one session. + labels.bind_session(&Arc::new(StdMutex::new("other".to_string()))); + assert_eq!(labels.session_id().as_deref(), Some("durable-id")); + } + + #[test] + fn binding_logs_the_join_between_ownership_and_session_once() { + let (events, _guard) = capture(); + let labels = labels(); + let envelope = Arc::new(StdMutex::new("session-a".to_string())); + labels.bind_session(&envelope); + labels.bind_session(&envelope); + let bound = with_message(&events, "freshagent.claude.sidecar_bound"); + assert_eq!(bound.len(), 1, "{bound:?}"); + assert_eq!(bound[0].level, Level::INFO); + assert_eq!(bound[0].field("session_id"), Some("session-a")); + assert_eq!( + bound[0].field("ownership_id"), + Some("claude-sidecar-test-owner") + ); + assert_eq!(bound[0].field("session_type"), Some("kilroy")); + assert_eq!(bound[0].field("pid"), Some("4242")); + } + + #[test] + fn the_limiter_admits_a_window_of_lines_and_summarizes_the_rest_when_it_closes() { + let start = Instant::now(); + let window = Duration::from_millis(100); + let mut limiter = LineRateLimiter::new(2, window, start); + assert!(limiter.admit(start)); + assert!(limiter.admit(start + Duration::from_millis(10))); + assert!(!limiter.admit(start + Duration::from_millis(20))); + assert!(!limiter.admit(start + Duration::from_millis(30))); + assert_eq!(limiter.summary_due(), Some(start + window)); + assert_eq!( + limiter.take_due_summary(start + Duration::from_millis(99)), + None + ); + assert_eq!(limiter.take_due_summary(start + window), Some(2)); + assert_eq!(limiter.summary_due(), None); + // The next window starts with a fresh allowance. + assert!(limiter.admit(start + Duration::from_millis(150))); + assert!(limiter.admit(start + Duration::from_millis(160))); + assert!(!limiter.admit(start + Duration::from_millis(170))); + assert_eq!(limiter.take_remaining(), Some(1)); + assert_eq!(limiter.take_remaining(), None); + } + + #[tokio::test] + async fn records_are_reemitted_at_their_own_severity_with_the_sidecar_labels() { + let (events, _guard) = capture(); + let input = [ + r#"{"severity":"info","component":"claude-sidecar","event":"sidecar.ready","pid":4242}"#, + r#"{"severity":"debug","component":"claude-sidecar","event":"x.debugged"}"#, + r#"{"severity":"warn","component":"claude-sidecar","event":"interrupt.failed","sessionId":"s-1","error":"no"}"#, + r#"{"severity":"error","component":"claude-cli-sim","event":"simulator_failure","message":"boom"}"#, + r#"{"severity":"loud","component":"claude-sidecar","event":"x.odd"}"#, + r#"{"component":"claude-sidecar","event":"x.unlabelled"}"#, + ] + .map(|line| format!("{line}\n")) + .concat(); + log_stderr( + input.as_bytes(), + labels(), + false, + StderrLogLimits::default(), + ) + .await; + + let logs = sidecar_logs(&events); + let summary: Vec<(Level, Option<&str>)> = logs + .iter() + .map(|event| (event.level, event.field("sidecar_event"))) + .collect(); + assert_eq!( + summary, + vec![ + (Level::INFO, Some("sidecar.ready")), + (Level::DEBUG, Some("x.debugged")), + (Level::WARN, Some("interrupt.failed")), + (Level::ERROR, Some("simulator_failure")), + (Level::WARN, Some("x.odd")), + (Level::WARN, Some("x.unlabelled")), + ] + ); + for event in &logs { + assert_eq!(event.field("provider"), Some("claude")); + assert_eq!( + event.field("ownership_id"), + Some("claude-sidecar-test-owner") + ); + assert_eq!(event.field("session_type"), Some("kilroy")); + assert_eq!(event.field("pid"), Some("4242")); + assert_eq!(event.field("session_id"), None, "no session bound yet"); + } + assert_eq!(logs[0].field("component"), Some("claude-sidecar")); + assert_eq!(logs[0].field("fields"), Some(r#"{"pid":4242}"#)); + assert_eq!( + logs[2].field("fields"), + Some(r#"{"sessionId":"s-1","error":"no"}"#) + ); + assert_eq!(logs[3].field("component"), Some("claude-cli-sim")); + assert_eq!(logs[3].field("detail"), Some("boom")); + assert_eq!( + logs[4].field("fields"), + Some(r#"{"severity":"loud"}"#), + "an unknown severity stays visible" + ); + assert_eq!(logs[1].field("fields"), None); + } + + #[tokio::test] + async fn records_after_the_session_is_bound_carry_its_id() { + let (events, _guard) = capture(); + let labels = labels(); + let (mut writer, reader) = tokio::io::duplex(4096); + let logger = tokio::spawn(run_stderr_logger( + reader, + labels.clone(), + false, + StderrLogLimits::default(), + )); + writer + .write_all(b"{\"severity\":\"info\",\"event\":\"sidecar.ready\"}\n") + .await + .unwrap(); + wait_until(|| !sidecar_logs(&events).is_empty()).await; + labels.bind_session(&Arc::new(StdMutex::new("session-b".to_string()))); + writer + .write_all(b"{\"severity\":\"warn\",\"event\":\"configure.failed\"}\n") + .await + .unwrap(); + drop(writer); + logger.await.unwrap(); + let logs = sidecar_logs(&events); + assert_eq!(logs.len(), 2, "{logs:?}"); + assert_eq!(logs[0].field("session_id"), None); + assert_eq!(logs[1].field("session_id"), Some("session-b")); + assert_eq!(logs[1].level, Level::WARN); + } + + #[tokio::test] + async fn a_line_that_is_not_a_record_is_a_raw_warning_cut_to_4_kib() { + let (events, _guard) = capture(); + let long = "x".repeat(10 * 1024); + let input = format!("Error: node crashed\r\n{long}\n\"a json string\"\n42\n"); + log_stderr( + input.as_bytes(), + labels(), + false, + StderrLogLimits::default(), + ) + .await; + let logs = sidecar_logs(&events); + assert_eq!(logs.len(), 4, "{logs:?}"); + for event in &logs { + assert_eq!(event.level, Level::WARN); + assert_eq!(event.field("raw"), Some("true")); + assert_eq!( + event.field("ownership_id"), + Some("claude-sidecar-test-owner") + ); + } + assert_eq!(logs[0].field("text"), Some("Error: node crashed")); + assert_eq!(logs[0].field("bytes"), Some("19")); + assert_eq!(logs[0].field("truncated"), Some("false")); + assert_eq!( + logs[1].field("text").map(str::len), + Some(RAW_TEXT_MAX_BYTES) + ); + assert_eq!(logs[1].field("bytes"), Some("10240")); + assert_eq!(logs[1].field("truncated"), Some("true")); + assert_eq!(logs[2].field("text"), Some("\"a json string\"")); + assert_eq!(logs[3].field("text"), Some("42")); + } + + #[tokio::test] + async fn a_hosted_sidecar_raw_line_is_logged_by_size_only() { + let (events, _guard) = capture(); + let input = "secret provider output\n{\"severity\":\"info\",\"event\":\"cli.stderr\",\"bytes\":9}\n"; + log_stderr(input.as_bytes(), labels(), true, StderrLogLimits::default()).await; + let logs = sidecar_logs(&events); + assert_eq!(logs.len(), 2, "{logs:?}"); + assert_eq!(logs[0].field("raw"), Some("true")); + assert_eq!(logs[0].field("bytes"), Some("22")); + assert_eq!( + logs[0].field("text"), + None, + "a hosted raw line keeps no text" + ); + assert!( + !format!("{logs:?}").contains("secret provider output"), + "{logs:?}" + ); + assert_eq!(logs[1].level, Level::INFO); + assert_eq!(logs[1].field("sidecar_event"), Some("cli.stderr")); + } + + #[tokio::test] + async fn an_overlong_line_is_cut_and_counted_and_the_next_line_still_parses() { + let (events, _guard) = capture(); + let overlong = format!( + "{{\"severity\":\"info\",\"event\":\"x\",\"pad\":\"{}\"}}", + "y".repeat(40 * 1024) + ); + let input = format!("{overlong}\n{{\"severity\":\"error\",\"event\":\"after\"}}\n"); + log_stderr( + input.as_bytes(), + labels(), + false, + StderrLogLimits::default(), + ) + .await; + let logs = sidecar_logs(&events); + assert_eq!(logs.len(), 2, "{logs:?}"); + assert_eq!( + logs[0].level, + Level::WARN, + "a cut line is raw, not a record" + ); + assert_eq!(logs[0].field("raw"), Some("true")); + assert_eq!( + logs[0].field("bytes"), + Some(overlong.len().to_string().as_str()) + ); + assert_eq!(logs[0].field("truncated"), Some("true")); + assert_eq!( + logs[0].field("text").map(str::len), + Some(RAW_TEXT_MAX_BYTES) + ); + assert_eq!(logs[1].level, Level::ERROR); + assert_eq!(logs[1].field("sidecar_event"), Some("after")); + } + + #[tokio::test] + async fn a_flood_is_limited_to_50_lines_a_second_with_a_dropped_lines_summary() { + let (events, _guard) = capture(); + let input: String = (0..60) + .map(|n| format!("{{\"severity\":\"info\",\"event\":\"line.{n}\"}}\n")) + .collect(); + log_stderr( + input.as_bytes(), + labels(), + false, + StderrLogLimits::default(), + ) + .await; + let logs = sidecar_logs(&events); + assert_eq!(logs.len(), 50); + assert_eq!(logs[49].field("sidecar_event"), Some("line.49")); + let dropped = dropped_summaries(&events); + assert_eq!(dropped.len(), 1, "{dropped:?}"); + assert_eq!(dropped[0].level, Level::WARN); + assert_eq!(dropped[0].field("dropped"), Some("10")); + assert_eq!( + dropped[0].field("ownership_id"), + Some("claude-sidecar-test-owner") + ); + assert_eq!(dropped[0].field("window_ms"), Some("1000")); + } + + #[tokio::test] + async fn the_dropped_lines_summary_is_logged_when_its_window_closes() { + let (events, _guard) = capture(); + let limits = StderrLogLimits { + lines_per_window: 2, + window: Duration::from_millis(100), + ..StderrLogLimits::default() + }; + let (mut writer, reader) = tokio::io::duplex(4096); + let logger = tokio::spawn(run_stderr_logger(reader, labels(), false, limits)); + for n in 0..5 { + writer + .write_all(format!("{{\"severity\":\"info\",\"event\":\"line.{n}\"}}\n").as_bytes()) + .await + .unwrap(); + } + // The stream stays open: the summary comes from the window closing. + wait_until(|| !dropped_summaries(&events).is_empty()).await; + let dropped = dropped_summaries(&events); + assert_eq!(dropped[0].field("dropped"), Some("3")); + assert_eq!(dropped[0].field("window_ms"), Some("100")); + assert_eq!(sidecar_logs(&events).len(), 2); + // A later line in a new window is logged again. + writer + .write_all(b"{\"severity\":\"info\",\"event\":\"line.later\"}\n") + .await + .unwrap(); + drop(writer); + logger.await.unwrap(); + let logs = sidecar_logs(&events); + assert_eq!(logs.len(), 3, "{logs:?}"); + assert_eq!(logs[2].field("sidecar_event"), Some("line.later")); + assert_eq!(dropped_summaries(&events).len(), 1); + } + + #[tokio::test] + async fn blank_lines_and_a_final_unterminated_line_are_handled() { + let (events, _guard) = capture(); + log_stderr( + b"\n \n{\"severity\":\"info\",\"event\":\"last\"}", + labels(), + false, + StderrLogLimits::default(), + ) + .await; + let logs = sidecar_logs(&events); + assert_eq!(logs.len(), 1, "{logs:?}"); + assert_eq!(logs[0].field("sidecar_event"), Some("last")); + } + + async fn wait_until(mut done: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(10); + while !done() { + assert!(Instant::now() < deadline, "timed out"); + tokio::time::sleep(Duration::from_millis(5)).await; + } + } +} diff --git a/crates/freshell-freshagent/src/claude_spawn.rs b/crates/freshell-freshagent/src/claude_spawn.rs index 4bee371e4e..5718e479dc 100644 --- a/crates/freshell-freshagent/src/claude_spawn.rs +++ b/crates/freshell-freshagent/src/claude_spawn.rs @@ -160,6 +160,7 @@ pub struct ClaudeNodeCommand { managed_environment: bool, ownership_tag: Option, closed_stdin: bool, + complete_environment: Option>, } impl ClaudeNodeCommand { @@ -190,6 +191,7 @@ impl ClaudeNodeCommand { managed_environment: false, ownership_tag: None, closed_stdin: false, + complete_environment: None, } } @@ -214,6 +216,15 @@ impl ClaudeNodeCommand { self } + /// When `Some`, the child's complete environment: it replaces the one the + /// child would inherit from this process (the test rigs give each sidecar + /// exactly its own). The ownership tag, the OneCLI key flag and the managed + /// secrets still layer over it. `None` keeps the inherited environment. + pub fn with_complete_environment(mut self, environment: Option<&[(String, String)]>) -> Self { + self.complete_environment = environment.map(<[(String, String)]>::to_vec); + self + } + /// The child reads no stdin (the catalog probe); the default is a pipe. pub fn with_closed_stdin(mut self) -> Self { self.closed_stdin = true; @@ -254,6 +265,10 @@ impl ClaudeNodeCommand { None => Command::new(&self.node), }; command.arg(&self.entry); + if let Some(environment) = &self.complete_environment { + command.env_clear(); + command.envs(environment.iter().map(|(key, value)| (key, value))); + } if let Some(ownership_id) = &self.ownership_tag { command.env(CLAUDE_SIDECAR_OWNERSHIP_ENV, ownership_id); } @@ -878,6 +893,59 @@ mod tests { ); } + /// The test rigs give each sidecar exactly its own environment: a + /// configured environment replaces the inherited one, and the ownership + /// tag, the OneCLI key flag and the managed secrets still layer over it. + #[test] + fn a_complete_environment_replaces_the_inherited_one_under_the_overlay() { + let env = env_from(&[]); + let configured = [ + ("HOME".to_string(), "/rig/home".to_string()), + ("ONECLI_URL".to_string(), "http://configured".to_string()), + ]; + let command = crate::claude::sidecar_node_command( + ClaudeNodeCommand::from_parts("/srv/sidecar/index.mjs", &env, managed_secrets()), + "claude-sidecar-rig", + ) + .with_complete_environment(Some(&configured)) + .command(); + + let env = envs(&command); + assert_eq!(env.get("HOME"), Some(&Some("/rig/home".to_string()))); + assert_eq!( + env.get(CLAUDE_SIDECAR_OWNERSHIP_ENV), + Some(&Some("claude-sidecar-rig".to_string())) + ); + assert_eq!( + env.get("ONECLI_URL"), + Some(&Some("http://127.0.0.1:10254".to_string())), + "the managed secrets win over the configured environment" + ); + assert_eq!( + env.get("FRESHELL_CLAUDE_ONECLI_API_KEY"), + Some(&Some("1".to_string())) + ); + } + + /// Nothing of the server's own environment reaches a child launched with + /// a complete environment (here `env -0` stands in for node and prints + /// what the child got). + #[cfg(unix)] + #[tokio::test] + async fn a_child_with_a_complete_environment_inherits_nothing() { + let env = env_from(&[("FRESHELL_CLAUDE_NODE", "/usr/bin/env")]); + let configured = [("HOME".to_string(), "/rig/home".to_string())]; + let output = ClaudeNodeCommand::from_parts("-0", &env, None) + .with_complete_environment(Some(&configured)) + .with_closed_stdin() + .command() + .output() + .await + .expect("run env"); + assert!(output.status.success(), "{output:?}"); + assert_eq!(String::from_utf8_lossy(&output.stdout), "HOME=/rig/home\0"); + } + #[test] fn the_run_as_wrapper_needs_both_ids() { let env = env_from(&[("FRESHELL_PROVIDER_RUN_AS_UID", "1234")]); diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index d0297a44fd..f92c8df0eb 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -38,6 +38,7 @@ pub mod claude; pub(crate) mod claude_projection; +pub(crate) mod claude_sidecar_io; pub(crate) mod claude_snapshot; pub mod claude_spawn; pub mod codex; diff --git a/crates/freshell-freshagent/src/model_capabilities.rs b/crates/freshell-freshagent/src/model_capabilities.rs index d18067d12c..8a558276ef 100644 --- a/crates/freshell-freshagent/src/model_capabilities.rs +++ b/crates/freshell-freshagent/src/model_capabilities.rs @@ -202,24 +202,58 @@ impl ModelCatalogProbe for ClaudeCatalogProbe { .map_err(|error| { claude_probe_error(format!("Cannot start Claude model catalog: {error}")) })?; + let pid = child.id(); + // The helper's stderr is the sidecar's JSONL log (log.mjs): the same + // logger as a session sidecar re-emits each record at its severity, + // bounded and rate-limited (G14, design §5.9). + let stderr_logger = child.stderr.take().map(|stderr| { + crate::claude_sidecar_io::spawn_stderr_logger( + stderr, + crate::claude_sidecar_io::SidecarLogLabels::model_catalog(pid), + crate::claude_sidecar_io::hosted_sidecar(None), + ) + }); + // On a timeout: what the tree guard established about the killed tree. + let mut timed_out_tree = None; let output = match tokio::time::timeout(self.timeout, collect_child_output(&mut child)).await { - Ok(result) => result.map_err(|error| claude_probe_error(error.to_string()))?, + Ok(result) => result.map_err(|error| claude_probe_error(error.to_string())), Err(_) => { // The tree guard owns the probe's SDK-spawned CLI on every // platform, so a hung SDK close cannot leave it behind. - let tree = tree_guard.terminate(&mut child).await; - tracing::warn!( - component = "claude_model_catalog", - tree = ?tree, - "Claude model catalog process timed out" - ); - return Err(claude_probe_error("Claude model catalog process timed out")); + timed_out_tree = Some(tree_guard.terminate(&mut child).await); + Err(claude_probe_error("Claude model catalog process timed out")) } }; + // The process is gone, so its stderr ends: let its last records + // land before the outcome is logged. + if let Some(logger) = stderr_logger { + let _ = tokio::time::timeout(Duration::from_secs(1), logger).await; + } + let output = match output { + Ok(output) => output, + Err(error) => { + tracing::warn!( + provider = "claude", + component = crate::claude_sidecar_io::MODEL_CATALOG_LABEL, + pid, + error = %error.message, + tree = ?timed_out_tree, + "freshagent.claude.model_catalog_unavailable" + ); + return Err(error); + } + }; if !output.status.success() { - let details = String::from_utf8_lossy(&output.stderr); - tracing::warn!(component = "claude_model_catalog", status = ?output.status, stderr = %details, "Claude model catalog unavailable"); + tracing::warn!( + provider = "claude", + component = crate::claude_sidecar_io::MODEL_CATALOG_LABEL, + pid, + status = %output.status, + see = "the helper's own freshagent.claude.sidecar_log records \ + (ownership_id claude-model-catalog) say why", + "freshagent.claude.model_catalog_unavailable" + ); return Err(claude_probe_error("Claude model catalog is unavailable. Check Claude authentication and the server logs.")); } let raw: Value = serde_json::from_slice(&output.stdout).map_err(|error| { diff --git a/crates/freshell-freshagent/src/model_capabilities_tests.rs b/crates/freshell-freshagent/src/model_capabilities_tests.rs index 2e9c5e9de4..71919bba01 100644 --- a/crates/freshell-freshagent/src/model_capabilities_tests.rs +++ b/crates/freshell-freshagent/src/model_capabilities_tests.rs @@ -414,17 +414,59 @@ fn claude_catalog_keeps_live_effort_choices_and_deduplicates_models() { assert!(normalize_claude_catalog(json!([{"displayName": "No id"}])).is_err()); } +/// Stage a copy of the sidecar package in `directory` (the catalog helper +/// imports its siblings), with a stub in place of the session entry and +/// `sdk_source` as the SDK module named `sdk_name`, for +/// `FRESHELL_CLAUDE_SDK_QUERY_MODULE=./`. Returns the stub entry. +fn stage_catalog_sidecar(directory: &std::path::Path, sdk_name: &str, sdk_source: &str) -> PathBuf { + let package = PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../freshell-claude-sidecar" + )); + for module in std::fs::read_dir(&package).expect("read the sidecar package") { + let module = module.expect("sidecar package entry").path(); + if module + .extension() + .is_some_and(|extension| extension == "mjs") + { + let name = module.file_name().expect("module file name"); + std::fs::copy(&module, directory.join(name)).expect("copy a sidecar module"); + } + } + assert!( + directory.join("model-catalog.mjs").is_file(), + "the catalog helper was copied" + ); + let sidecar_entry = directory.join("index.mjs"); + std::fs::write(&sidecar_entry, "export {}\n").expect("write sidecar entry"); + std::fs::write(directory.join(sdk_name), sdk_source).expect("write fake SDK module"); + sidecar_entry +} + +/// Runs the catalog probe against a staged copy of the sidecar package +/// ([`stage_catalog_sidecar`]) and returns the probe's answer. +async fn probe_relocated_catalog(sdk_name: &str, sdk_source: &str) -> CatalogOut { + let directory = tempfile::tempdir().expect("temporary sidecar directory"); + let sidecar_entry = stage_catalog_sidecar(directory.path(), sdk_name, sdk_source); + + std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &sidecar_entry); + std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); + std::env::set_var("FRESHELL_CLAUDE_SDK_QUERY_MODULE", format!("./{sdk_name}")); + let result = ClaudeCatalogProbe::default().probe(None).await; + std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); + std::env::remove_var("FRESHELL_CLAUDE_NODE"); + std::env::remove_var("FRESHELL_CLAUDE_SDK_QUERY_MODULE"); + result +} + /// The installed Electron runtime relocates the sidecar away from the source /// checkout. A relative SDK seam must therefore be resolved by the copied /// model-catalog helper, not by the source-tree helper baked into the binary. #[tokio::test] async fn claude_catalog_probe_uses_the_configured_sidecar_directory() { let _guard = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let directory = tempfile::tempdir().expect("temporary sidecar directory"); - let sdk_name = "task-b-relocated-sdk.mjs"; - let sidecar_entry = stage_catalog_sidecar( - directory.path(), - sdk_name, + let result = probe_relocated_catalog( + "task-b-relocated-sdk.mjs", r#" export function query() { return { @@ -438,15 +480,8 @@ export function query() { } } "#, - ); - - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &sidecar_entry); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FRESHELL_CLAUDE_SDK_QUERY_MODULE", format!("./{sdk_name}")); - let result = ClaudeCatalogProbe::default().probe(None).await; - std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); - std::env::remove_var("FRESHELL_CLAUDE_NODE"); - std::env::remove_var("FRESHELL_CLAUDE_SDK_QUERY_MODULE"); + ) + .await; let models = result.expect("configured sidecar catalog should be probed"); assert_eq!(models.len(), 1); @@ -456,22 +491,86 @@ export function query() { assert!(models[0].supports_adaptive_thinking); } -/// Stage a relocated sidecar directory holding the real catalog helper (and -/// the modules it imports) plus a fake SDK module; returns the index path. -fn stage_catalog_sidecar(directory: &std::path::Path, sdk_name: &str, sdk_source: &str) -> PathBuf { - for module in ["model-catalog.mjs", "exec-profile.mjs", "sdk-env.mjs"] { - std::fs::copy( - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../freshell-claude-sidecar") - .join(module), - directory.join(module), - ) - .unwrap_or_else(|error| panic!("copy {module}: {error}")); - } - std::fs::write(directory.join(sdk_name), sdk_source).expect("write fake SDK module"); - let sidecar_entry = directory.join("index.mjs"); - std::fs::write(&sidecar_entry, "export {}\n").expect("write sidecar entry"); - sidecar_entry +/// G14 / design §5.9 (review F7): the catalog probe's stderr is the +/// sidecar's JSONL log, so it goes through the same stderr logger as a session +/// sidecar: each record is re-emitted at its own severity, on success too. +#[tokio::test] +async fn claude_catalog_probe_re_emits_its_log_records_at_their_severity() { + use crate::claude_sidecar_io::test_capture::{capture, with_message}; + let _guard = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; + let (events, _capture) = capture(); + let result = probe_relocated_catalog( + "f7-logging-sdk.mjs", + r#" +export function query({ options }) { + options.stderr('cli says hello\n') + return { + supportedModels: async () => [{ value: 'f7-model', displayName: 'F7 Model' }], + close: async () => { throw new Error('close failed for the test') }, + } +} +"#, + ) + .await; + assert_eq!(result.expect("the catalog is read")[0].id, "f7-model"); + + let records = with_message(&events, "freshagent.claude.sidecar_log"); + let record = |event: &str| { + records + .iter() + .find(|record| record.fields.get("sidecar_event").map(String::as_str) == Some(event)) + .unwrap_or_else(|| panic!("no {event} record among {records:?}")) + }; + let cli = record("cli.stderr"); + assert_eq!(cli.level, tracing::Level::INFO); + assert_eq!( + cli.fields.get("component").map(String::as_str), + Some("claude-model-catalog") + ); + assert_eq!(record("catalog.close_failed").level, tracing::Level::WARN); +} + +/// On failure the probe's records keep their own severity, and the failure +/// warning gives the exit status and points to them, instead of carrying the +/// whole stderr as one field. +#[tokio::test] +async fn claude_catalog_probe_failure_keeps_its_records_apart_from_the_warning() { + use crate::claude_sidecar_io::test_capture::{capture, with_message}; + let _guard = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; + let (events, _capture) = capture(); + let result = probe_relocated_catalog( + "f7-failing-sdk.mjs", + r#" +export function query() { + return { + supportedModels: async () => { throw new Error('not signed in for the test') }, + close: async () => {}, + } +} +"#, + ) + .await; + assert_eq!( + result.expect_err("the catalog is unavailable").code, + "CAPABILITY_PROBE_FAILED" + ); + + let unavailable = with_message(&events, "freshagent.claude.sidecar_log") + .into_iter() + .find(|record| { + record.fields.get("sidecar_event").map(String::as_str) == Some("catalog.unavailable") + }) + .expect("the catalog.unavailable record is re-emitted"); + assert_eq!(unavailable.level, tracing::Level::ERROR); + + let warning = with_message(&events, "freshagent.claude.model_catalog_unavailable"); + assert_eq!(warning.len(), 1, "one failure warning: {warning:?}"); + assert_eq!(warning[0].level, tracing::Level::WARN); + assert!(warning[0].fields.contains_key("status")); + assert!( + !warning[0].fields.contains_key("stderr"), + "the warning does not carry the stderr blob: {warning:?}" + ); } /// G30: in managed mode the session sidecar authenticates with the managed diff --git a/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs b/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs index e99ca5ec53..042de35db0 100644 --- a/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs +++ b/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs @@ -10,8 +10,8 @@ //! This test spawns the REAL `index.mjs` source with `node` and drives its stdin //! directly. The `@anthropic-ai/claude-agent-sdk` dependency is vendored via //! `pnpm install` into the sidecar package's own node_modules and is NOT present -//! in a plain checkout/CI, so the test copies the real sidecar entrypoint and its -//! local helper modules VERBATIM into a temp dir with a +//! in a plain checkout/CI, so the test copies the real sidecar entrypoint and every +//! local helper module (each `*.mjs` of the package) VERBATIM into a temp dir with a //! stub `node_modules/@anthropic-ai/claude-agent-sdk` that satisfies only the //! top-level SDK import (the interrupt-dispatch path under test never calls //! `query()`; the stub throws if it is called). Only module RESOLUTION is redirected @@ -43,15 +43,32 @@ const STUB_SDK_PACKAGE_JSON: &str = r#"{ } "#; -/// Read one real sidecar module verbatim. The entrypoint and every local helper -/// it imports must be present in the staged dir for ESM resolution to succeed. -fn real_sidecar_source(module: &str) -> String { - let path = format!( - "{}/../freshell-claude-sidecar/{module}", - env!("CARGO_MANIFEST_DIR") +/// Every real sidecar module (the entrypoint and each local helper it imports), +/// read verbatim. All of them are staged, so ESM resolution succeeds however the +/// sidecar splits its modules. +fn real_sidecar_modules() -> Vec<(String, String)> { + let package = format!("{}/../freshell-claude-sidecar", env!("CARGO_MANIFEST_DIR")); + let mut modules: Vec<(String, String)> = std::fs::read_dir(&package) + .unwrap_or_else(|e| panic!("list the real crates/freshell-claude-sidecar: {e}")) + .map(|entry| entry.expect("sidecar dir entry").path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "mjs")) + .map(|path| { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .expect("utf-8 module name") + .to_string(); + let source = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("read the real {}: {e}", path.display())); + (name, source) + }) + .collect(); + modules.sort(); + assert!( + modules.iter().any(|(name, _)| name == "index.mjs"), + "the sidecar entrypoint index.mjs is missing from {package}" ); - std::fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("read the real crates/freshell-claude-sidecar/{module}: {e}")) + modules } /// Keep the child and its pipe readers owned until every assertion has settled, @@ -83,17 +100,8 @@ impl Drop for SidecarProcess { #[test] fn real_sidecar_dispatches_interrupt_frames_to_handle_interrupt() { let dir = tempfile::tempdir().expect("create temp dir"); - for module in [ - "index.mjs", - "exec-profile.mjs", - "hosted-mcp.mjs", - "monotonic-clock.mjs", - "permission-channel.mjs", - "sdk-env.mjs", - "session-settings.mjs", - "turn-complete-gate.mjs", - ] { - std::fs::write(dir.path().join(module), real_sidecar_source(module)) + for (module, source) in real_sidecar_modules() { + std::fs::write(dir.path().join(&module), source) .unwrap_or_else(|e| panic!("copy real {module} verbatim: {e}")); } let sdk_dir = dir diff --git a/crates/freshell-server/src/managed_provider_sdk_tests.rs b/crates/freshell-server/src/managed_provider_sdk_tests.rs index c30c7569f7..c9d6d6d74b 100644 --- a/crates/freshell-server/src/managed_provider_sdk_tests.rs +++ b/crates/freshell-server/src/managed_provider_sdk_tests.rs @@ -18,6 +18,11 @@ async fn managed_provider_claude_default_sdk_initializes_fixed_mcp_without_a_pro "exec-profile.mjs", include_str!("../../freshell-claude-sidecar/exec-profile.mjs"), ), + // Both modules above read the hosted mode through hosted.mjs. + ( + "hosted.mjs", + include_str!("../../freshell-claude-sidecar/hosted.mjs"), + ), ("probe.mjs", include_str!("managed_provider_sdk_probe.mjs")), ] { std::fs::write(fixture.path().join(name), source).unwrap(); diff --git a/crates/freshell-ws/Cargo.toml b/crates/freshell-ws/Cargo.toml index 6fcfb8c591..2f8503ad17 100644 --- a/crates/freshell-ws/Cargo.toml +++ b/crates/freshell-ws/Cargo.toml @@ -107,6 +107,10 @@ sha2 = "0.10" libc = "0.2" [dev-dependencies] +# The claude rigs (tests/common/claude_rig.rs) give every sidecar their own +# isolated environment through FreshClaudeState::with_sidecar_environment, a seam +# that only exists with this feature (dev builds only; production never has it). +freshell-freshagent = { path = "../freshell-freshagent", features = ["test-support"] } # tabs_persist tests write snapshot generations into throwaway dirs. Same # version other workspace crates pin (e.g. crates/freshell-terminal). tempfile = "3" diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs new file mode 100644 index 0000000000..c41c88f61f --- /dev/null +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -0,0 +1,592 @@ +//! Rigs that run the REAL claude sidecar (`crates/freshell-claude-sidecar/index.mjs`) +//! behind a real freshell-ws server, for the claude integration tests (design §11.3 +//! and §11.5). +//! +//! - [`RigKind::Sim`] (RealSidecarSim): the sidecar over the CLI simulator +//! (`test/fixtures/claude-cli-sim/query.mjs`, through the sidecar's +//! `FRESHELL_CLAUDE_SDK_QUERY_MODULE` seam). The simulator matches the pinned CLI +//! (`test/integration/providers/claude-cli-conformance.ts`) and records what +//! the SDK side received in its ledger ([`ClaudeRig::ledger_rows`]). +//! - [`RigKind::Cli`] (RealSidecarCli): the sidecar over the pinned SDK and its +//! bundled CLI, talking only to the local fake Anthropic endpoint +//! (`test/integration/providers/claude-local-provider.mjs --serve`) with a dummy +//! key. It never contacts the real API. +//! +//! Each rig's server is wired like production: one ownership coordinator shared by +//! the WS state and the claude lane (main.rs does the same). +//! +//! Each rig gets a throwaway HOME / CLAUDE_CONFIG_DIR / workspace, and its server's +//! [`freshell_freshagent::FreshClaudeState`] gives every sidecar exactly the rig's +//! environment (the `test-support` seam `with_sidecar_environment`), so nothing from +//! this process's environment (credentials, the developer's `~/.claude`) reaches the +//! sidecar and rigs never touch process-global env vars: tests run in parallel. +//! +//! A missing node, sidecar, simulator or pinned CLI panics with the reason. Nothing +//! is ever skipped. + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::Message as WsMessage; + +use freshell_ws::WsState; + +use super::{handshake_settings_lock, test_settings_value, TestWs, AUTH_TOKEN}; + +/// The repository root (the rigs read the sidecar, the simulator and the provider +/// from the source tree). +pub fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +/// Budget for one expected frame. The real CLI boots in about a second; the budget +/// is generous for a loaded machine, and a genuinely missing frame still fails. +pub const RIG_FRAME_BUDGET: Duration = Duration::from_secs(60); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RigKind { + /// RealSidecarSim: the real `index.mjs` over the CLI simulator. + Sim, + /// RealSidecarCli: the real `index.mjs` over the pinned CLI and the local endpoint. + Cli, +} + +pub struct ClaudeRig { + pub kind: RigKind, + /// `ws://127.0.0.1:/ws` of the rig's server (an ephemeral loopback port). + pub url: String, + /// The isolated workspace every session of this rig runs in. + pub workspace: PathBuf, + /// CLAUDE_CONFIG_DIR of the CLI (simulated or real): its transcripts land here. + pub claude_config_dir: PathBuf, + /// The simulator's ledger (JSONL; written by [`RigKind::Sim`] only). + pub ledger: PathBuf, + /// The local fake Anthropic endpoint ([`RigKind::Cli`] only), killed on drop. + _provider: Option, + _root: tempfile::TempDir, +} + +impl ClaudeRig { + pub async fn start(kind: RigKind) -> Self { + Self::start_with_env(kind, &[]).await + } + + /// Start a rig whose sidecars also get `extra` (for example simulator knobs such + /// as `FRESHELL_CLAUDE_SIM_PROGRAM` or `FRESHELL_CLAUDE_SIM_TIME_SCALE`). + pub async fn start_with_env(kind: RigKind, extra: &[(&str, &str)]) -> Self { + let node_version = require_node(); + let root = tempfile::Builder::new() + .prefix("freshell-claude-rig-") + .tempdir() + .expect("create the rig's temp dir"); + let home = root.path().join("home"); + let workspace = root.path().join("workspace"); + let tmp = root.path().join("tmp"); + for directory in [&home, &workspace, &tmp] { + std::fs::create_dir_all(directory).expect("create rig directories"); + } + let claude_config_dir = home.join(".claude"); + let ledger = root.path().join("sim-ledger.jsonl"); + + let mut environment: Vec<(String, String)> = vec![ + ("HOME".into(), path_string(&home)), + ("PATH".into(), std::env::var("PATH").unwrap_or_default()), + ("TMPDIR".into(), path_string(&tmp)), + ("LANG".into(), "C.UTF-8".into()), + ("CLAUDE_CONFIG_DIR".into(), path_string(&claude_config_dir)), + ]; + let mut provider = None; + match kind { + RigKind::Sim => { + let simulator = repo_root().join("test/fixtures/claude-cli-sim/query.mjs"); + assert!( + simulator.is_file(), + "the CLI simulator is missing at {}", + simulator.display() + ); + environment.extend([ + ( + "FRESHELL_CLAUDE_SDK_QUERY_MODULE".into(), + path_string(&simulator), + ), + ("FRESHELL_CLAUDE_SIM_LEDGER".into(), path_string(&ledger)), + ("FRESHELL_CLAUDE_SIM_TIME_SCALE".into(), "1".into()), + ]); + } + RigKind::Cli => { + require_pinned_cli(); + let (child, url) = start_local_provider().await; + provider = Some(child); + environment.extend([ + ("XDG_CONFIG_HOME".into(), path_string(&home.join(".config"))), + ("XDG_CACHE_HOME".into(), path_string(&home.join(".cache"))), + ( + "XDG_DATA_HOME".into(), + path_string(&home.join(".local/share")), + ), + ("ANTHROPIC_BASE_URL".into(), url), + ( + "ANTHROPIC_API_KEY".into(), + format!("sk-local-{}", uuid::Uuid::new_v4()), + ), + // The sidecar strips ANTHROPIC_API_KEY from the SDK child unless the + // key was handed over on purpose (the managed-key path). + ("FRESHELL_CLAUDE_ONECLI_API_KEY".into(), "1".into()), + // Any egress other than the loopback endpoint hits a closed port. + ("HTTPS_PROXY".into(), "http://127.0.0.1:9".into()), + ("HTTP_PROXY".into(), "http://127.0.0.1:9".into()), + ("NO_PROXY".into(), "127.0.0.1,localhost".into()), + ( + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".into(), + "1".into(), + ), + ("DISABLE_TELEMETRY".into(), "1".into()), + ("DISABLE_ERROR_REPORTING".into(), "1".into()), + ("DISABLE_AUTOUPDATER".into(), "1".into()), + ("CLAUDE_CODE_DISABLE_METRICS".into(), "1".into()), + ("CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY".into(), "1".into()), + ]); + } + } + for (key, value) in extra { + environment.retain(|(existing, _)| existing != key); + environment.push(((*key).into(), (*value).into())); + } + + let url = spawn_rig_server(environment).await; + eprintln!( + "{}", + json!({ + "severity": "info", "event": "claude_rig.started", "kind": format!("{kind:?}"), + "url": url, "node": node_version, "root": path_string(root.path()), + }) + ); + Self { + kind, + url, + workspace, + claude_config_dir, + ledger, + _provider: provider, + _root: root, + } + } + + /// A WS connection that has completed the handshake (`ready` seen). + pub async fn connect(&self) -> RigConnection { + let (mut ws, _) = tokio_tungstenite::connect_async(&self.url) + .await + .expect("connect to the rig's server"); + ws.send(WsMessage::Text( + json!({ + "type": "hello", + "token": AUTH_TOKEN, + "protocolVersion": freshell_protocol::WS_PROTOCOL_VERSION, + }) + .to_string(), + )) + .await + .expect("send hello"); + let mut connection = RigConnection { + ws, + workspace: self.workspace.clone(), + frames: Vec::new(), + }; + connection.wait_for("ready", |v| v["type"] == "ready").await; + connection + } + + /// The transcripts (`*.jsonl`) under this rig's CLAUDE_CONFIG_DIR/projects. + pub fn transcripts(&self) -> Vec { + let mut found = Vec::new(); + let Ok(projects) = std::fs::read_dir(self.claude_config_dir.join("projects")) else { + return found; + }; + for project in projects.flatten() { + let Ok(entries) = std::fs::read_dir(project.path()) else { + continue; + }; + for entry in entries.flatten() { + if entry.path().extension().is_some_and(|ext| ext == "jsonl") { + found.push(entry.path()); + } + } + } + found.sort(); + found + } + + /// [`Self::transcripts`] once at least one exists (the CLI writes a transcript + /// shortly after a turn's first result, T3). + pub async fn wait_for_transcripts(&self) -> Vec { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + let found = self.transcripts(); + if !found.is_empty() { + return found; + } + assert!( + tokio::time::Instant::now() < deadline, + "no transcript appeared under {}", + self.claude_config_dir.display() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + + /// The simulator's ledger rows of one kind (`query`, `boot`, `prompt`, + /// `can_use_tool`, `emission`); see `test/fixtures/claude-cli-sim/query.mjs`. + pub fn ledger_rows(&self, kind: &str) -> Vec { + assert_eq!( + self.kind, + RigKind::Sim, + "only the simulator writes a ledger" + ); + let text = std::fs::read_to_string(&self.ledger).unwrap_or_default(); + text.lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).expect("ledger row is json")) + .filter(|row| row["kind"] == kind) + .collect() + } +} + +/// One client connection to a rig's server. Every frame it reads is kept in +/// [`Self::frames`], so a test can wait for one frame and later count others. +pub struct RigConnection { + ws: TestWs, + workspace: PathBuf, + pub frames: Vec, +} + +impl RigConnection { + pub async fn send(&mut self, frame: Value) { + self.ws + .send(WsMessage::Text(frame.to_string())) + .await + .expect("send a frame to the rig's server"); + } + + /// The first frame (already read or still to come) matching `predicate`. + pub async fn wait_for(&mut self, what: &str, predicate: impl Fn(&Value) -> bool) -> Value { + if let Some(hit) = self.frames.iter().find(|frame| predicate(frame)) { + return hit.clone(); + } + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + let next = match tokio::time::timeout_at(deadline, self.ws.next()).await { + Ok(Some(Ok(message))) => message, + Ok(Some(Err(error))) => panic!("ws error waiting for {what}: {error}"), + Ok(None) => panic!("the server closed the socket waiting for {what}"), + Err(_) => panic!( + "timed out waiting for {what}; frames: {}", + tail(&self.frames) + ), + }; + let WsMessage::Text(text) = next else { + continue; + }; + let frame: Value = serde_json::from_str(&text).expect("json frame"); + self.frames.push(frame.clone()); + if predicate(&frame) { + return frame; + } + } + } + + /// `freshAgent.create` for a freshclaude session in the rig's workspace; returns + /// the session id from `freshAgent.created`. + pub async fn create(&mut self, request_id: &str) -> String { + let cwd = path_string(&self.workspace); + self.send(json!({ + "type": "freshAgent.create", + "requestId": request_id, + "provider": "claude", + "sessionType": "freshclaude", + "cwd": cwd, + })) + .await; + let answer = self + .wait_for(&format!("the answer to create {request_id}"), |v| { + v["requestId"] == request_id + && matches!( + v["type"].as_str(), + Some("freshAgent.created" | "freshAgent.create.failed" | "error") + ) + }) + .await; + assert_eq!( + answer["type"], "freshAgent.created", + "create failed: {answer}" + ); + answer["sessionId"] + .as_str() + .expect("created carries a sessionId") + .to_string() + } + + pub async fn send_text(&mut self, session_id: &str, request_id: &str, text: &str) { + self.send(json!({ + "type": "freshAgent.send", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + "requestId": request_id, + "text": text, + })) + .await; + } + + /// A send the way the browser client makes every send (`FreshAgentView`'s + /// `sendFreshAgentSendFrame`): with a checkpoint intent, and with the owner pair + /// (epoch, generation) of the latest `session.runtimeOwner` frame this + /// connection read for the session's durable id, once there is one. + pub async fn send_checkpointed_text(&mut self, session_id: &str, request_id: &str, text: &str) { + let mut frame = json!({ + "type": "freshAgent.send", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + "requestId": request_id, + "checkpointIntent": { "logicalAttemptId": format!("attempt-{request_id}") }, + "text": text, + }); + if let Some((epoch, generation)) = self.observed_owner(session_id) { + frame["observedEpoch"] = json!(epoch); + frame["observedGeneration"] = json!(generation); + } + self.send(frame).await; + } + + /// The owner pair the client would hold for this pane's session: the latest + /// `session.runtimeOwner` frame for the durable id its `session.init` named. + fn observed_owner(&self, session_id: &str) -> Option<(u64, u64)> { + let durable = self.frames.iter().rev().find_map(|v| { + (v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.session.init" + && v["event"]["sessionId"] == session_id) + .then(|| v["event"]["cliSessionId"].as_str().map(str::to_string)) + .flatten() + })?; + self.frames.iter().rev().find_map(|v| { + (v["type"] == "session.runtimeOwner" + && v["provider"] == "claude" + && v["sessionId"] == durable.as_str()) + .then(|| Some((v["epoch"].as_u64()?, v["generation"].as_u64()?))) + .flatten() + }) + } + + /// Top-level `error` frames answering this request id, read so far. + pub fn request_errors(&self, request_id: &str) -> Vec { + self.frames + .iter() + .filter(|v| v["type"] == "error" && v["requestId"] == request_id) + .cloned() + .collect() + } + + /// The first `freshAgent.event` whose inner event has this type. + pub async fn wait_for_event(&mut self, event_type: &str) -> Value { + self.wait_for(&format!("a {event_type} event"), |v| { + v["type"] == "freshAgent.event" && v["event"]["type"] == event_type + }) + .await + } + + /// How many `freshAgent.event`s of this inner type this connection has read. + pub fn events_seen(&self, event_type: &str) -> usize { + self.frames + .iter() + .filter(|v| v["type"] == "freshAgent.event" && v["event"]["type"] == event_type) + .count() + } + + /// `freshAgent.kill`, then wait for `freshAgent.killed{success:true}`. + pub async fn kill(&mut self, session_id: &str) { + self.send(json!({ + "type": "freshAgent.kill", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + })) + .await; + let killed = self + .wait_for("freshAgent.killed", |v| v["type"] == "freshAgent.killed") + .await; + assert_eq!(killed["success"], true, "{killed}"); + } +} + +fn tail(frames: &[Value]) -> String { + let start = frames.len().saturating_sub(40); + serde_json::to_string(&frames[start..]).unwrap_or_default() +} + +fn path_string(path: &Path) -> String { + path.to_str().expect("utf-8 path").to_string() +} + +/// `node --version`, or a panic that says node is required. +fn require_node() -> String { + let output = std::process::Command::new("node") + .arg("--version") + .output() + .unwrap_or_else(|error| { + panic!("the claude rigs need node on PATH to run the sidecar: {error}") + }); + assert!( + output.status.success(), + "`node --version` failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + +/// Resolve the pinned SDK and its bundled CLI with the same resolver the real-CLI +/// contract suite uses; its PinnedClaudeMissingError (with the install hint) becomes +/// the panic message. +fn require_pinned_cli() { + let provider = repo_root().join("test/integration/providers/claude-local-provider.mjs"); + let script = format!( + "const m = await import({url}); const r = await m.resolvePinnedClaude(); \ + console.log(JSON.stringify({{ sdk: r.sdkVersion, cli: r.cliVersion }}))", + url = serde_json::to_string(&format!("file://{}", path_string(&provider))) + .expect("json string"), + ); + let output = std::process::Command::new("node") + .args(["--input-type=module", "-e", &script]) + .current_dir(repo_root()) + .output() + .expect("run the pinned-CLI resolver"); + assert!( + output.status.success(), + "the pinned Claude CLI is not usable: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Start the local fake Anthropic endpoint (`--serve` prints `{"port","url"}` and +/// serves until its stdin closes). The child is killed when the rig drops. +async fn start_local_provider() -> (tokio::process::Child, String) { + let script = repo_root().join("test/integration/providers/claude-local-provider.mjs"); + let mut child = tokio::process::Command::new("node") + .arg(&script) + .arg("--serve") + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .expect("start the local fake Anthropic endpoint"); + let stdout = child.stdout.take().expect("provider stdout"); + let mut lines = BufReader::new(stdout).lines(); + let line = tokio::time::timeout(Duration::from_secs(30), lines.next_line()) + .await + .expect("the local endpoint reports its address within 30 s") + .expect("read the local endpoint's stdout") + .expect("the local endpoint printed its address"); + let address: Value = serde_json::from_str(&line).expect("address json"); + let url = address["url"] + .as_str() + .expect("the endpoint's url") + .to_string(); + assert!( + url.starts_with("http://127.0.0.1:"), + "the endpoint must be loopback: {url}" + ); + (child, url) +} + +/// A freshell-ws server on an ephemeral loopback port (never 3001/3002) with fresh +/// agents enabled and a claude state whose sidecars get exactly `environment`. +async fn spawn_rig_server(environment: Vec<(String, String)>) -> String { + let mut settings_value = test_settings_value(); + settings_value["freshAgent"]["enabled"] = json!(true); + let auth_token = Arc::new(AUTH_TOKEN.to_string()); + let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(256).0); + let settings = + Arc::new(serde_json::from_value(settings_value.clone()).expect("valid settings fixture")); + let handshake_settings = handshake_settings_lock(); + *handshake_settings.write().await = + serde_json::from_value(settings_value).expect("valid settings fixture"); + + // The ONE ownership coordinator, wired into the WS state and the claude lane + // the way main.rs wires it. + let ownership = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); + let mut fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)) + .with_sidecar_environment(environment); + fresh_claude.set_ownership(Arc::clone(&ownership)); + + let state = WsState { + layout: Default::default(), + terminal_meta: Default::default(), + pane_ledger: Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), + identity: freshell_ws::identity::TerminalIdentityRegistry::new(), + auth_token: Arc::clone(&auth_token), + server_instance_id: Arc::new("srv-claude-rig".to_string()), + boot_id: Arc::new("boot-claude-rig".to_string()), + settings, + handshake_settings, + broadcast_tx: Arc::clone(&broadcast_tx), + auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, + auto_resume_cancels: Default::default(), + fresh_codex: freshell_freshagent::FreshCodexState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + json!({ "freshAgent": { "enabled": true } }), + ), + fresh_claude, + fresh_opencode: freshell_freshagent::FreshOpencodeState::new( + freshell_freshagent::FreshAgentState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + ), + ), + registry: freshell_terminal::TerminalRegistry::new(), + tabs: freshell_ws::tabs::TabsRegistry::new(), + screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), + subagent_interest: Default::default(), + host_stats: Default::default(), + checkpoint_send: Default::default(), + terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + cli_commands: Arc::new(Vec::new()), + shutdown: Arc::new(tokio::sync::Notify::new()), + ping_interval_ms: 30_000, + hello_timeout_ms: 5_000, + allowed_origins: Arc::new(freshell_ws::origin::default_allowed_origins()), + ws_max_payload_bytes: 16 * 1024 * 1024, + term09: freshell_ws::backpressure::Term09Config::default(), + create_protect: freshell_ws::create_limit::CreateProtectConfig::default(), + spawn_gate: Arc::new(freshell_ws::spawn_gate::SpawnGate::new(4, 64)), + shutdown_started: Arc::new(std::sync::atomic::AtomicBool::new(false)), + create_dedupe: Arc::new(freshell_ws::create_dedupe::CreateDedupe::default()), + config_fallback: None, + opencode_locator: None, + codex_locator: None, + activity: None, + session_existence: Arc::new(freshell_ws::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), + ownership: Some(ownership), + }; + + let router = freshell_ws::router(state); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind an ephemeral loopback port"); + let addr = listener.local_addr().expect("local addr"); + tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + format!("ws://{addr}/ws") +} diff --git a/crates/freshell-ws/tests/common/log_capture.rs b/crates/freshell-ws/tests/common/log_capture.rs new file mode 100644 index 0000000000..88ca9d0487 --- /dev/null +++ b/crates/freshell-ws/tests/common/log_capture.rs @@ -0,0 +1,128 @@ +//! A process-wide `tracing` capture for integration tests that assert on the +//! server's log events. Server work runs on tasks polled by any runtime +//! thread, so a thread-local subscriber would miss events; this installs one +//! global subscriber per test binary (the first [`install`] wins) that keeps +//! every event. A capture sees the events emitted after it was taken; tests +//! running in parallel share them, so a test finds its own events by an id it +//! owns (a session id, an ownership id). + +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use tracing::field::{Field, Visit}; +use tracing::{Event, Level, Subscriber}; +use tracing_subscriber::layer::{Context, SubscriberExt}; +use tracing_subscriber::Layer; + +#[derive(Clone, Debug)] +pub struct CapturedEvent { + pub level: Level, + pub target: String, + pub message: String, + pub fields: BTreeMap, +} + +impl CapturedEvent { + pub fn field(&self, name: &str) -> Option<&str> { + self.fields.get(name).map(String::as_str) + } +} + +#[derive(Default)] +struct Visitor { + message: String, + fields: BTreeMap, +} + +impl Visit for Visitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + let rendered = format!("{value:?}"); + if field.name() == "message" { + self.message = rendered; + } else { + self.fields.insert(field.name().to_string(), rendered); + } + } + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.message = value.to_string(); + } else { + self.fields + .insert(field.name().to_string(), value.to_string()); + } + } +} + +struct CaptureLayer(Arc>>); + +impl Layer for CaptureLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let mut visitor = Visitor::default(); + event.record(&mut visitor); + self.0.lock().expect("capture lock").push(CapturedEvent { + level: *event.metadata().level(), + target: event.metadata().target().to_string(), + message: visitor.message, + fields: visitor.fields, + }); + } +} + +static EVENTS: OnceLock>>> = OnceLock::new(); + +/// A capture of the events emitted from now on (installing the binary's +/// subscriber on first use). +pub fn install() -> LogCapture { + let events = EVENTS + .get_or_init(|| { + let events = Arc::new(Mutex::new(Vec::new())); + let subscriber = tracing_subscriber::registry().with(CaptureLayer(Arc::clone(&events))); + tracing::subscriber::set_global_default(subscriber) + .expect("a test binary using log_capture installs no other global subscriber"); + events + }) + .clone(); + let start = events.lock().expect("capture lock").len(); + LogCapture { events, start } +} + +#[derive(Clone)] +pub struct LogCapture { + events: Arc>>, + /// Events before this index predate the capture. + start: usize, +} + +impl LogCapture { + /// Every event since the capture matching `predicate`, in emission order. + pub fn matching(&self, predicate: impl Fn(&CapturedEvent) -> bool) -> Vec { + self.events + .lock() + .expect("capture lock") + .iter() + .skip(self.start) + .filter(|event| predicate(event)) + .cloned() + .collect() + } + + /// The first event matching `predicate`, waiting up to 60 s for it. + pub async fn wait_for( + &self, + what: &str, + predicate: impl Fn(&CapturedEvent) -> bool, + ) -> CapturedEvent { + let deadline = tokio::time::Instant::now() + Duration::from_secs(60); + loop { + if let Some(hit) = self.matching(&predicate).into_iter().next() { + return hit; + } + assert!( + tokio::time::Instant::now() < deadline, + "no log event: {what}" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } +} diff --git a/crates/freshell-ws/tests/common/mod.rs b/crates/freshell-ws/tests/common/mod.rs index 65e97b698d..c0de0181d8 100644 --- a/crates/freshell-ws/tests/common/mod.rs +++ b/crates/freshell-ws/tests/common/mod.rs @@ -17,6 +17,8 @@ use tokio_tungstenite::tungstenite::Message as WsMessage; use freshell_ws::WsState; +pub mod claude_rig; +pub mod log_capture; pub mod opencode_home; pub const AUTH_TOKEN: &str = "s3cr3t-token-abcdef"; diff --git a/crates/freshell-ws/tests/freshagent_claude_real_cli.rs b/crates/freshell-ws/tests/freshagent_claude_real_cli.rs new file mode 100644 index 0000000000..044181a550 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_real_cli.rs @@ -0,0 +1,301 @@ +//! The freshclaude lane that runs the REAL claude sidecar +//! (`crates/freshell-claude-sidecar/index.mjs`) behind a real freshell-ws server +//! (design §11.5; C04 adds the smoke test, C29 completes the lane). Each case runs +//! on both rigs from `common::claude_rig`: +//! - `sim`: RealSidecarSim, the sidecar over the CLI simulator +//! (`test/fixtures/claude-cli-sim`, kept honest by the conformance suite); +//! - `cli`: RealSidecarCli, the sidecar over the pinned SDK and its bundled CLI, +//! talking only to the local fake Anthropic endpoint (never the real API). +//! +//! A missing node, sidecar, simulator or pinned CLI fails the test; nothing here +//! is ever skipped. + +mod common; + +use common::claude_rig::{ClaudeRig, RigKind}; +use common::log_capture::{self, CapturedEvent}; +use serde_json::json; +use tracing::Level; + +/// Today's plain turn works end to end: create, one send, a successful result, +/// and exactly one attention edge (`freshAgent.turn.complete`) for the turn. An +/// explicit kill afterwards is the end marker: a requested teardown never rings. +async fn create_send_result_rings_once(kind: RigKind) { + let rig = ClaudeRig::start(kind).await; + let mut conn = rig.connect().await; + + let session_id = conn.create("create-1").await; + conn.send_text(&session_id, "send-1", "hello").await; + + let result = conn.wait_for_event("freshAgent.result").await; + assert_eq!(result["event"]["result"], "success", "{result}"); + let complete = conn.wait_for_event("freshAgent.turn.complete").await; + assert!( + complete["event"]["at"].is_u64(), + "the edge carries a numeric `at`: {complete}" + ); + + // The turn ran in the rig's isolated CLAUDE_CONFIG_DIR: its transcript is there. + let transcripts = rig.wait_for_transcripts().await; + assert_eq!(transcripts.len(), 1, "{transcripts:?}"); + + conn.kill(&session_id).await; + assert_eq!( + conn.events_seen("freshAgent.turn.complete"), + 1, + "exactly one attention edge for one turn; frames: {:?}", + conn.frames + ); + + if kind == RigKind::Sim { + // The turn really ran on the simulator. + let prompts = rig.ledger_rows("prompt"); + assert_eq!(prompts.len(), 1, "{prompts:?}"); + assert_eq!(prompts[0]["text"], "hello"); + } +} + +/// A new pane's messages are sent the way the browser sends every message: with a +/// checkpoint intent, which the coordinator fences. The CLI reports the session +/// only once a prompt runs (T1), so nothing owns the new pane's session in the +/// coordinator yet: the first send must still run, not be refused as +/// SESSION_RESERVED. Once the session is reported the coordinator owns it under +/// its durable id, and the next send, fenced with the owner pair the client +/// observed for that id, runs too. +async fn first_checkpointed_send_of_a_new_pane_runs(kind: RigKind) { + let rig = ClaudeRig::start(kind).await; + let mut conn = rig.connect().await; + + let session_id = conn.create("create-1").await; + conn.send_checkpointed_text(&session_id, "send-1", "hello") + .await; + let result = conn.wait_for_event("freshAgent.result").await; + assert_eq!(result["event"]["result"], "success", "{result}"); + let first_edge = conn.wait_for_event("freshAgent.turn.complete").await; + let first_at = first_edge["event"]["at"].as_u64().expect("numeric at"); + assert_eq!( + conn.request_errors("send-1"), + Vec::::new(), + "the first send is not refused" + ); + + conn.send_checkpointed_text(&session_id, "send-2", "again") + .await; + conn.wait_for("the second turn's attention edge", |v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.turn.complete" + && v["event"]["at"].as_u64() > Some(first_at) + }) + .await; + conn.kill(&session_id).await; + assert_eq!( + conn.events_seen("freshAgent.result"), + 2, + "{:?}", + conn.frames + ); + assert_eq!( + conn.events_seen("freshAgent.turn.complete"), + 2, + "{:?}", + conn.frames + ); + assert_eq!( + conn.request_errors("send-2"), + Vec::::new(), + "the second send is not refused" + ); +} + +/// The sidecar's stderr log reaches the server log (G14). Each record is +/// re-emitted at its own severity with the sidecar's ownership id; once the +/// sidecar has named its session, the session id and type too. The join +/// between the two ids is logged when the session is bound. +async fn sidecar_log_records_reach_the_server_log(kind: RigKind) { + let logs = log_capture::install(); + let rig = ClaudeRig::start(kind).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + + let bound = logs + .wait_for("the sidecar bound to the new session", |event| { + event.message == "freshagent.claude.sidecar_bound" + && event.field("session_id") == Some(session_id.as_str()) + }) + .await; + assert_eq!( + bound.field("session_type"), + Some("freshclaude"), + "{bound:?}" + ); + let ownership_id = bound + .field("ownership_id") + .expect("the bound event names the sidecar's ownership id") + .to_string(); + let sidecar_log = |sidecar_event: &'static str| { + let ownership_id = ownership_id.clone(); + move |event: &CapturedEvent| { + event.message == "freshagent.claude.sidecar_log" + && event.field("ownership_id") == Some(ownership_id.as_str()) + && event.field("sidecar_event") == Some(sidecar_event) + } + }; + + let ready = logs + .wait_for("the sidecar's ready record", sidecar_log("sidecar.ready")) + .await; + assert_eq!(ready.level, Level::INFO, "{ready:?}"); + assert_eq!(ready.field("component"), Some("claude-sidecar")); + + // The CLI refuses an unknown permission mode (T7); the sidecar logs the + // refusal as a warning about this session. + conn.send(json!({ + "type": "freshAgent.configure", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + "requestId": "configure-1", + "settings": { "permissionMode": "no-such-mode" }, + })) + .await; + conn.wait_for("the settings failure", |v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.error" + && v["event"]["code"] == "CLAUDE_SETTINGS_FAILED" + }) + .await; + let failed = logs + .wait_for( + "the sidecar's configure.failed record", + sidecar_log("configure.failed"), + ) + .await; + assert_eq!(failed.level, Level::WARN, "{failed:?}"); + assert_eq!(failed.field("session_id"), Some(session_id.as_str())); + assert_eq!(failed.field("session_type"), Some("freshclaude")); + + conn.kill(&session_id).await; +} + +/// Every attention edge is logged as it leaves the server (G14, design §5.5): +/// a forwarded sidecar `turn.complete` is the info event +/// `freshagent.turn.complete` with the session, its type, the source and the +/// edge's `at`. +async fn a_forwarded_turn_complete_is_logged(kind: RigKind) { + let logs = log_capture::install(); + let rig = ClaudeRig::start(kind).await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + conn.send_text(&session_id, "send-1", "hello").await; + let edge = conn.wait_for_event("freshAgent.turn.complete").await; + let at = edge["event"]["at"] + .as_i64() + .expect("numeric at") + .to_string(); + + let logged = logs + .wait_for("the turn.complete log event", |event| { + event.message == "freshagent.turn.complete" + && event.field("session_id") == Some(session_id.as_str()) + }) + .await; + assert_eq!(logged.level, Level::INFO, "{logged:?}"); + assert_eq!(logged.field("provider"), Some("claude")); + assert_eq!(logged.field("session_type"), Some("freshclaude")); + assert_eq!(logged.field("source"), Some("freshclaude/sidecar")); + assert_eq!(logged.field("at"), Some(at.as_str())); + + conn.kill(&session_id).await; + let edges = logs.matching(|event| { + event.message == "freshagent.turn.complete" + && event.field("session_id") == Some(session_id.as_str()) + }); + assert_eq!(edges.len(), 1, "one logged edge per edge sent: {edges:?}"); +} + +mod sim { + use super::*; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn sidecar_log_records_reach_the_server_log() { + super::sidecar_log_records_reach_the_server_log(RigKind::Sim).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_forwarded_turn_complete_is_logged() { + super::a_forwarded_turn_complete_is_logged(RigKind::Sim).await; + } + + /// A forwarded `turn.waiting` (a question raised mid-turn) is logged as + /// `freshagent.turn.waiting`. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_forwarded_turn_waiting_is_logged() { + let logs = log_capture::install(); + let program = json!({ "rules": [{ + "on": "msg:prompt", "match": { "text": "please ask" }, + "emit": [{ "kind": "question", "data": { "questions": [{ + "question": "Which one?", "header": "Pick", "multiSelect": false, + "options": [{ "label": "A", "description": "a" }, { "label": "B", "description": "b" }], + }] } }], + }] }); + let rig = ClaudeRig::start_with_env( + RigKind::Sim, + &[("FRESHELL_CLAUDE_SIM_PROGRAM", &program.to_string())], + ) + .await; + let mut conn = rig.connect().await; + let session_id = conn.create("create-1").await; + conn.send_text(&session_id, "send-1", "please ask").await; + let edge = conn.wait_for_event("freshAgent.turn.waiting").await; + let at = edge["event"]["at"] + .as_i64() + .expect("numeric at") + .to_string(); + let logged = logs + .wait_for("the turn.waiting log event", |event| { + event.message == "freshagent.turn.waiting" + && event.field("session_id") == Some(session_id.as_str()) + }) + .await; + assert_eq!(logged.level, Level::INFO, "{logged:?}"); + assert_eq!(logged.field("provider"), Some("claude")); + assert_eq!(logged.field("session_type"), Some("freshclaude")); + assert_eq!(logged.field("source"), Some("freshclaude/sidecar")); + assert_eq!(logged.field("at"), Some(at.as_str())); + conn.kill(&session_id).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn create_send_result_rings_once() { + super::create_send_result_rings_once(RigKind::Sim).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn first_checkpointed_send_of_a_new_pane_runs() { + super::first_checkpointed_send_of_a_new_pane_runs(RigKind::Sim).await; + } +} + +mod cli { + use super::*; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn sidecar_log_records_reach_the_server_log() { + super::sidecar_log_records_reach_the_server_log(RigKind::Cli).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_forwarded_turn_complete_is_logged() { + super::a_forwarded_turn_complete_is_logged(RigKind::Cli).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn create_send_result_rings_once() { + super::create_send_result_rings_once(RigKind::Cli).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn first_checkpointed_send_of_a_new_pane_runs() { + super::first_checkpointed_send_of_a_new_pane_runs(RigKind::Cli).await; + } +} diff --git a/crates/freshell-ws/tests/freshagent_claude_rollback.rs b/crates/freshell-ws/tests/freshagent_claude_rollback.rs index a2443f1d12..74810d5904 100644 --- a/crates/freshell-ws/tests/freshagent_claude_rollback.rs +++ b/crates/freshell-ws/tests/freshagent_claude_rollback.rs @@ -978,8 +978,16 @@ async fn setup_and_undo_two_turn_conversation() -> ( #[tokio::test] async fn claude_undo_recreates_the_sidecar_with_resume_session_at_and_rekeys_the_pane() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let (rig, _ws, _addr, _ledger, _frames, _original, _new_id) = + let logs = common::log_capture::install(); + let (rig, _ws, _addr, _ledger, _frames, original, _new_id) = setup_and_undo_two_turn_conversation().await; + // G14: the respawned sidecar's log is bound to the session the undo named. + let bound = logs.matching(|event| { + event.message == "freshagent.claude.sidecar_bound" + && event.field("session_id") == Some(original.as_str()) + }); + assert_eq!(bound.len(), 1, "{bound:?}"); + assert_eq!(bound[0].field("session_type"), Some("freshclaude")); drop(rig); } 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..d7f12e34de --- /dev/null +++ b/docs/plans/2026-10-22-freshclaude-api-hosted-ops.md @@ -0,0 +1,463 @@ +# 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. +- **Open decision (from core PR-A review F6): hosted kilroy.** The session host sets `FRESHELL_HOSTED_FRESH_AGENT=kilroy` for hosted kilroy (`freshell-supervisor/src/backend.rs`). Core moved every read of that variable into `crates/freshell-claude-sidecar/hosted.mjs`: `isHostedSidecar` (any hosted sidecar, log redaction) and `usesHostedClaudeRuntime` (hosted `claude` only: the managed spawn check in `exec-profile.mjs` and the fixed hosted recipe). So hosted kilroy today gets neither the managed spawn check nor the fixed recipe. This workstream decides whether it should, and changes `usesHostedClaudeRuntime` (and the case in `test/unit/claude-sidecar/hosted.test.ts` that pins today's split) in a red-first commit. + +**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..939616363b --- /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 && num_turns > 0 && stop armed) // older CLIs; a T5 boot refusal (num_turns 0) is never a stopped turn +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.ts`: the same scenarios on `real` and `sim`, compared as normalized traces. It runs inside `claude-cli-contract.test.ts` on that file's real traces, so each real scenario runs once per test run. +- `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=