` processes. The visible failure was terminal hydration: hidden restored panes started PTYs, did not attach a browser terminal stream on `terminal.created`, and later `viewport_hydrate` attach attempts requested `sinceSeq: 0` after the replay-ring prefix had been evicted.
+- Current `/home/user/code/freshell/.worktrees/dev` source confirms that hidden restored panes with no persisted `terminalId` still send `terminal.create`, while the hidden `terminal.created` branch stores the new `terminalId` and defers `terminal.attach`. A focused red test in `/home/user/code/freshell/.worktrees/codex-opencode-hidden-attach-A-20260517/test/e2e/opencode-startup-probes.test.tsx` failed before the prototype because hidden restored OpenCode sent no `terminal.attach`; the prototype commit `ddba8fdfc9f03010075203ff4f631de670203251` made it green by attaching immediately after `terminal.created`. A second red/green prototype in `/home/user/code/freshell/.worktrees/opencode-defer-probe` commit `01a98383` proved the alternative policy: do not start hidden restored OpenCode PTYs until the pane becomes visible.
+- A follow-up focus/activation investigation corrected the missing acceptance case. `/home/user/.freshell/logs/20260517-0031-01-server-debug.production.3001.jsonl:11362` through `:11364` show focused attach for `V7SooJOfFJjuZmtWTc-1M` with `sinceSeq: 0` followed by `replay_window_exceeded` for missed seq `1-42262`; `:11373` through `:11375` show the same failure for `itWOwjSsP3-5uZNu9CK4c` with missed seq `1-83908`. The red test `recreates a restored OpenCode pane when visible viewport hydration cannot replay startup output` in `/home/user/code/freshell/.worktrees/opencode-focus-activation-repro-20260517/test/unit/client/components/TerminalView.lifecycle.test.tsx` failed before the fix because the pane kept the stale terminal id. It now proves the deterministic repair: send `terminal.kill`, wait for `terminal.exit`, clear the live handle, and create a restored OpenCode terminal with the same canonical `sessionRef`.
## Scope and provenance
-The real-binary provider probes were rerun on `2026-04-26` inside `/home/user/code/freshell/.worktrees/trycycle-codex-session-resilience`. Binary version facts were refreshed on `2026-05-03` inside `/home/user/code/freshell/.worktrees/land-local-main-codex-sidecar-lifecycle`; the Claude Code binary version fact was refreshed again on `2026-05-06` inside `/home/user/code/freshell/.worktrees/codex-sidebar-reopen-corner-origin-pr-20260505` after the installed binary changed. A targeted Codex pre-durable resume and identity-capture experiment was run on `2026-05-13` inside `/home/user/code/freshell/.worktrees/dev` using isolated temp roots. OpenCode root-child and TUI restart facts were refreshed on `2026-05-16` against `/home/user/.opencode/bin/opencode` 1.15.3, an isolated temp root under `/tmp/opencode-probe-20260516-144511-994599`, and upstream OpenCode tag `v1.15.3` at commit `37f89b742907c43b20d38b68eabe65981a59690a` in `/tmp/opencode-upstream`.
+The real-binary provider probes were rerun on `2026-04-26` inside `/home/user/code/freshell/.worktrees/trycycle-codex-session-resilience`. Binary version facts were refreshed on `2026-05-03` inside `/home/user/code/freshell/.worktrees/land-local-main-codex-sidecar-lifecycle`; the Claude Code binary version fact was refreshed again on `2026-05-06` inside `/home/user/code/freshell/.worktrees/codex-sidebar-reopen-corner-origin-pr-20260505` after the installed binary changed. A targeted Codex pre-durable resume and identity-capture experiment was run on `2026-05-13` inside `/home/user/code/freshell/.worktrees/dev` using isolated temp roots. OpenCode root-child and TUI restart facts were refreshed on `2026-05-16` against `/home/user/.opencode/bin/opencode` 1.15.3, an isolated temp root under `/tmp/opencode-probe-20260516-144511-994599`, and upstream OpenCode tag `v1.15.3` at commit `37f89b742907c43b20d38b68eabe65981a59690a` in `/tmp/opencode-upstream`. The hidden-pane OpenCode terminal hydration failure, the focus/activation repair case, and client lifecycle prototypes were studied on `2026-05-17` in `/home/user/code/freshell/.worktrees/dev`, `/home/user/code/freshell/.worktrees/codex-opencode-hidden-attach-A-20260517`, `/home/user/code/freshell/.worktrees/opencode-defer-probe`, and `/home/user/code/freshell/.worktrees/opencode-focus-activation-repro-20260517`.
The later version-only refreshes did not re-prove the full behavior contract, so `capturedOn` remains `2026-04-26`; the `2026-05-13` experiment is recorded as a narrow Codex addendum. A Codex source-code study was added on `2026-05-13` against the locally installed `@openai/codex` package and the official upstream `openai/codex` tag `rust-v0.130.0`.
@@ -270,6 +276,19 @@ The real-provider harness parses the next section. Keep the `## Machine-readable
"opencode.db session.parent_id"
],
"postRestartAttachStateMustBeVerified": true,
+ "tuiVisualRestoreSurface": "terminal-state",
+ "httpTuiFramebufferAvailable": false,
+ "hiddenRestoreCreateWithoutAttachIsDeterministic": false,
+ "viewportHydrateSinceSeq": 0,
+ "viewportHydrateReplayGapIsRestoreFailure": true,
+ "visibleViewportReplayGapRepairPolicy": "kill_old_terminal_then_restore_create_after_exit",
+ "visibleViewportReplayGapRepairRequiresSessionRef": true,
+ "visibleViewportReplayGapRepairTestedOn": "2026-05-17",
+ "redrawNudgesAreRestoreContract": false,
+ "testedHiddenRestorePolicies": [
+ "immediate_attach_after_terminal_created",
+ "defer_create_until_visible"
+ ],
"titleOnResumeMutatesStoredTitle": false,
"sessionSubcommands": [
"list",
@@ -770,6 +789,38 @@ The `2026-05-16` probe also launched an OpenCode TUI with `opencode --session ` and, for restored panes, `--session ` from canonical `sessionRef`. Current source derives the restore session at `/home/user/code/freshell/.worktrees/dev/server/ws-handler.ts:2317` through `:2327`, appends OpenCode control flags at `/home/user/code/freshell/.worktrees/dev/server/ws-handler.ts:2814` through `:2861`, and builds OpenCode resume args at `/home/user/code/freshell/.worktrees/dev/server/terminal-registry.ts:93` through `:105`.
+- In OpenCode 1.15.3, `--hostname` and `--port` put the TUI in external server mode at `/tmp/opencode-upstream/packages/opencode/src/cli/cmd/tui/thread.ts:192`; `--session` is validated before the renderer starts at `/tmp/opencode-upstream/packages/opencode/src/cli/cmd/tui/thread.ts:213` through `:219` and `/tmp/opencode-upstream/packages/opencode/src/cli/cmd/tui/validate-session.ts:14`.
+- The TUI creates an OpenTUI renderer and renders Solid components into terminal control frames at `/tmp/opencode-upstream/packages/opencode/src/cli/cmd/tui/app.tsx:185`. A current PTY probe saw `OSC 10;?`, `OSC 11;?`, startup probe traffic, `CSI ?1049h` alternate-screen entry, bracketed paste, mouse modes, and first paint frames. Freshell's frozen startup fixture also captures the OSC 11 query followed by alternate-screen entry at `/home/user/code/freshell/.worktrees/dev/test/helpers/opencode-startup-probes.ts:1`.
+- OpenCode's HTTP API exposes session metadata, messages, status, events, and TUI control routes. No canonical framebuffer, screen snapshot, or render-state endpoint was found in `/tmp/opencode-upstream/packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts:36`; the `onSnapshot` hook in `/tmp/opencode-upstream/packages/opencode/src/cli/cmd/tui/worker.ts:69` is a V8 heap snapshot hook, not a terminal screen snapshot.
+- Therefore Freshell cannot reconstruct an OpenCode TUI from HTTP after terminal startup frames are missed. The current terminal pane model must preserve terminal state through live attachment and replay from startup, or replace it with a server-side terminal emulator/snapshot owner.
+
+### 2026-05-17 hidden-pane TUI hydration failure classification
+
+The `2026-05-17` user-visible failure was not that OpenCode sessions failed to resume. The restored processes, session bindings, and OpenCode HTTP sessions existed; the broken part was terminal viewport hydration for hidden restored panes.
+
+- `/home/user/.freshell/logs/session-lifecycle.production.3001.jsonl:2874` through `:2888` show restored OpenCode creates for five canonical session ids, including `ses_1d0ba9968ffeNn5tFfCoX55KmM`, followed by `terminal_session_bound` and `terminal_created` for each new terminal id.
+- The process table after the restart showed those terminals still running as child processes of the Freshell server, for example `opencode --hostname 127.0.0.1 --port 33995 --session ses_1d0ba9968ffeNn5tFfCoX55KmM`.
+- `curl http://127.0.0.1:33995/session` returned the restored OpenCode session with title `Yente reverse port to nanoclaw investigation`, proving the provider process and canonical session were alive.
+- The failure signal was `terminal_stream_replay_miss` followed by `terminal_stream_gap` on later `viewport_hydrate` attaches. Examples include `/home/user/.freshell/logs/20260517-0031-01-server-debug.production.3001.jsonl:2843` through `:2844` for restored terminal `V7SooJOfFJjuZmtWTc-1M` with `sinceSeq: 0`, missed seq `1-465`, and `reason: "replay_window_exceeded"`; `:4711` through `:4712` for `itWOwjSsP3-5uZNu9CK4c` with missed seq `1-59862`; and `:9612` through `:9613` for `fp3MKIy0ajGwUIv3k_Q1-` with missed seq `1-20848`.
+- Focus/activation was separately checked because a successful process restore is not enough. `/home/user/.freshell/logs/20260517-0031-01-server-debug.production.3001.jsonl:11362` through `:11364` show the focused attach for `V7SooJOfFJjuZmtWTc-1M` still requesting `sinceSeq: 0` and receiving `replay_window_exceeded` for missed seq `1-42262`; `:11373` through `:11375` show the same focus-time failure for `itWOwjSsP3-5uZNu9CK4c` with missed seq `1-83908`.
+- Current source explains the failure. Persisted panes are all mounted, including hidden tabs, through `/home/user/code/freshell/.worktrees/dev/src/App.tsx:1224` through `:1226`; hidden terminal panes still send `terminal.create` when they have no `terminalId` at `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx:2527` through `:2534`; but the hidden `terminal.created` handler stores the new terminal id and defers attach at `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx:2084` through `:2149`. Later `viewport_hydrate` attaches send `sinceSeq: 0` at `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx:1629` through `:1632`.
+- Server replay is ordered and bounded, not full-history. The replay ring evicts by byte budget at `/home/user/code/freshell/.worktrees/dev/server/terminal-stream/replay-ring.ts:43` through `:78`, and the broker emits `replay_window_exceeded` when `sinceSeq` is older than the retained tail at `/home/user/code/freshell/.worktrees/dev/server/terminal-stream/broker.ts:201` through `:235`. This makes late OpenCode viewport hydration unrecoverable without another terminal-state snapshot.
+
+Two focused client lifecycle policies were tested against this failure:
+
+| Policy | Test proof | Product tradeoff |
+| --- | --- | --- |
+| Immediate hidden attach after `terminal.created` | In `/home/user/code/freshell/.worktrees/codex-opencode-hidden-attach-A-20260517`, the red test `attaches hidden restored OpenCode terminals immediately after create` failed before the prototype because no `terminal.attach` was sent. Commit `ddba8fdfc9f03010075203ff4f631de670203251` made the OpenCode startup-probe suite pass by attaching immediately and processing startup probes live. | Preserves the current "prewarm restored panes" behavior, but it is still create-then-attach rather than a formally atomic create-and-attach protocol. |
+| Defer hidden restored OpenCode create until visible | In `/home/user/code/freshell/.worktrees/opencode-defer-probe`, the red test `defers hidden restored OpenCode process creation` failed before the prototype because hidden restored OpenCode sent `terminal.create`. Commit `01a98383` made the focused lifecycle suite pass by keeping the restore request unconsumed until reveal. | Deterministically removes hidden-output-before-attach. Hidden restored OpenCode panes become queued restores, not live background terminals, until clicked or otherwise made visible. |
+| Visible replay-gap replacement for already-broken panes | In `/home/user/code/freshell/.worktrees/opencode-focus-activation-repro-20260517`, the red test `recreates a restored OpenCode pane when visible viewport hydration cannot replay startup output` failed before the implementation because the pane kept the stale `terminalId` after `replay_window_exceeded`. The green path sends `terminal.kill`, waits for `terminal.exit` or invalid-terminal confirmation, clears the stale live handle, and sends a restored `terminal.create` with the same OpenCode `sessionRef`. | Repairs panes that already reached the bad hidden-created state. It is not a replacement for prevention; it deliberately retires the stale OpenCode PTY so the server will not reuse the same canonical running terminal. |
+
+The tested production recommendation is the defer-create policy for future hidden restored OpenCode panes, plus visible replay-gap replacement for panes already stuck with a stale live handle. This matches the requirement that old OpenCode sessions remain listed and resumable when clicked, and avoids pretending a hidden OpenCode TUI can be deterministically reconstructed from replay after missing startup terminal state. If Freshell later wants background live OpenCode restores, the next architecture should be an explicit atomic create-and-attach protocol or server-side terminal emulator/snapshot support, not redraw nudges.
+
### 2026-05-16 dev restart failure classification
The research premise was incomplete but correct: OpenCode has authoritative root-child metadata, and Freshell must use it. The PR did not fail because OpenCode lacks parent metadata. It failed in current `/home/user/code/freshell/.worktrees/dev` because the root resolver was not wired into the running tracker after integration, and because the client can attempt a stale live-terminal attach before restored creation finishes.
@@ -815,4 +866,8 @@ Observed title behavior:
- Busy or restore state may only be promoted from the control surface or canonical DB/session events after child ids have been resolved to roots.
- `/session/status` alone is insufficient in TUI state because it is a flat status map with no parent metadata.
- `opencode --session ` is necessary for restored launch but insufficient as the whole Freshell restore proof; Freshell must verify that the pane attached to the new terminal and that the activity tracker resolved child statuses to the same root.
+- Hidden restored OpenCode panes should not start a PTY until visible unless Freshell also creates a live terminal attachment or server-side terminal emulator before OpenCode can emit startup control frames.
+- A replay gap during OpenCode `viewport_hydrate` is a visible restore failure, not a condition to repair with Ctrl-L, resize, redraw delay, or a larger replay cap.
+- If a restored OpenCode pane is visible and hits `replay_window_exceeded` during `viewport_hydrate` from seq 0, the stale PTY must be retired before reissuing a restored create. Otherwise the server can legally reuse the same canonical running terminal and reproduce the blank pane.
+- OpenCode HTTP can support a native session browser or timeline UI, but it cannot reconstruct the terminal TUI screen in the tested 1.15.3 surface.
- Titles are metadata and do not replace session identity.
diff --git a/docs/plans/2026-04-18-fresh-agent-platform-test-plan.md b/docs/plans/2026-04-18-fresh-agent-platform-test-plan.md
new file mode 100644
index 000000000..92eb37188
--- /dev/null
+++ b/docs/plans/2026-04-18-fresh-agent-platform-test-plan.md
@@ -0,0 +1,210 @@
+# Fresh Agent Platform Test Plan
+
+The agreed testing strategy still holds after reconciling it against [2026-04-18-fresh-agent-platform.md](/home/user/code/freshell/.worktrees/fresh-agent-platform/docs/plans/2026-04-18-fresh-agent-platform.md:1). The plan expands the interaction surface beyond the earlier high-level summary, but it does not require paid APIs, external infrastructure, or manual validation. The main adjustment is emphasis: acceptance has to be led by the real migration and user-facing surfaces in this order: persisted layout/settings migration, remote snapshot and session-directory projection, fresh-agent WS and HTTP transport, rendered shared pane behavior, then browser flows for create/resume/fork/mobile.
+
+## Harness requirements
+
+- `fresh-agent-route-harness`
+ What it does: extends the existing `read-model-route-harness` / `supertest` route coverage to mount `/api/fresh-agent/threads/...` routes with revision-aware snapshot, page, and turn-body handlers.
+ Exposes: HTTP status/body assertions, revision conflict injection, lane/scheduler observation, route call logs.
+ Estimated complexity: low-medium.
+ Tests depending on it: 5, 6, 7.
+
+- `fresh-agent-ws-harness`
+ What it does: extends the existing `protocol-harness` / `WsHandler` integration setup so tests can send and observe `freshAgent.*` messages alongside legacy terminal traffic.
+ Exposes: ordered outbound WS messages, adapter call capture, reconnect and lost-session simulation, ready-handshake transcript.
+ Estimated complexity: medium.
+ Tests depending on it: 4, 8, 9, 15.
+
+- `adapter-fixture-harness`
+ What it does: loads recorded Claude ledger fixtures and Codex app-server fixtures through the real normalization path, then exposes normalized snapshots/pages/bodies to server and client tests.
+ Exposes: deterministic provider fixtures, normalized thread snapshots, extension payloads, capability flags, fork/worktree/subagent metadata.
+ Estimated complexity: medium.
+ Tests depending on it: 10, 11, 12, 13, 14, 16.
+
+- `rendered-fresh-agent-app`
+ What it does: extends the existing RTL app and pane harnesses so a `fresh-agent` pane can be rendered with the real reducers, tabs/panes persistence, fresh-agent store, and context-menu wiring.
+ Exposes: rendered transcript/composer/banners, tab and pane state, persisted layout state, outbound WS/API calls.
+ Estimated complexity: medium.
+ Tests depending on it: 1, 2, 3, 8, 9, 12, 13, 14.
+
+- `playwright-fresh-agent-fixtures`
+ What it does: extends the existing Playwright `freshellPage`/`harness` fixtures to seed migrated browser storage and provider fixtures for Freshclaude and Freshcodex browser flows.
+ Exposes: real browser UI, screenshots/snapshots, isolated server info, browser storage seeding, harness state inspection.
+ Estimated complexity: medium.
+ Tests depending on it: 15, 16, 17, 18.
+
+## Test plan
+
+1. **Name:** Reloading a saved Freshclaude tab migrates `agent-chat` storage to `fresh-agent` without losing the pane, settings, or resume identity
+ - **Type:** scenario
+ - **Disposition:** extend
+ - **Harness:** `rendered-fresh-agent-app`
+ - **Preconditions:** Browser storage contains a combined layout payload with `kind: 'agent-chat'`, legacy `settings.agentChat`, pane titles, and a matching tab resume fallback.
+ - **Actions:** Run the real storage migration; bootstrap the app from the migrated browser storage; render the restored tab and pane.
+ - **Expected outcome:** Per the implementation plan sections `Steady-State Product Behavior`, `Contracts And Invariants`, and Task 1, the restored pane renders as `kind: 'fresh-agent'`, preserves `sessionType: 'freshclaude'`, keeps the existing pane/tab titles and resume identity, and does not clear unrelated Freshell browser state. Primary assertions are the rendered pane shell and persisted layout payload; supporting assertions may inspect the migrated JSON written back to the real storage keys.
+ - **Interactions:** `src/store/storage-migration.ts`, `src/store/persistedState.ts`, `src/store/paneTypes.ts`, `src/components/TabContent.tsx`, settings bootstrap.
+
+2. **Name:** Cross-tab hydrate preserves the local canonical resume identity when a remote snapshot still carries the older session id
+ - **Type:** regression
+ - **Disposition:** extend
+ - **Harness:** `rendered-fresh-agent-app`
+ - **Preconditions:** Local store already holds a `fresh-agent` pane with canonical `resumeSessionId`; an incoming persisted layout broadcast contains the same pane id with an older resume id and matching session family.
+ - **Actions:** Feed the remote persisted layout through the real cross-tab sync path.
+ - **Expected outcome:** Per Task 1 and the plan invariant `runtime readers must accept both legacy agent-chat persisted data and the new fresh-agent shape until every ... hydrate path has been switched`, the hydrated layout keeps the local canonical resume id on the pane and tab fallback metadata while still applying non-conflicting remote layout updates. Primary assertions are the post-hydrate pane/tab state visible to the app.
+ - **Interactions:** `src/store/crossTabSync.ts`, `src/store/persistControl.ts`, pane hydration, tab merge.
+
+3. **Name:** Remote tab snapshots round-trip a `fresh-agent` pane back into a reopenable tab with the same session identity and pane kind
+ - **Type:** integration
+ - **Disposition:** new
+ - **Harness:** `rendered-fresh-agent-app`
+ - **Preconditions:** A tab registry record or layout snapshot exists for a remote device with a rich agent pane, pane title metadata, and a session locator.
+ - **Actions:** Serialize the open tab via the real snapshot path; hydrate it through the real `TabsView` reopen flow; open the restored tab in the client.
+ - **Expected outcome:** Per `Remote layout snapshots and tab registry records must serialize fresh-agent, not agent-chat`, the reopened tab builds a `fresh-agent` pane, keeps `sessionType`, restores the session locator, and renders the right pane label/icon instead of falling back to picker or terminal mode. Primary assertions are the reopened tab/pane behavior in the rendered UI.
+ - **Interactions:** `server/agent-api/layout-store.ts`, `server/tabs-registry/types.ts`, `src/components/TabsView.tsx`, `src/lib/tab-registry-snapshot.ts`.
+
+4. **Name:** `freshAgent.create` routes to the adapter selected by `sessionType` while terminal WS traffic remains unchanged
+ - **Type:** integration
+ - **Disposition:** new
+ - **Harness:** `fresh-agent-ws-harness`
+ - **Preconditions:** The WS handler is mounted with a provider registry containing at least `freshclaude` and `freshcodex` adapters plus a terminal registry.
+ - **Actions:** Open a WS connection; send `freshAgent.create` for `freshcodex`; then send a normal `terminal.create`.
+ - **Expected outcome:** Per Task 2, the fresh-agent create call is dispatched to the Codex adapter chosen by `sessionType`, emits fresh-agent namespaced responses, and does not alter or intercept the terminal create flow. Primary assertions are the ordered outbound WS messages and adapter call log.
+ - **Interactions:** `server/ws-handler.ts`, `server/fresh-agent/runtime-manager.ts`, provider registry, existing terminal WS envelopes.
+
+5. **Name:** Fresh-agent thread routes reject stale revisions instead of serving mixed snapshot and body data
+ - **Type:** integration
+ - **Disposition:** new
+ - **Harness:** `fresh-agent-route-harness`
+ - **Preconditions:** A thread exists at revision `N`; the route harness can serve snapshot/page/body reads and inject stale revision conditions.
+ - **Actions:** Request the thread snapshot, turn page, and turn body with revision `N-1`; repeat with revision `N`.
+ - **Expected outcome:** Per Task 2 and the invariant `Read-model routes stay revisioned and lane-aware and must never mix bodies from one revision with summaries from another`, stale requests return `409` with the stale-revision code, while current-revision requests succeed and return the matching revision. Primary assertions are HTTP status and JSON payloads.
+ - **Interactions:** `shared/read-models.ts`, scheduler lane selection, fresh-agent router, revision handling.
+
+6. **Name:** Fresh-agent read-model routes stay lane-aware and do not regress visible-first route discipline
+ - **Type:** invariant
+ - **Disposition:** extend
+ - **Harness:** `fresh-agent-route-harness`
+ - **Preconditions:** The route harness is recording scheduled lane events for bootstrap, session directory, and fresh-agent thread reads.
+ - **Actions:** Fetch bootstrap, session directory, and fresh-agent thread routes with `critical`, `visible`, and `background` priorities.
+ - **Expected outcome:** Per the existing visible-first acceptance contract and Task 2, fresh-agent thread reads use the declared lane, do not force forbidden session-directory pre-ready routes, and preserve the scheduler event ordering already required elsewhere in the app. Primary assertions are scheduler event logs and route transcripts.
+ - **Interactions:** read-model scheduler, visible-first fixtures, bootstrap router, fresh-agent router.
+
+7. **Name:** Posting session metadata updates changes `sessionType` without clobbering the stored derived title
+ - **Type:** regression
+ - **Disposition:** extend
+ - **Harness:** `fresh-agent-route-harness`
+ - **Preconditions:** Session metadata store already contains `derivedTitle` for a Codex session.
+ - **Actions:** Call the real session metadata API with `{ provider: 'codex', sessionId, sessionType: 'freshcodex' }`; then read back the stored entry and session-directory projection.
+ - **Expected outcome:** Per Task 5 and the invariant `Session metadata remains keyed by provider:sessionId; updating sessionType must keep derivedTitle`, the session now reports `sessionType: 'freshcodex'` while the prior derived title remains intact in both storage and projection. Primary assertions are API response and projected session-directory JSON.
+ - **Interactions:** `server/session-metadata-store.ts`, `server/sessions-router.ts`, `server/session-directory/projection.ts`, index refresh.
+
+8. **Name:** A `fresh-agent` pane reconnects after lost-session transport errors by surfacing the explicit session-lost state and reloading through the fresh-agent transport
+ - **Type:** scenario
+ - **Disposition:** new
+ - **Harness:** `rendered-fresh-agent-app` plus `fresh-agent-ws-harness`
+ - **Preconditions:** A rendered `fresh-agent` pane is attached to an active thread; the WS harness can inject a lost-session error and a subsequent successful resume/snapshot.
+ - **Actions:** Deliver a fresh-agent lost-session error; trigger the pane’s retry or reconnect action; deliver the resumed snapshot/page stream.
+ - **Expected outcome:** Per Task 6 and Task 7, the pane shows a clear user-facing lost-session state, retry uses the fresh-agent transport rather than legacy `sdk.*`, and a successful retry restores the thread instead of degrading to a terminal or blank pane. Primary assertions are rendered error/recovery states and outbound WS/API calls.
+ - **Interactions:** `src/lib/ws-client.ts`, `src/lib/fresh-agent-ws.ts`, `src/store/freshAgentThunks.ts`, pane-level recovery UI.
+
+9. **Name:** The normalized fresh-agent client store merges live and durable updates by thread locator and revision without duplicating turns
+ - **Type:** invariant
+ - **Disposition:** new
+ - **Harness:** `rendered-fresh-agent-app`
+ - **Preconditions:** The client store contains a thread locator, an initial snapshot, and a later live delta plus durable page for the same revision family.
+ - **Actions:** Dispatch the real fresh-agent snapshot, page, and body handlers in the order expected during resume and live streaming.
+ - **Expected outcome:** Per Task 6 and the architecture section `Shared normalized read model`, the store keys the thread by runtime locator, retains stable turn/item ids, and renders one transcript with no duplicate turns/items when live and durable sources overlap. Primary assertions are rendered transcript order and user-visible de-duplication.
+ - **Interactions:** `src/store/freshAgentSlice.ts`, `src/store/freshAgentThunks.ts`, read-model hydration, transcript rendering.
+
+10. **Name:** Claude adapter restores one canonical thread from ledger-backed durable history plus live stream state
+ - **Type:** integration
+ - **Disposition:** new
+ - **Harness:** `adapter-fixture-harness`
+ - **Preconditions:** Claude ledger fixtures cover durable backlog, live stream overlap, question state, approval state, and model/permission metadata.
+ - **Actions:** Load the fixtures through the real Claude fresh-agent adapter; request snapshot, page, and body data for the same thread.
+ - **Expected outcome:** Per Task 3 and the architecture section `Claude runtime implementation stays behind the adapter boundary; the ledger/history strategy is preserved`, the normalized snapshot contains one canonical thread, preserves questions/approvals/model settings, and exposes provider-native detail as extension data rather than flattening it away. Primary assertions are normalized snapshot/page/body outputs from the adapter harness.
+ - **Interactions:** `server/fresh-agent/adapters/claude/*`, `server/sdk-bridge.ts`, `server/agent-timeline/*`.
+
+11. **Name:** Codex adapter normalizes fork, worktree, review, token, and child-thread metadata into the shared fresh-agent model
+ - **Type:** integration
+ - **Disposition:** new
+ - **Harness:** `adapter-fixture-harness`
+ - **Preconditions:** Codex app-server fixtures include raw rich-session events with fork lineage, worktree info, review/diff references, token summaries, and subagent children.
+ - **Actions:** Load the fixtures through the real Codex adapter and request snapshot/page/body data.
+ - **Expected outcome:** Per Task 4 and `Freshcodex rich panes use the Codex app-server as the source of truth`, the normalized thread advertises the correct capabilities, exposes worktree and child-thread refs, and keeps provider-specific extensions for Codex-only metadata. Primary assertions are the normalized shared-model payloads.
+ - **Interactions:** `server/coding-cli/codex-app-server/*`, `server/fresh-agent/adapters/codex/*`, provider extension payloads.
+
+12. **Name:** The shared fresh-agent pane shell shows provider-specific capabilities without changing the core transcript, composer, or banner affordances
+ - **Type:** scenario
+ - **Disposition:** new
+ - **Harness:** `rendered-fresh-agent-app`
+ - **Preconditions:** One normalized Freshclaude thread fixture and one normalized Freshcodex thread fixture are available with differing capability flags.
+ - **Actions:** Render the shared `FreshAgentView` with the Claude thread, then with the Codex thread; activate capability-backed controls such as interrupt, fork, and approval/question actions where available.
+ - **Expected outcome:** Per Task 7 and `Existing Freshclaude UX stays intact unless the new shared shell makes it stronger`, both providers render the same shell structure, Claude-only or Codex-only actions appear only when their capability flags permit them, and activating those controls produces the expected user-visible state changes or outbound actions. Primary assertions are the rendered controls and their activation effects.
+ - **Interactions:** `src/components/fresh-agent/*`, `src/lib/fresh-agent-capabilities.ts`, context menus, shared session rendering primitives.
+
+13. **Name:** Freshclaude input history survives the architecture cutover and remains scoped to the pane
+ - **Type:** regression
+ - **Disposition:** extend
+ - **Harness:** `rendered-fresh-agent-app`
+ - **Preconditions:** A Freshclaude `fresh-agent` pane is rendered with a composer bound to a stable pane id.
+ - **Actions:** Send multiple prompts, navigate history with ArrowUp/ArrowDown, reload or remount the pane, and reopen history.
+ - **Expected outcome:** Per Task 7 and the requirement to preserve current Freshclaude behavior, the composer recalls sent prompts in order, preserves draft behavior, persists history across reload/remount, and keeps the history isolated to the pane id rather than global session state. Primary assertions are the rendered composer value and persisted input-history storage key contents.
+ - **Interactions:** `src/lib/input-history-store.ts`, composer state, pane persistence.
+
+14. **Name:** Fresh-agent context menus target the migrated pane/session surfaces and keep resume-command, diff, and copy actions working
+ - **Type:** scenario
+ - **Disposition:** extend
+ - **Harness:** `rendered-fresh-agent-app`
+ - **Preconditions:** A rendered fresh-agent transcript contains tool input, diff content, and a resumable session reference; tabs and panes are present in the real menu-building context.
+ - **Actions:** Open the context menu on transcript text, tool input, diff content, and the pane/tab chrome; activate the copy-resume-command and agent-content copy actions.
+ - **Expected outcome:** Per Task 7 and Task 8, the menu resolves targets against `fresh-agent` panes rather than `agent-chat`, exposes the right copy/resume items, and dispatches the expected action for each activated item. Primary assertions are rendered menu items and resulting clipboard/action calls.
+ - **Interactions:** `src/components/context-menu/menu-defs.ts`, session refs, resume-command helpers, transcript DOM attributes.
+
+15. **Name:** Browser user can create and resume Freshcodex with visible worktree and fork metadata in the shared pane
+ - **Type:** scenario
+ - **Disposition:** new
+ - **Harness:** `playwright-fresh-agent-fixtures`
+ - **Preconditions:** Playwright server fixture has Codex rich-session fixtures available and Freshcodex enabled in the pane picker.
+ - **Actions:** Open the pane picker, create a Freshcodex pane, allow the thread to load, navigate away and back or reload to resume it, then activate the fork/worktree UI affordances.
+ - **Expected outcome:** Per the user goal and Tasks 4, 6, and 7, the real browser UI shows a Freshcodex pane, preserves the resumed thread after reload, and visibly surfaces worktree/fork metadata through the shared shell. Primary assertions are browser-visible controls, labels, and thread content; supporting assertions may read harness state.
+ - **Interactions:** pane picker, fresh-agent transport, Codex adapter, browser storage persistence, shared pane shell.
+
+16. **Name:** Browser user can restore Freshclaude and still see approval and question banners after the migration
+ - **Type:** scenario
+ - **Disposition:** extend
+ - **Harness:** `playwright-fresh-agent-fixtures`
+ - **Preconditions:** Playwright browser storage or server fixture seeds a persisted Freshclaude rich pane with outstanding approval and question state.
+ - **Actions:** Load the app, open the restored pane, respond to the approval/question controls, and reload the page.
+ - **Expected outcome:** Per `Existing Freshclaude sessions, settings, reopen entries, local layouts, remote tab snapshots, and sidebar/history items survive migration with no manual repair`, the restored pane appears automatically, banners are visible and actionable, and their resolved state persists across reload. Primary assertions are the browser-visible alert/banner content and action effects.
+ - **Interactions:** persisted layout migration, Claude adapter, fresh-agent client store, banner actions.
+
+17. **Name:** Mobile browser flows keep the fresh-agent shell usable without regressing tab and sidebar navigation
+ - **Type:** scenario
+ - **Disposition:** extend
+ - **Harness:** `playwright-fresh-agent-fixtures`
+ - **Preconditions:** Playwright viewport is mobile-sized and the app starts with at least one rich agent tab plus standard tab strip controls.
+ - **Actions:** Open the mobile tab switcher, create or switch to a fresh-agent tab, open the sidebar/history surface, and return to the pane.
+ - **Expected outcome:** Per the implementation plan requirement `Mobile and sidebar behavior remain first-class requirements`, the mobile tab strip, tab switcher, sidebar open/close controls, and fresh-agent pane remain operable and visually coherent; the user can reach and return from the sidebar/history flows without losing the active rich pane. Primary assertions are browser-visible controls and resulting navigation state.
+ - **Interactions:** `src/components/MobileTabStrip.tsx`, `src/components/Sidebar.tsx`, `src/components/HistoryView.tsx`, rich pane mount/unmount behavior.
+
+18. **Name:** Full targeted verification replaces legacy `agent-chat` browser proofs with fresh-agent browser proofs before deletion
+ - **Type:** regression
+ - **Disposition:** new
+ - **Harness:** `playwright-fresh-agent-fixtures`
+ - **Preconditions:** Replacement browser specs exist for the flows currently covered by `agent-chat.spec.ts`, `agent-chat-input-history.spec.ts`, `pane-activity-indicator.spec.ts`, and the rich-pane portions of `tab-management.spec.ts`.
+ - **Actions:** Run the fresh-agent browser specs that cover create, resume, input history, pane activity, and tab restoration; compare their covered user-visible behaviors against the legacy spec inventory before any legacy rich-pane spec is deleted or renamed away.
+ - **Expected outcome:** Per Task 8 and `Port existing regression coverage forward; do not delete a test unless its behavior is demonstrably covered elsewhere`, the fresh-agent browser suite proves the same user-visible behaviors before legacy `agent-chat` browser coverage is removed. Primary assertions are passing browser scenarios and a one-to-one coverage mapping in the renamed replacement specs.
+ - **Interactions:** Playwright fixtures, pane activity indicators, input history, restored tabs, spec migration inventory.
+
+## Coverage summary
+
+- Covered action space:
+ storage migration on startup; local settings migration; persisted layout parsing and hydration; cross-tab persisted-layout broadcast handling; remote tab snapshot serialization and reopen; session metadata POST; session-directory projection refresh; fresh-agent WebSocket create/resume/reconnect/lost-session handling; fresh-agent thread snapshot/page/body HTTP reads; Claude and Codex adapter normalization; shared fresh-agent transcript/composer/banner/diff/context-menu actions; pane picker create flows; browser restore flows; mobile tab/sidebar navigation.
+
+- Explicitly excluded per the agreed strategy:
+ live external Claude/Codex/OpenCode processes, real paid provider APIs, production-only worktree/review backends outside the repo’s existing fixtures, and manual visual review.
+
+- Risks carried by those exclusions:
+ true subprocess timing issues, upstream protocol drift, or production-only review/worktree behaviors could still appear after local verification. The strongest practical mitigation here is fixture-backed adapter coverage plus real browser/UI coverage against the repo’s own transport and persistence paths, which this plan makes the acceptance gate.
diff --git a/docs/plans/2026-04-18-fresh-agent-platform.md b/docs/plans/2026-04-18-fresh-agent-platform.md
new file mode 100644
index 000000000..f4ba5b95a
--- /dev/null
+++ b/docs/plans/2026-04-18-fresh-agent-platform.md
@@ -0,0 +1,915 @@
+# Fresh Agent Platform Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Ship a shared `fresh-agent` platform that powers `freshclaude` and `freshcodex` from one architecture, preserves existing Freshclaude behavior and saved state, and leaves a clean adapter seam for `freshopencode` later without another rewrite.
+
+**Architecture:** Cut over directly from the current `agent-chat` domain to a new `fresh-agent` domain that separates user-facing `sessionType` from runtime `provider`. Reuse Claude’s existing durable/live ledger stack and the existing Codex app-server runtime, normalize both into one read model plus one shared UI shell, and migrate every persistence/restore surface up front so current users keep their tabs, settings, history, and remote snapshots.
+
+**Tech Stack:** TypeScript, React 18, Redux Toolkit, Express, WebSocket/Zod contracts, existing read-model scheduler, Claude SDK bridge, Codex app-server runtime/client, Vitest, Testing Library, Playwright browser e2e.
+
+---
+
+## Why The Previous Plan Would Fail
+
+- It correctly chose the target architecture, but it did not fully cover the persistence and restore surfaces that still encode `agent-chat`. An executor following it would get partway through the cutover and then discover breakage in local storage versioning, remote tab snapshots, tab registry history, and sidebar resume behavior.
+- It did not account for [`src/store/storage-migration.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/store/storage-migration.ts:1), which currently hard-clears persisted browser state on incompatible version changes. A naive schema bump would erase exactly the saved Freshclaude tabs and settings the user asked to preserve.
+- It did not include the remote snapshot and restore path through [`server/agent-api/layout-store.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/agent-api/layout-store.ts:1), [`server/tabs-registry/types.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/tabs-registry/types.ts:1), [`src/store/tabRegistryTypes.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/store/tabRegistryTypes.ts:1), and [`src/components/TabsView.tsx`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/components/TabsView.tsx:1). Without those, reopened remote tabs would still serialize and hydrate `agent-chat`.
+- It did not include the layout bootstrap path through [`src/components/TabContent.tsx`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/components/TabContent.tsx:1), [`src/lib/tab-directory-preference.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/lib/tab-directory-preference.ts:1), and [`src/store/paneTreeValidation.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/store/paneTreeValidation.ts:1). Those still special-case `agent-chat`.
+- It sequenced the persistence cutover too early. If an executor migrates stored panes from `agent-chat` to `fresh-agent` before `PaneContainer`, `TabContent`, `crossTabSync`, pane-title helpers, and local snapshot parsing can read the new shape, the next reload or cross-tab hydrate will strand rich panes as unknown content.
+- It did not call out [`src/store/selectors/sidebarSelectors.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/src/store/selectors/sidebarSelectors.ts:1) and related session metadata helpers, which are where `provider` and `sessionType` semantics get merged for history/sidebar rendering. Missing them would reintroduce the wrong identity model after the store cutover.
+- It did not call out [`server/coding-cli/session-indexer.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/coding-cli/session-indexer.ts:1) and [`server/coding-cli/types.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/coding-cli/types.ts:1), which are where stored session metadata, derived titles, and indexed Codex runtime metadata get merged before the session directory and sidebar consume them. Leaving that seam out would make the migration look complete in storage while the user-visible projections stayed wrong.
+- It did not call out [`server/platform-router.ts`](/home/user/code/freshell/.worktrees/fresh-agent-platform/server/platform-router.ts:1), which is part of keeping hidden `kilroy` support wired through the platform feature flags.
+- It did not include the existing browser specs, Vitest e2e flows, context-menu tests, visible-first perf fixtures, and MCP help text that still hard-code `agent-chat` or `sdk.*`. An executor could finish the product code, hit the repo-wide verification gate, and then discover a second migration hidden in the test/tooling surface.
+- It was still too optimistic about “delete old `agent-chat` glue later”. Some of the old files are not merely legacy UI; they currently encode product-critical behavior that must be ported deliberately before deletion: restore hydration, question/approval state, plugin defaults, input history, and lost-session recovery.
+- It still leaned too hard toward rebuilding the UI layer from scratch. The repo already contains reusable shared rendering pieces in `src/components/session/*` plus reusable diff and settings primitives. A plan that does not explicitly direct the executor to reuse and promote those pieces risks violating the user’s “reuse as much as possible” requirement and burning time on unnecessary rewrites.
+- Its cleanup section was too deletion-oriented. Several files and tests listed as “modify/delete” are not dead weight; they are the current regression net for restore hydration, split-pane remounts, browser/mobile behavior, MCP tool help text, and visible-first performance contracts. Treating them as delete-first would cause avoidable backtracking and test dilution.
+
+## Steady-State Product Behavior
+
+- Rich panes use `kind: 'fresh-agent'`, not `kind: 'agent-chat'`.
+- `freshclaude`, `freshcodex`, and hidden `kilroy` all come from one fresh-agent registry.
+- `provider` means runtime family (`claude`, `codex`, later `opencode`).
+- `sessionType` means user-facing identity (`freshclaude`, `freshcodex`, `kilroy`, later `freshopencode`).
+- Existing Freshclaude sessions, settings, reopen entries, local layouts, remote tab snapshots, and sidebar/history items survive migration with no manual repair.
+- Raw CLI terminals remain separate pane types. Rich panes never silently degrade into terminal scraping.
+- Freshcodex rich panes use the Codex app-server as the source of truth and surface fork, diff/review, worktree, child-thread, and token/context metadata when the runtime exposes them.
+- OpenCode is not shipped here, but the registry, adapter interface, and normalized model already admit it without Claude- or Codex-specific assumptions leaking into shared code.
+
+## Contracts And Invariants
+
+### Naming and persistence
+
+- The final domain name is `fresh-agent`, not `agent-chat`.
+- Persisted pane/layout data must migrate existing `agent-chat` leaves to `fresh-agent` leaves.
+- Browser storage migration must preserve existing Freshell auth, layout, and settings data; do not solve this by clearing local storage.
+- Server/local settings must migrate `agentChat` to `freshAgent` while continuing to read legacy input during rollout.
+- Remote layout snapshots and tab registry records must serialize `fresh-agent`, not `agent-chat`.
+- Session metadata remains keyed by `provider:sessionId`; updating `sessionType` must keep `derivedTitle`.
+
+### Registry and adapters
+
+- One declarative registry owns labels, icons, settings visibility/defaults, runtime provider, and feature flags for `freshclaude`, `freshcodex`, `kilroy`, and disabled `freshopencode`.
+- Runtime adapters own `create`, `resume`, `subscribe`, `send`, `interrupt`, `fork`, `answerQuestion`, `resolveApproval`, `listThreads`, `getSnapshot`, `getTurnPage`, `getTurnBody`, and capability-backed workspace actions.
+- Claude runtime implementation stays behind the adapter boundary; the ledger/history strategy is preserved, not discarded.
+- Codex runtime reuses `server/coding-cli/codex-app-server/*`; extend that stack instead of duplicating it.
+
+### Read model and UI
+
+- All shared UI reads normalized fresh-agent data first and provider extensions second.
+- Normalized entities have stable ids for thread, turn, item, approval, question, diff, artifact, child thread, and worktree references.
+- Read-model routes stay revisioned and lane-aware and must never mix bodies from one revision with summaries from another.
+- Existing Freshclaude UX stays intact unless the new shared shell makes it stronger.
+- Mobile and sidebar behavior remain first-class requirements, not follow-up cleanup.
+- During the migration tasks, runtime readers must accept both legacy `agent-chat` persisted data and the new `fresh-agent` shape until every local bootstrap, cross-tab hydrate, and remote snapshot path has been switched.
+
+## File Structure
+
+### Create
+
+- `shared/fresh-agent.ts`
+- `server/fresh-agent/runtime-adapter.ts`
+- `server/fresh-agent/provider-registry.ts`
+- `server/fresh-agent/runtime-manager.ts`
+- `server/fresh-agent/router.ts`
+- `server/fresh-agent/adapters/claude/adapter.ts`
+- `server/fresh-agent/adapters/claude/normalize.ts`
+- `server/fresh-agent/adapters/codex/adapter.ts`
+- `server/fresh-agent/adapters/codex/normalize.ts`
+- `src/lib/fresh-agent-registry.ts`
+- `src/lib/fresh-agent-capabilities.ts`
+- `src/lib/fresh-agent-ws.ts`
+- `src/store/freshAgentTypes.ts`
+- `src/store/freshAgentSlice.ts`
+- `src/store/freshAgentThunks.ts`
+- `src/components/fresh-agent/FreshAgentView.tsx`
+- `src/components/fresh-agent/FreshAgentTranscript.tsx`
+- `src/components/fresh-agent/FreshAgentComposer.tsx`
+- `src/components/fresh-agent/FreshAgentSidebar.tsx`
+- `src/components/fresh-agent/FreshAgentApprovalBanner.tsx`
+- `src/components/fresh-agent/FreshAgentQuestionBanner.tsx`
+- `src/components/fresh-agent/FreshAgentDiffPanel.tsx`
+- `test/fixtures/fresh-agent/claude/*`
+- `test/fixtures/fresh-agent/codex/*`
+- `test/e2e-browser/specs/fresh-agent.spec.ts`
+- `test/e2e-browser/specs/fresh-agent-mobile.spec.ts`
+
+### Modify
+
+- `shared/ws-protocol.ts`
+- `shared/read-models.ts`
+- `shared/settings.ts`
+- `server/config-store.ts`
+- `server/index.ts`
+- `server/ws-handler.ts`
+- `server/platform-router.ts`
+- `server/sdk-bridge.ts`
+- `server/sdk-bridge-types.ts`
+- `server/agent-timeline/*`
+- `server/coding-cli/codex-app-server/protocol.ts`
+- `server/coding-cli/codex-app-server/client.ts`
+- `server/coding-cli/codex-app-server/runtime.ts`
+- `server/coding-cli/codex-app-server/launch-planner.ts`
+- `server/coding-cli/providers/codex.ts`
+- `server/session-directory/*`
+- `server/sessions-router.ts`
+- `server/session-metadata-store.ts`
+- `server/agent-api/layout-store.ts`
+- `server/tabs-registry/types.ts`
+- `src/store/paneTypes.ts`
+- `src/store/panesSlice.ts`
+- `src/store/persistedState.ts`
+- `src/store/persistMiddleware.ts`
+- `src/store/crossTabSync.ts`
+- `src/store/storage-migration.ts`
+- `src/store/store.ts`
+- `src/store/persistControl.ts`
+- `src/store/tabsSlice.ts`
+- `src/store/settingsSlice.ts`
+- `src/store/settingsThunks.ts`
+- `src/store/browserPreferencesPersistence.ts`
+- `src/store/paneTreeValidation.ts`
+- `src/store/tabRegistryTypes.ts`
+- `src/store/selectors/sidebarSelectors.ts`
+- `src/lib/session-type-utils.ts`
+- `src/lib/derivePaneTitle.ts`
+- `src/lib/pane-title.ts`
+- `src/lib/pane-activity.ts`
+- `src/lib/session-utils.ts`
+- `src/lib/input-history-store.ts`
+- `src/lib/tab-directory-preference.ts`
+- `src/lib/tab-registry-snapshot.ts`
+- `src/lib/ws-client.ts`
+- `src/lib/api.ts`
+- `src/lib/agent-chat-utils.ts`
+- `src/lib/agent-chat-types.ts`
+- `src/components/session/MessageBubble.tsx`
+- `src/components/session/ToolCallBlock.tsx`
+- `src/components/panes/PaneContainer.tsx`
+- `src/components/panes/PanePicker.tsx`
+- `src/components/Sidebar.tsx`
+- `src/components/HistoryView.tsx`
+- `src/components/TabContent.tsx`
+- `src/components/TabsView.tsx`
+- `src/components/context-menu/ContextMenuProvider.tsx`
+- `src/components/context-menu/context-menu-constants.ts`
+- `src/components/context-menu/context-menu-types.ts`
+- `src/components/context-menu/context-menu-utils.ts`
+- `src/components/context-menu/menu-defs.ts`
+- `src/components/icons/PaneIcon.tsx`
+- `src/components/TabBar.tsx`
+- `src/components/TabSwitcher.tsx`
+- `src/components/MobileTabStrip.tsx`
+- `src/components/SettingsView.tsx`
+- `src/components/settings/WorkspaceSettings.tsx`
+- `src/components/agent-chat/DiffView.tsx`
+- `server/mcp/freshell-tool.ts`
+- `docs/index.html`
+- `test/unit/client/store/panesPersistence.test.ts`
+- `test/unit/server/agent-layout-schema.test.ts`
+- `test/unit/server/tabs-registry/types.test.ts`
+- `test/integration/server/settings-api.test.ts`
+- `test/integration/server/tabs-registry-store.persistence.test.ts`
+- `test/integration/server/session-directory-router.test.ts`
+
+### Delete Only After Porting Behavior And Coverage
+
+- `src/store/agentChatSlice.ts`
+- `src/store/agentChatThunks.ts`
+- `src/store/agentChatTypes.ts`
+- `src/lib/sdk-message-handler.ts`
+- legacy `src/components/agent-chat/*` files that are provably dead after their behavior has been moved into `src/components/fresh-agent/*` or promoted shared primitives
+- renamed or superseded test files only after the replacement tests cover the same restore, split-pane, session-lost, input-history, mobile, context-menu, and perf behaviors
+- any other `sdk.*` or `agent-chat` glue that no longer carries real behavior after the new transport, persistence, and coverage are all green
+
+## Strategy Gate
+
+The right path is a direct cutover to the final architecture, not another “genericize `sdk.*` later” detour. The repo already contains the two foundations this work must respect:
+
+- Claude has durable/live merge and restore semantics in `server/sdk-bridge.ts` and `server/agent-timeline/*`.
+- Codex already has a shared app-server runtime/client/planner in `server/coding-cli/codex-app-server/*`.
+- The client already has reusable generalized rendering and event primitives in `src/components/session/*`, `src/components/agent-chat/DiffView.tsx`, `src/lib/input-history-store.ts`, and `src/lib/coding-cli-types.ts`.
+
+The plan therefore reuses both, renames the product domain to `fresh-agent`, migrates persistence/settings/restore surfaces first, and then lands one transport, one read model, one store, and one UI shell. That is the cleanest route to the requested end state and the only route that avoids another rewrite when `freshopencode` arrives.
+
+### Task 1: Rename the domain to `fresh-agent` and migrate local/server persistence without data loss
+
+**Files:**
+- Create: `shared/fresh-agent.ts`
+- Create: `src/lib/fresh-agent-registry.ts`
+- Modify: `shared/settings.ts`
+- Modify: `server/config-store.ts`
+- Modify: `server/platform-router.ts`
+- Modify: `src/store/settingsSlice.ts`
+- Modify: `src/store/settingsThunks.ts`
+- Modify: `src/store/browserPreferencesPersistence.ts`
+- Modify: `src/store/storage-migration.ts`
+- Modify: `src/store/paneTypes.ts`
+- Modify: `src/store/panesSlice.ts`
+- Modify: `src/store/persistedState.ts`
+- Modify: `src/store/persistMiddleware.ts`
+- Modify: `src/store/crossTabSync.ts`
+- Modify: `src/store/paneTreeValidation.ts`
+- Modify: `src/lib/agent-chat-utils.ts`
+- Modify: `src/lib/agent-chat-types.ts`
+- Modify: `src/lib/pane-title.ts`
+- Modify: `src/components/panes/PaneContainer.tsx`
+- Modify: `src/components/TabContent.tsx`
+- Modify: `src/lib/tab-registry-snapshot.ts`
+- Test: `test/unit/shared/fresh-agent-registry.test.ts`
+- Test: `test/unit/client/store/persisted-state.fresh-agent.test.ts`
+- Test: `test/unit/client/store/storage-migration.fresh-agent.test.ts`
+- Test: `test/unit/client/store/crossTabSync.test.ts`
+- Test: `test/unit/client/store/panesPersistence.test.ts`
+- Test: `test/unit/client/components/panes/PaneContainer.createContent.test.tsx`
+- Test: `test/unit/server/config-store.fresh-agent-settings.test.ts`
+- Test: `test/integration/server/settings-api.test.ts`
+- Test: `test/unit/server/agent-layout-schema.test.ts`
+- Test: `test/unit/client/store/tabsSlice.merge.test.ts`
+- Test: `test/integration/server/platform-api.test.ts`
+
+- [ ] **Step 1: Identify or write the failing tests**
+
+Add tests that pin the migration and registry rules:
+
+```ts
+it('migrates persisted agent-chat panes to fresh-agent panes', () => {
+ const parsed = parsePersistedPanesRaw(JSON.stringify({
+ version: 6,
+ layouts: {
+ tab_1: {
+ type: 'leaf',
+ id: 'pane_1',
+ content: { kind: 'agent-chat', provider: 'freshclaude', createRequestId: 'req-1', status: 'idle' },
+ },
+ },
+ }))
+ expect(findLeafContent(parsed!.layouts.tab_1)).toMatchObject({ kind: 'fresh-agent', sessionType: 'freshclaude' })
+})
+
+it('migrates legacy settings.agentChat to settings.freshAgent', () => {
+ const settings = resolveServerSettings({
+ agentChat: { defaultPlugins: ['/tmp/plugin'], providers: { freshclaude: { defaultModel: 'x' } } },
+ } as any)
+ expect(settings.freshAgent.defaultPlugins).toEqual(['/tmp/plugin'])
+})
+
+it('does not clear freshell layout storage during the fresh-agent migration', () => {
+ // Use the real layout-storage key from storage-migration.ts in the implementation test.
+ // The literal below is illustrative only.
+ localStorage.setItem('freshell.layout.v3', '{"version":3}')
+ runStorageMigration()
+ expect(localStorage.getItem('freshell.layout.v3')).toBe('{"version":3}')
+})
+
+it('keeps kilroy as a hidden claude-backed fresh-agent type', () => {
+ expect(resolveFreshAgentType('kilroy')).toMatchObject({ runtimeProvider: 'claude', hidden: true })
+})
+
+it('hydrates a persisted fresh-agent pane without falling back to an unknown pane kind', () => {
+ const content = getLeafContentFromHydratedLayout({
+ type: 'leaf',
+ id: 'pane_1',
+ content: { kind: 'fresh-agent', sessionType: 'freshclaude', provider: 'claude', createRequestId: 'req-1', status: 'idle' },
+ })
+ expect(content).toMatchObject({ kind: 'fresh-agent', sessionType: 'freshclaude' })
+})
+
+it('preserves canonical resume identity when cross-tab sync rehydrates a fresh-agent pane', () => {
+ const result = protectCanonicalPaneResumeIdentity(
+ buildLeaf('pane_1', { kind: 'fresh-agent', provider: 'claude', resumeSessionId: 'remote-id' }),
+ buildLeaf('pane_1', { kind: 'fresh-agent', provider: 'claude', resumeSessionId: 'local-id' }),
+ )
+ expect(getLeafContent(result)?.resumeSessionId).toBe('local-id')
+})
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/server/agent-layout-schema.test.ts test/integration/server/settings-api.test.ts test/integration/server/platform-api.test.ts`
+Expected: FAIL because the registry and migrations do not exist yet.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Implement the shared vocabulary and migrations:
+
+- `FreshAgentSessionType = 'freshclaude' | 'freshcodex' | 'kilroy' | 'freshopencode'`
+- `FreshAgentRuntimeProvider = 'claude' | 'codex' | 'opencode'`
+- registry entries for `freshclaude`, `freshcodex`, hidden `kilroy`, and disabled `freshopencode`
+- persisted layout migration from `kind: 'agent-chat'` to `kind: 'fresh-agent'`
+- server/local settings migration from `agentChat` to `freshAgent`, still accepting legacy input
+- storage migration that preserves saved Freshell state instead of clearing it by rewriting the persisted rich-pane/settings payloads before any incompatible-version clear path runs; do not preserve data by skipping migration or suppressing future version bumps
+- pane content shape that stores `sessionType` explicitly instead of overloading `provider`
+- compatibility readers in `PaneContainer`, `TabContent`, `crossTabSync`, pane-title helpers, and local snapshot helpers so fresh-agent layouts can boot before the legacy client state is removed
+- migration of browser preference and input-history surfaces that currently derive behavior from legacy `agent-chat` pane data
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/server/agent-layout-schema.test.ts test/integration/server/settings-api.test.ts test/integration/server/platform-api.test.ts`
+Expected: PASS
+
+- [ ] **Step 5: Refactor and verify**
+
+Refactor compatibility to one place only:
+
+- legacy `agent-chat` parsing belongs in persisted/settings migration code
+- runtime readers accept `fresh-agent` first and tolerate legacy `agent-chat` only at bootstrap boundaries
+- `agent-chat` helper modules become thin compatibility exports or are queued for removal
+
+Run: `npm run test:vitest -- test/unit/shared/fresh-agent-registry.test.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/server/config-store.fresh-agent-settings.test.ts test/unit/server/agent-layout-schema.test.ts test/unit/client/store/tabsSlice.merge.test.ts test/integration/server/settings-api.test.ts test/integration/server/platform-api.test.ts`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add shared/fresh-agent.ts src/lib/fresh-agent-registry.ts shared/settings.ts server/config-store.ts server/platform-router.ts src/store/settingsSlice.ts src/store/settingsThunks.ts src/lib/agent-chat-utils.ts src/lib/agent-chat-types.ts test/unit/shared/fresh-agent-registry.test.ts test/unit/server/config-store.fresh-agent-settings.test.ts test/integration/server/settings-api.test.ts test/integration/server/platform-api.test.ts
+git commit -m "refactor: add fresh agent registry and settings vocabulary"
+
+git add src/store/browserPreferencesPersistence.ts src/store/storage-migration.ts src/store/paneTypes.ts src/store/panesSlice.ts src/store/persistedState.ts src/store/persistMiddleware.ts src/store/crossTabSync.ts src/store/paneTreeValidation.ts test/unit/client/store/persisted-state.fresh-agent.test.ts test/unit/client/store/storage-migration.fresh-agent.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/server/agent-layout-schema.test.ts test/unit/client/store/tabsSlice.merge.test.ts
+git commit -m "refactor: migrate fresh agent persistence surfaces"
+
+git add src/lib/pane-title.ts src/components/panes/PaneContainer.tsx src/components/TabContent.tsx src/lib/tab-registry-snapshot.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx
+git commit -m "refactor: add fresh agent bootstrap compatibility"
+```
+
+### Task 2: Build the shared fresh-agent transport and normalized read-model contract
+
+**Files:**
+- Create: `server/fresh-agent/runtime-adapter.ts`
+- Create: `server/fresh-agent/provider-registry.ts`
+- Create: `server/fresh-agent/runtime-manager.ts`
+- Create: `server/fresh-agent/router.ts`
+- Modify: `shared/ws-protocol.ts`
+- Modify: `shared/read-models.ts`
+- Modify: `server/ws-handler.ts`
+- Modify: `server/index.ts`
+- Test: `test/unit/server/fresh-agent/runtime-manager.test.ts`
+- Test: `test/unit/server/fresh-agent/router.test.ts`
+- Test: `test/unit/server/ws-handler-fresh-agent.test.ts`
+
+- [ ] **Step 1: Identify or write the failing tests**
+
+Write server tests that prove the final contract:
+
+```ts
+it('routes freshAgent.create through the adapter selected by sessionType', async () => {
+ expect(adapter.create).toHaveBeenCalledWith(expect.objectContaining({ sessionType: 'freshcodex' }))
+})
+
+it('returns 409 for stale thread revisions instead of mixing bodies from different revisions', async () => {
+ const response = await request(app).get('/api/fresh-agent/threads/codex/thread-1/turns/turn-9?revision=4')
+ expect(response.status).toBe(409)
+ expect(response.body.code).toBe('STALE_THREAD_REVISION')
+})
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `npm run test:vitest -- test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/router.test.ts test/unit/server/ws-handler-fresh-agent.test.ts`
+Expected: FAIL because the fresh-agent transport does not exist.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Implement:
+
+- provider registry lookup from `sessionType`
+- runtime manager operations: `create`, `resume`, `subscribe`, `send`, `interrupt`, `fork`, `answerQuestion`, `resolveApproval`
+- normalized snapshot/page/body read-model types
+- `freshAgent.*` WS messages and `/api/fresh-agent/threads/...` routes, using a dedicated fresh-agent namespace instead of overloading terminal envelopes
+- explicit error taxonomy for runtime unavailable, stale revision, unsupported capability, and lost session
+
+Keep terminal WebSocket behavior untouched.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npm run test:vitest -- test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/router.test.ts test/unit/server/ws-handler-fresh-agent.test.ts`
+Expected: PASS
+
+- [ ] **Step 5: Refactor and verify**
+
+Remove fake-generic `sdk.*` transport assumptions from shared code. Keep only Claude-specific implementation details under the Claude adapter boundary.
+
+Run: `npm run test:vitest -- test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/router.test.ts test/unit/server/ws-handler-fresh-agent.test.ts test/unit/visible-first/read-model-route-harness.test.ts`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add server/fresh-agent/runtime-adapter.ts server/fresh-agent/provider-registry.ts server/fresh-agent/runtime-manager.ts server/fresh-agent/router.ts shared/ws-protocol.ts shared/read-models.ts server/ws-handler.ts server/index.ts test/unit/server/fresh-agent/runtime-manager.test.ts test/unit/server/fresh-agent/router.test.ts test/unit/server/ws-handler-fresh-agent.test.ts
+git commit -m "feat: add fresh agent transport and read models"
+```
+
+### Task 3: Move Claude runtime behavior behind the Claude fresh-agent adapter
+
+**Files:**
+- Create: `test/fixtures/fresh-agent/claude/*`
+- Create: `server/fresh-agent/adapters/claude/adapter.ts`
+- Create: `server/fresh-agent/adapters/claude/normalize.ts`
+- Modify: `server/sdk-bridge.ts`
+- Modify: `server/sdk-bridge-types.ts`
+- Modify: `server/agent-timeline/ledger.ts`
+- Modify: `server/agent-timeline/history-source.ts`
+- Modify: `server/agent-timeline/service.ts`
+- Modify: `server/agent-timeline/router.ts`
+- Test: `test/unit/server/fresh-agent/claude-adapter.test.ts`
+- Test: `test/unit/server/fresh-agent/claude-normalize.test.ts`
+- Test: `test/unit/server/fresh-agent/claude-restore-contract.test.ts`
+
+- [ ] **Step 1: Identify or write the failing tests**
+
+Cover the Freshclaude behaviors that must survive:
+
+```ts
+it('merges ledger-backed restore state and live stream into one canonical snapshot', async () => {
+ expect(snapshot.turns.map((turn) => turn.source)).toEqual(['durable', 'live'])
+})
+
+it('preserves plugin defaults and mid-session model/permission changes through the claude adapter', async () => {
+ expect(adapter.updateSessionSettings).toHaveBeenCalledWith(expect.objectContaining({
+ defaultPlugins: ['/tmp/plugin'],
+ model: expect.any(String),
+ permissionMode: 'plan',
+ }))
+})
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `npm run test:vitest -- test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/fresh-agent/claude-normalize.test.ts test/unit/server/fresh-agent/claude-restore-contract.test.ts`
+Expected: FAIL because the adapter does not exist.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Wrap the existing Claude stack behind the adapter:
+
+- preserve durable/live restore semantics
+- preserve question/permission flows
+- preserve model and permission-mode updates
+- preserve plugin injection and token summaries
+- normalize Claude block messages into shared fresh-agent items
+
+Do not let Claude-specific types leak back into shared contracts.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npm run test:vitest -- test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/fresh-agent/claude-normalize.test.ts test/unit/server/fresh-agent/claude-restore-contract.test.ts`
+Expected: PASS
+
+- [ ] **Step 5: Refactor and verify**
+
+Move only real Claude implementation details under `server/fresh-agent/adapters/claude/*`; delete any top-level abstractions that only existed to make Claude look generic.
+
+Run: `npm run test:vitest -- test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/ws-sdk-session-history-cache.test.ts test/unit/server/sdk-bridge.test.ts`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add server/fresh-agent/adapters/claude/adapter.ts server/fresh-agent/adapters/claude/normalize.ts server/sdk-bridge.ts server/sdk-bridge-types.ts server/agent-timeline/ledger.ts server/agent-timeline/history-source.ts server/agent-timeline/service.ts server/agent-timeline/router.ts test/unit/server/fresh-agent/claude-adapter.test.ts test/unit/server/fresh-agent/claude-normalize.test.ts test/unit/server/fresh-agent/claude-restore-contract.test.ts
+git commit -m "refactor: move claude runtime behind fresh agent adapter"
+```
+
+### Task 4: Extend the existing Codex app-server stack for rich Freshcodex sessions
+
+**Files:**
+- Create: `test/fixtures/fresh-agent/codex/*`
+- Create: `server/fresh-agent/adapters/codex/adapter.ts`
+- Create: `server/fresh-agent/adapters/codex/normalize.ts`
+- Modify: `server/coding-cli/codex-app-server/protocol.ts`
+- Modify: `server/coding-cli/codex-app-server/client.ts`
+- Modify: `server/coding-cli/codex-app-server/runtime.ts`
+- Modify: `server/coding-cli/codex-app-server/launch-planner.ts`
+- Modify: `server/coding-cli/providers/codex.ts`
+- Test: `test/unit/server/coding-cli/codex-app-server/client.test.ts`
+- Test: `test/unit/server/coding-cli/codex-app-server/runtime.test.ts`
+- Test: `test/unit/server/fresh-agent/codex-adapter.test.ts`
+- Test: `test/unit/server/fresh-agent/codex-normalize.test.ts`
+
+- [ ] **Step 1: Identify or write the failing tests**
+
+Pin the rich Codex requirements without creating a duplicate client:
+
+```ts
+it('starts fresh rich codex threads with raw events enabled', async () => {
+ await runtime.planCreate({ cwd: '/repo', richClient: true })
+ expect(requestParams.experimentalRawEvents).toBe(true)
+})
+
+it('normalizes codex fork, review, worktree, and child-thread metadata into the shared snapshot', async () => {
+ expect(snapshot.capabilities.fork).toBe(true)
+ expect(snapshot.worktrees[0]?.path).toContain('.worktrees')
+ expect(snapshot.childThreads[0]?.origin).toBe('subagent')
+})
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts`
+Expected: FAIL because the existing app-server layer does not yet expose rich-session events and capabilities.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Extend the current Codex app-server stack instead of copying it:
+
+- add protocol/client support for the notifications and RPCs needed by rich Freshcodex
+- keep terminal-mode behavior intact
+- allow rich-pane creation and resume to request raw events and replay where required
+- normalize review, diff, fork lineage, worktree, token/context, and child-thread metadata into the shared model
+- keep `server/coding-cli/providers/codex.ts` focused on indexing and terminal concerns
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts`
+Expected: PASS
+
+- [ ] **Step 5: Refactor and verify**
+
+Make the adapter thin and keep the protocol source of truth in `server/coding-cli/codex-app-server/*`.
+
+Run: `npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts test/unit/server/coding-cli/codex-provider.test.ts`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add server/fresh-agent/adapters/codex/adapter.ts server/fresh-agent/adapters/codex/normalize.ts server/coding-cli/codex-app-server/protocol.ts server/coding-cli/codex-app-server/client.ts server/coding-cli/codex-app-server/runtime.ts server/coding-cli/codex-app-server/launch-planner.ts server/coding-cli/providers/codex.ts test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts test/unit/server/fresh-agent/codex-adapter.test.ts test/unit/server/fresh-agent/codex-normalize.test.ts
+git commit -m "feat: add codex rich runtime support"
+```
+
+### Task 5: Integrate fresh-agent sessions into metadata, session directory, remote snapshots, and resume flows
+
+**Files:**
+- Modify: `server/session-directory/projection.ts`
+- Modify: `server/session-directory/service.ts`
+- Modify: `server/session-directory/types.ts`
+- Modify: `server/coding-cli/session-indexer.ts`
+- Modify: `server/coding-cli/types.ts`
+- Modify: `server/sessions-router.ts`
+- Modify: `server/session-metadata-store.ts`
+- Modify: `server/agent-api/layout-store.ts`
+- Modify: `server/tabs-registry/types.ts`
+- Modify: `src/store/tabRegistryConstants.ts`
+- Modify: `src/store/tabRegistrySlice.ts`
+- Modify: `src/store/tabRegistrySync.ts`
+- Modify: `src/store/tabRegistryTypes.ts`
+- Modify: `src/lib/api.ts`
+- Modify: `src/lib/session-metadata.ts`
+- Modify: `src/lib/session-type-utils.ts`
+- Modify: `src/lib/tab-directory-preference.ts`
+- Modify: `src/lib/tab-registry-snapshot.ts`
+- Modify: `src/components/TabContent.tsx`
+- Modify: `src/components/TabsView.tsx`
+- Modify: `src/store/selectors/sidebarSelectors.ts`
+- Test: `test/unit/server/session-directory/fresh-agent-projection.test.ts`
+- Test: `test/unit/server/coding-cli/session-indexer.test.ts`
+- Test: `test/unit/server/session-metadata-store.test.ts`
+- Test: `test/integration/server/session-metadata-api.test.ts`
+- Test: `test/unit/server/agent-api/layout-store.fresh-agent.test.ts`
+- Test: `test/unit/client/components/TabsView.fresh-agent.test.tsx`
+- Test: `test/unit/client/lib/api.test.ts`
+- Test: `test/unit/server/tabs-registry/types.test.ts`
+- Test: `test/integration/server/tabs-registry-store.persistence.test.ts`
+- Test: `test/integration/server/session-directory-router.test.ts`
+
+- [ ] **Step 1: Identify or write the failing tests**
+
+Cover the resume and snapshot contract:
+
+```ts
+it('keeps derivedTitle when sessionType is updated to freshcodex', async () => {
+ await store.set('codex', 'sess-1', { derivedTitle: 'Sticky title' })
+ await request(app).post('/api/session-metadata').send({ provider: 'codex', sessionId: 'sess-1', sessionType: 'freshcodex' })
+ expect(await store.get('codex', 'sess-1')).toMatchObject({ derivedTitle: 'Sticky title', sessionType: 'freshcodex' })
+})
+
+it('serializes fresh-agent panes in remote layout snapshots and rehydrates them back into fresh-agent panes', () => {
+ expect(snapshot.panes[0]).toMatchObject({ kind: 'fresh-agent' })
+ expect(restored.content).toMatchObject({ kind: 'fresh-agent', sessionType: 'freshclaude' })
+})
+
+it('projects fresh sessionType and codex runtime metadata through the indexed session directory snapshot', async () => {
+ expect(projects[0]?.sessions[0]).toMatchObject({
+ sessionType: 'freshcodex',
+ isSubagent: true,
+ })
+})
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/unit/server/coding-cli/session-indexer.test.ts test/unit/server/session-metadata-store.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts`
+Expected: FAIL because fresh-agent metadata and snapshot flows are not fully projected yet.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Implement projection and metadata updates so that:
+
+- sidebar and history pages show `freshclaude`, `freshcodex`, and `kilroy` correctly
+- resume actions rebuild `kind: 'fresh-agent'` panes
+- `sessionType` updates do not clobber `derivedTitle`
+- coding-cli indexing merges `sessionType`, derived titles, and Codex task metadata into the same summaries the session directory and sidebar render
+- remote layout snapshots and registry records store `fresh-agent`, not `agent-chat`
+- session directory carries the metadata needed for fork, worktree, and subagent badges
+- tabs-registry persistence and HTTP/session-directory routes expose the same migrated shape the UI hydrates
+- tab-registry constants, sync reducers, and selectors continue to round-trip the migrated `sessionType` and `fresh-agent` pane shape with no legacy `agent-chat` assumptions left behind
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/unit/server/coding-cli/session-indexer.test.ts test/unit/server/session-metadata-store.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts`
+Expected: PASS
+
+- [ ] **Step 5: Refactor and verify**
+
+Keep `provider` and `sessionType` semantics separate everywhere. Do not smuggle UI identity back into filesystem or provider fields.
+
+Run: `npm run test:vitest -- test/unit/server/session-directory/fresh-agent-projection.test.ts test/unit/server/coding-cli/session-indexer.test.ts test/unit/server/session-metadata-store.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts test/unit/server/session-directory/service.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add server/session-directory/projection.ts server/session-directory/service.ts server/session-directory/types.ts server/coding-cli/session-indexer.ts server/coding-cli/types.ts server/sessions-router.ts server/session-metadata-store.ts server/agent-api/layout-store.ts server/tabs-registry/types.ts src/store/tabRegistryConstants.ts src/store/tabRegistrySlice.ts src/store/tabRegistrySync.ts src/store/tabRegistryTypes.ts src/lib/api.ts src/lib/session-metadata.ts src/lib/session-type-utils.ts src/lib/tab-directory-preference.ts src/lib/tab-registry-snapshot.ts src/components/TabContent.tsx src/components/TabsView.tsx src/store/selectors/sidebarSelectors.ts test/unit/server/session-directory/fresh-agent-projection.test.ts test/unit/server/coding-cli/session-indexer.test.ts test/unit/server/session-metadata-store.test.ts test/integration/server/session-metadata-api.test.ts test/unit/server/agent-api/layout-store.fresh-agent.test.ts test/unit/client/components/TabsView.fresh-agent.test.tsx test/unit/client/lib/api.test.ts test/unit/server/tabs-registry/types.test.ts test/integration/server/tabs-registry-store.persistence.test.ts test/integration/server/session-directory-router.test.ts
+git commit -m "feat: project fresh agent sessions through metadata and snapshots"
+```
+
+### Task 6: Replace client state and WebSocket handling with the fresh-agent store
+
+**Files:**
+- Create: `src/lib/fresh-agent-capabilities.ts`
+- Create: `src/lib/fresh-agent-ws.ts`
+- Create: `src/store/freshAgentTypes.ts`
+- Create: `src/store/freshAgentSlice.ts`
+- Create: `src/store/freshAgentThunks.ts`
+- Modify: `src/store/store.ts`
+- Modify: `src/store/persistControl.ts`
+- Modify: `src/store/tabsSlice.ts`
+- Modify: `src/lib/ws-client.ts`
+- Modify: `src/lib/session-utils.ts`
+- Modify: `src/lib/pane-activity.ts`
+- Modify: `src/components/TabSwitcher.tsx`
+- Test: `test/unit/client/store/freshAgentSlice.test.ts`
+- Test: `test/unit/client/store/freshAgentThunks.test.ts`
+- Test: `test/unit/client/lib/fresh-agent-ws.test.ts`
+- Test: `test/unit/client/store/persistControl.fresh-agent.test.ts`
+- Test: `test/unit/server/ws-handler-sdk.test.ts`
+
+- [ ] **Step 1: Identify or write the failing tests**
+
+Write client tests for the final state model:
+
+```ts
+it('stores threads by locator and revision without duplicating live and durable items', () => {
+ expect(state.threads['claude:thread-1'].turnOrder).toEqual(['turn-1', 'turn-2'])
+})
+
+it('persists sessionType and provider separately for resume identity', () => {
+ expect(update.tabUpdates?.sessionMetadataByKey?.['codex:sess-1']).toMatchObject({ sessionType: 'freshcodex' })
+})
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts`
+Expected: FAIL because the fresh-agent state layer does not exist.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Implement the normalized client layer:
+
+- thread state keyed by runtime locator
+- revision-safe snapshot, page, and body hydration
+- WS handling for `freshAgent.*`
+- pending approvals, questions, and action errors in shared state
+- capability helpers that normalize provider-backed actions into one client-facing surface for the shared shell
+- resume identity helpers that work for Claude and Codex without Claude-only assumptions
+- pane activity and tab switcher wiring that no longer read from `agentChat`
+
+Do not persist transient streaming or pending-action state.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts`
+Expected: PASS
+
+- [ ] **Step 5: Refactor and verify**
+
+Convert shared selectors and helpers to consume `freshAgent` state, not `agentChat`.
+
+Run: `npm run test:vitest -- test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts test/unit/client/components/App.ws-bootstrap.test.tsx test/unit/server/ws-handler-sdk.test.ts`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/lib/fresh-agent-ws.ts src/store/freshAgentTypes.ts src/store/freshAgentSlice.ts src/store/freshAgentThunks.ts src/store/store.ts src/store/persistControl.ts src/store/tabsSlice.ts src/lib/ws-client.ts src/lib/session-utils.ts src/lib/pane-activity.ts src/components/TabSwitcher.tsx test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/lib/fresh-agent-ws.test.ts test/unit/client/store/persistControl.fresh-agent.test.ts test/unit/server/ws-handler-sdk.test.ts
+git commit -m "feat: add fresh agent client state"
+```
+
+### Task 7: Ship the shared fresh-agent UI shell and preserve current Freshclaude behavior
+
+**Files:**
+- Create: `src/components/fresh-agent/FreshAgentView.tsx`
+- Create: `src/components/fresh-agent/FreshAgentTranscript.tsx`
+- Create: `src/components/fresh-agent/FreshAgentComposer.tsx`
+- Create: `src/components/fresh-agent/FreshAgentSidebar.tsx`
+- Create: `src/components/fresh-agent/FreshAgentApprovalBanner.tsx`
+- Create: `src/components/fresh-agent/FreshAgentQuestionBanner.tsx`
+- Create: `src/components/fresh-agent/FreshAgentDiffPanel.tsx`
+- Modify: `src/components/panes/PaneContainer.tsx`
+- Modify: `src/components/panes/PanePicker.tsx`
+- Modify: `src/components/Sidebar.tsx`
+- Modify: `src/components/HistoryView.tsx`
+- Modify: `src/components/context-menu/ContextMenuProvider.tsx`
+- Modify: `src/components/context-menu/context-menu-constants.ts`
+- Modify: `src/components/context-menu/context-menu-types.ts`
+- Modify: `src/components/context-menu/context-menu-utils.ts`
+- Modify: `src/components/context-menu/menu-defs.ts`
+- Modify: `src/components/icons/PaneIcon.tsx`
+- Modify: `src/components/TabBar.tsx`
+- Modify: `src/components/MobileTabStrip.tsx`
+- Modify: `src/components/SettingsView.tsx`
+- Modify: `src/components/settings/WorkspaceSettings.tsx`
+- Modify: `src/lib/derivePaneTitle.ts`
+- Modify: `src/lib/pane-title.ts`
+- Modify: `src/lib/pane-activity.ts`
+- Modify: `src/components/session/MessageBubble.tsx`
+- Modify: `src/components/session/ToolCallBlock.tsx`
+- Modify: `src/components/agent-chat/DiffView.tsx`
+- Modify: `src/lib/input-history-store.ts`
+- Modify: `docs/index.html`
+- Test: `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx`
+- Test: `test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx`
+- Test: `test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx`
+- Test: `test/unit/client/components/panes/PaneContainer.test.tsx`
+- Test: `test/unit/client/components/SettingsView.fresh-agent.test.tsx`
+- Test: `test/unit/client/components/ContextMenuProvider.test.tsx`
+- Test: `test/unit/client/components/context-menu/menu-defs.test.ts`
+
+- [ ] **Step 1: Identify or write the failing tests**
+
+Pin the user-visible shell:
+
+```tsx
+it('renders the same shell for freshclaude and freshcodex while honoring capability differences', () => {
+ render()
+ expect(screen.getByRole('button', { name: /fork/i })).toBeVisible()
+ render()
+ expect(screen.queryByRole('button', { name: /fork/i })).toBeNull()
+})
+
+it('preserves existing freshclaude settings, plugin controls, and question banners after migration', () => {
+ expect(screen.getByRole('button', { name: /send/i })).toBeEnabled()
+ expect(screen.getByRole('alert')).toHaveTextContent('Question')
+})
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/SettingsView.fresh-agent.test.tsx test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts`
+Expected: FAIL because the shared UI shell does not exist.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Build the shared shell with:
+
+- virtualized transcript backed by normalized turns and items
+- approval and question banners reused across providers
+- shared composer supporting send, interrupt, fork, and capability-backed actions
+- mobile drawers or sheets for secondary panes
+- promotion of reusable primitives instead of duplication: adapt `src/components/session/MessageBubble.tsx`, `src/components/session/ToolCallBlock.tsx`, and `src/components/agent-chat/DiffView.tsx` into provider-agnostic building blocks wherever possible
+- preserved Freshclaude features: plugin defaults, settings popover, input history, restore hydration, session-lost recovery, timecodes, show thinking and tools toggles
+- fresh-agent-aware context menus, pane badges, and resume-command affordances with no stale `agent-chat` target assumptions
+
+Switch pane picker, pane container, sidebar, history, and context menus to `kind: 'fresh-agent'`.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/SettingsView.fresh-agent.test.tsx test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts`
+Expected: PASS
+
+- [ ] **Step 5: Refactor and verify**
+
+Fold or delete old `src/components/agent-chat/*` pieces only when their behavior has been moved into `src/components/fresh-agent/*` or shared primitives and the corresponding tests have been ported without coverage loss.
+
+Run: `npm run test:vitest -- test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/Sidebar.render-stability.test.tsx test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/components/fresh-agent src/components/panes/PaneContainer.tsx src/components/panes/PanePicker.tsx src/components/Sidebar.tsx src/components/HistoryView.tsx src/components/context-menu/ContextMenuProvider.tsx src/components/context-menu/context-menu-constants.ts src/components/context-menu/context-menu-types.ts src/components/context-menu/context-menu-utils.ts src/components/context-menu/menu-defs.ts src/components/icons/PaneIcon.tsx src/components/TabBar.tsx src/components/MobileTabStrip.tsx src/components/SettingsView.tsx src/components/settings/WorkspaceSettings.tsx src/lib/derivePaneTitle.ts src/lib/pane-title.ts src/lib/pane-activity.ts docs/index.html test/unit/client/components/fresh-agent/FreshAgentView.test.tsx test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/SettingsView.fresh-agent.test.tsx test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts
+git commit -m "feat: ship shared fresh agent pane shell"
+```
+
+### Task 8: Port remaining regression coverage, remove only provably dead code, and run the full verification gate
+
+**Files:**
+- Modify/Delete: `src/store/agentChatSlice.ts`
+- Modify/Delete: `src/store/agentChatThunks.ts`
+- Modify/Delete: `src/store/agentChatTypes.ts`
+- Modify/Delete: `src/components/agent-chat/*`
+- Modify/Delete: `src/lib/sdk-message-handler.ts`
+- Modify: `server/mcp/freshell-tool.ts`
+- Modify/Rename: `test/e2e/agent-chat-*.test.tsx`
+- Modify/Rename: `test/e2e/pane-activity-indicator-flow.test.tsx`
+- Modify/Rename: `test/e2e/pane-header-runtime-meta-flow.test.tsx`
+- Modify/Rename: `test/e2e/sidebar-click-opens-pane.test.tsx`
+- Modify/Rename: `test/e2e/title-sync-flow.test.tsx`
+- Modify/Rename: `test/e2e/tool-coalesce.test.tsx`
+- Modify/Rename: `test/e2e-browser/specs/agent-chat.spec.ts`
+- Modify/Rename: `test/e2e-browser/specs/agent-chat-input-history.spec.ts`
+- Modify/Rename: `test/e2e-browser/specs/pane-activity-indicator.spec.ts`
+- Modify/Rename: `test/e2e-browser/specs/tab-management.spec.ts`
+- Modify: `test/e2e-browser/perf/audit-contract.ts`
+- Modify: `test/e2e-browser/perf/run-sample.ts`
+- Modify: `test/e2e-browser/perf/scenarios.ts`
+- Modify: `test/e2e-browser/perf/seed-browser-storage.ts`
+- Modify/Rename: `test/unit/client/components/agent-chat/*`
+- Modify: `test/unit/client/components/HistoryView.mobile.test.tsx`
+- Modify: `test/unit/client/components/ContextMenuProvider.test.tsx`
+- Modify/Rename: `test/unit/client/components/SettingsView.agent-chat.test.tsx`
+- Modify/Rename: `test/unit/client/components/context-menu/agent-chat-actions.test.ts`
+- Modify: `test/unit/client/components/context-menu/menu-defs.test.ts`
+- Modify: `test/unit/client/store/crossTabSync.test.ts`
+- Modify: `test/unit/client/lib/sdk-message-handler.session-lost.test.ts`
+- Modify: `test/unit/client/ws-client-sdk.test.ts`
+- Modify/Rename: `test/unit/server/ws-handler-sdk.test.ts`
+- Modify: `test/server/ws-sidebar-snapshot-refresh.test.ts`
+- Create: `test/e2e-browser/specs/fresh-agent.spec.ts`
+- Create: `test/e2e-browser/specs/fresh-agent-mobile.spec.ts`
+- Modify: existing Playwright helpers only if needed
+- Test: all targeted unit, integration, and e2e suites below
+
+Rename or consolidate the old browser specs into `fresh-agent.spec.ts` and `fresh-agent-mobile.spec.ts` unless a surviving split is clearly better, in which case keep the replacement names on a `fresh-agent-*` pattern instead of leaving `agent-chat` names behind.
+
+- [ ] **Step 1: Identify or write the failing tests**
+
+Add the browser-level proof of the requested outcome:
+
+```ts
+test('creates and resumes freshcodex with fork lineage and worktree metadata intact', async ({ page }) => {
+ await expect(page.getByRole('button', { name: /freshcodex/i })).toBeVisible()
+ await expect(page.getByText(/worktree/i)).toBeVisible()
+})
+
+test('freshclaude still restores durable history and surfaces approvals and questions', async ({ page }) => {
+ await expect(page.getByRole('alert')).toBeVisible()
+})
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent.spec.ts test/e2e-browser/specs/fresh-agent-mobile.spec.ts`
+Expected: FAIL because the browser flows are not fully wired yet.
+
+- [ ] **Step 3: Write minimal implementation**
+
+Port or rename every existing browser spec, Vitest e2e flow, visible-first perf fixture, context-menu test, and MCP/tooling string that still encodes `agent-chat` or `sdk.*`; keep or adapt the coverage until the replacement tests prove the same behaviors in the fresh-agent world. Move `src/lib/sdk-message-handler.ts` session-lost, orphan-create, and reconnect handling into `src/lib/fresh-agent-ws.ts` and the fresh-agent thunks before deleting it. Delete obsolete client glue only after the replacement coverage and browser flows pass.
+
+- [ ] **Step 4: Run tests to verify targeted suites pass**
+
+Run: `npm run test:vitest -- test/unit/server/fresh-agent test/unit/client/components/fresh-agent test/unit/client/components/HistoryView.mobile.test.tsx test/unit/client/store/freshAgentSlice.test.ts test/unit/client/store/freshAgentThunks.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/lib/sdk-message-handler.session-lost.test.ts test/unit/client/ws-client-sdk.test.ts test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/context-menu/menu-defs.test.ts test/unit/server/ws-handler-sdk.test.ts test/server/ws-sidebar-snapshot-refresh.test.ts test/unit/lib/visible-first-audit-contract.test.ts test/unit/lib/visible-first-audit-run-sample.test.ts test/unit/lib/visible-first-audit-scenarios.test.ts test/unit/lib/visible-first-audit-seed-browser-storage.test.ts test/e2e/agent-chat-restore-flow.test.tsx test/e2e/agent-chat-resume-history-flow.test.tsx test/e2e/agent-chat-context-menu-flow.test.tsx test/e2e/agent-chat-input-history-flow.test.tsx test/e2e/pane-activity-indicator-flow.test.tsx test/e2e/pane-header-runtime-meta-flow.test.tsx test/e2e/title-sync-flow.test.tsx`
+Run: `npm run test:vitest -- test/integration/server/session-metadata-api.test.ts test/e2e`
+Run: `npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent.spec.ts test/e2e-browser/specs/fresh-agent-mobile.spec.ts`
+Expected: PASS
+
+- [ ] **Step 5: Refactor and verify**
+
+Run the repo verification expected before handing off:
+
+Run: `npm run lint`
+Run: `npm run test:status`
+Run: `FRESHELL_TEST_SUMMARY="fresh agent platform" npm test`
+Run: `npm run check`
+Expected: all PASS
+
+If a valid check fails, continue fixing the code. Do not weaken or delete good tests.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add server/mcp/freshell-tool.ts src/store/agentChatSlice.ts src/store/agentChatThunks.ts src/store/agentChatTypes.ts src/components/agent-chat src/lib/sdk-message-handler.ts test/e2e/agent-chat-restore-flow.test.tsx test/e2e/agent-chat-resume-history-flow.test.tsx test/e2e/agent-chat-context-menu-flow.test.tsx test/e2e/agent-chat-input-history-flow.test.tsx test/e2e/pane-activity-indicator-flow.test.tsx test/e2e/pane-header-runtime-meta-flow.test.tsx test/e2e/sidebar-click-opens-pane.test.tsx test/e2e/title-sync-flow.test.tsx test/e2e/tool-coalesce.test.tsx test/e2e-browser/specs/agent-chat.spec.ts test/e2e-browser/specs/agent-chat-input-history.spec.ts test/e2e-browser/specs/pane-activity-indicator.spec.ts test/e2e-browser/specs/tab-management.spec.ts test/e2e-browser/specs/fresh-agent.spec.ts test/e2e-browser/specs/fresh-agent-mobile.spec.ts test/e2e-browser/perf/audit-contract.ts test/e2e-browser/perf/run-sample.ts test/e2e-browser/perf/scenarios.ts test/e2e-browser/perf/seed-browser-storage.ts test/unit/client/components/agent-chat test/unit/client/components/HistoryView.mobile.test.tsx test/unit/client/components/ContextMenuProvider.test.tsx test/unit/client/components/SettingsView.agent-chat.test.tsx test/unit/client/components/context-menu/agent-chat-actions.test.ts test/unit/client/components/context-menu/menu-defs.test.ts test/unit/client/store/crossTabSync.test.ts test/unit/client/lib/sdk-message-handler.session-lost.test.ts test/unit/client/ws-client-sdk.test.ts test/unit/server/ws-handler-sdk.test.ts test/server/ws-sidebar-snapshot-refresh.test.ts
+git commit -m "refactor: remove legacy agent chat architecture"
+```
+
+## OpenCode design constraint for later work
+
+Do not ship `freshopencode` here. Do ensure the final architecture already supports it:
+
+- disabled registry entry may exist now
+- adapter interface already supports explicit permission and command flows plus server-driven event streams
+- normalized model already has diff, review, worktree, child-thread, and artifact concepts
+- no client code assumes Claude block messages or Codex review objects are universal
+
+## Implementation notes for the executing agent
+
+- Work only in `/home/user/code/freshell/.worktrees/fresh-agent-platform`.
+- Reuse the existing Codex app-server runtime, client, and planner instead of creating a parallel stack.
+- Preserve current Freshclaude behavior while renaming the architecture underneath it.
+- Preserve `kilroy` as a hidden Claude-backed fresh-agent type.
+- Preserve saved tabs and settings; do not “solve” migration by clearing local storage.
+- Update remote snapshot and tab registry code in the same migration as local pane persistence.
+- Reuse and promote existing shared renderer primitives before creating new ones.
+- Port existing regression coverage forward; do not delete a test unless its behavior is demonstrably covered elsewhere.
+- Use Playwright for browser and mobile e2e, not vitest-only pseudo-e2e files.
+- Broad runs go through the test coordinator. Check `npm run test:status` before them.
diff --git a/docs/plans/2026-04-30-freshcodex-contract-foundation.md b/docs/plans/2026-04-30-freshcodex-contract-foundation.md
new file mode 100644
index 000000000..1647fdfaa
--- /dev/null
+++ b/docs/plans/2026-04-30-freshcodex-contract-foundation.md
@@ -0,0 +1,5564 @@
+# Freshcodex Contract Foundation Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Finish Freshcodex as a first-class rich client on the shared fresh-agent foundation, with strict shared contracts, typed Codex normalization, interactive Codex actions, scalable transcript/diff UX, and full regression coverage.
+
+**Architecture:** Keep `fresh-agent` as the shared product domain, but make the normalized contract real instead of implicit: every snapshot, turn page, turn body, transcript item, action response, and provider extension crosses server/client boundaries through shared Zod schemas. Freshcodex uses the official Codex app-server as its source of truth for thread lifecycle, turn lifecycle, fork, interrupt, approvals, questions, review/diff items, token usage, worktrees, and child threads; the client consumes only typed fresh-agent data and never reaches into Claude session state. Freshclaude remains supported through the existing adapter, but this plan optimizes implementation order and tests for Freshcodex correctness.
+
+**Tech Stack:** TypeScript, Zod, React 18, Redux Toolkit, Express, WebSocket JSON-RPC, Codex app-server, existing read-model scheduler, react-window, Vitest, Testing Library, Playwright browser e2e.
+
+---
+
+## Current State
+
+The implementation workspace is `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation`. At this planning checkpoint the branch already contains `origin/main`, but Task 1 must re-check the exact ahead/behind counts before implementation because main can move between planning and execution. If `origin/main` has moved, merge it into this worktree branch before contract work. If `origin/main` is already contained, run the merge-sensitive verification and skip the no-op merge commit. The branch already contains:
+
+- `kind: 'fresh-agent'` pane content with `sessionType` separate from runtime `provider`.
+- Claude and Codex runtime adapters under `server/fresh-agent/adapters/*`.
+- Fresh-agent REST routes and WebSocket messages.
+- A shared FreshAgent shell that can render Freshclaude and Freshcodex snapshots.
+- Basic Codex rich snapshot metadata for diffs, worktrees, child threads, review, fork lineage, and token totals.
+- Regression coverage for the initial cutover and the last nonconvergence closure pass.
+
+Two existing implementation seams must be corrected before the new feature work can be considered a durable shared foundation:
+
+- `server/fresh-agent/provider-registry.ts` currently stores one registration per runtime provider. Because both `freshclaude` and hidden `kilroy` use `provider: 'claude'`, the last registration can overwrite the runtime-provider lookup. Split session identity registration from runtime adapter registration so many `sessionType` values can intentionally share one provider adapter without changing lookup semantics.
+- `src/store/freshAgentSlice.ts`, `src/store/freshAgentTypes.ts`, and `src/store/freshAgentThunks.ts` currently re-export or alias legacy agent-chat state/thunks. `src/lib/pane-activity.ts` also reads fresh-agent pane activity from `agentChatSessions`. That was acceptable as a temporary bridge, but it is not a shared fresh-agent foundation. Fresh-agent state, thunks, activity projection, and action names must be based on the shared fresh-agent contract; legacy agent-chat may keep its own slice until Freshclaude is fully ported.
+- Freshcodex defaults and restored-pane runtime settings currently flow through helper files that still assume Claude-shaped agent-chat values. `src/lib/session-type-utils.ts`, `src/store/tabsSlice.ts`, `src/lib/tab-registry-snapshot.ts`, `src/store/paneTreeValidation.ts`, `src/components/TabsView.tsx`, and pane persistence tests must be updated so Codex-shaped approval policy, sandbox, and effort values survive picker creation, session resume, browser persistence, remote tab snapshots, and hydration.
+
+Recent mainline fixes touched exactly the areas this project depends on: agent-chat auto-title, mobile keyboard/touch behavior, stale pane hydration, two-browser reconnect recovery, and Codex app-server startup/init hardening. Those changes are present at this planning checkpoint, but Task 1 must preserve them and repeat the main-sync gate if `origin/main` moves again before implementation so the implementation does not reintroduce known fixed bugs.
+
+## Local Codex Schema Audit
+
+These facts were verified in this worktree against the locally installed CLI, not from memory:
+
+```bash
+codex --version
+# codex-cli 0.128.0
+rm -rf /tmp/freshell-codex-schema-0.128.0
+mkdir -p /tmp/freshell-codex-schema-0.128.0/ts /tmp/freshell-codex-schema-0.128.0/json
+codex app-server generate-ts --out /tmp/freshell-codex-schema-0.128.0/ts
+codex app-server generate-json-schema --out /tmp/freshell-codex-schema-0.128.0/json
+```
+
+The generated sources that matter most are:
+
+- `/tmp/freshell-codex-schema-0.128.0/json/JSONRPCRequest.json`, `JSONRPCResponse.json`, `JSONRPCError.json`, `JSONRPCNotification.json`, and `JSONRPCMessage.json`.
+- `/tmp/freshell-codex-schema-0.128.0/ts/RequestId.ts`, `ClientRequest.ts`, `ClientNotification.ts`, `ServerRequest.ts`, `ServerNotification.ts`, `InitializeParams.ts`, `InitializeResponse.ts`, and `InitializeCapabilities.ts`.
+- `/tmp/freshell-codex-schema-0.128.0/ts/v2/ThreadStartParams.ts`, `ThreadStartResponse.ts`, `ThreadResumeParams.ts`, `ThreadReadParams.ts`, `ThreadReadResponse.ts`, `ThreadTurnsListParams.ts`, `ThreadTurnsListResponse.ts`, `ThreadForkParams.ts`, `ThreadForkResponse.ts`, `TurnStartParams.ts`, `TurnStartResponse.ts`, `TurnInterruptParams.ts`, and `TurnInterruptResponse.ts`.
+- `/tmp/freshell-codex-schema-0.128.0/ts/v2/Thread.ts`, `Turn.ts`, `ThreadItem.ts`, `UserInput.ts`, `ThreadStatus.ts`, `TurnStatus.ts`, the v2 approval/request param and response files, and `DynamicToolCallResponse.ts`.
+- Legacy root server-request files are still generated in this local schema and remain part of the Freshcodex unblock contract: `/tmp/freshell-codex-schema-0.128.0/ts/ApplyPatchApprovalParams.ts`, `ExecCommandApprovalParams.ts`, `ApplyPatchApprovalResponse.ts`, `ExecCommandApprovalResponse.ts`, and `ReviewDecision.ts`.
+- Runtime-setting and identity leaf types are part of the contract, not incidental dependencies. The plan must also preserve and audit `ReasoningEffort.ts`, `v2/AskForApproval.ts`, `v2/SandboxMode.ts`, `v2/SandboxPolicy.ts`, `v2/NetworkAccess.ts`, `v2/UserInput.ts`, `v2/ThreadStatus.ts`, `v2/TurnStatus.ts`, `v2/ThreadActiveFlag.ts`, `v2/SessionSource.ts`, and `SubAgentSource.ts` because those files define the values Freshcodex sends to Codex and the source/subagent shapes Freshcodex projects into history and child-thread UI.
+
+Schema-grounded protocol facts to preserve:
+
+- Codex app-server supports `--listen stdio://`, `unix://`, `ws://IP:PORT`, and `off`; `stdio://` is the default. Freshcodex rich runtime should use stdio; keep the existing websocket runtime only for raw Codex terminal `--remote` attach.
+- JSON-RPC envelopes omit `"jsonrpc": "2.0"`. Request ids are strings or integer numbers on the wire. Generated TypeScript exposes this as `string | number`, but generated JSON Schema constrains numeric ids to `integer`; server-initiated request ids must round-trip unchanged.
+- JSON-RPC requests are `{ id, method, params?, trace? }`; responses are `{ id, result }`; errors are `{ id, error: { code, message, data? } }`; notifications are `{ method, params? }`.
+- Initialization is `initialize` with `{ clientInfo, capabilities }`, followed by exactly one client notification `{ method: 'initialized' }` after a valid response. `InitializeCapabilities` has `experimentalApi` and optional `optOutNotificationMethods`. `InitializeResponse` has `userAgent`, `codexHome`, `platformFamily`, and `platformOs`; there is no `protocolVersion` field in this local schema. Because this plan checks in and classifies the normal generated schema, Freshcodex must initialize with `experimentalApi: false`. If a future plan opts into experimental APIs, it must regenerate the checked-in snapshot with `--experimental`, classify every added method/field/notification, and update fixtures before sending `experimentalApi: true`.
+- Generated client methods relevant enough to classify include `thread/start`, `thread/resume`, `thread/fork`, `thread/list`, `thread/loaded/list`, `thread/read`, `thread/turns/list`, `thread/compact/start`, `thread/rollback`, `turn/start`, `turn/steer`, `turn/interrupt`, `review/start`, `model/list`, and `modelProvider/capabilities/read`; Task 4 defines which of these Freshcodex implements now versus disables with a clear unsupported path. There is no `thread/turn/read` method.
+- `thread/start` accepts runtime settings such as `model`, `modelProvider`, `serviceTier`, `cwd`, `approvalPolicy`, `approvalsReviewer`, `sandbox`, `config`, instructions/personality, `ephemeral`, and `sessionStartSource`; it does not accept `richClient`, `experimentalRawEvents`, or `persistExtendedHistory`.
+- `thread/resume` accepts `threadId`, the same major runtime overrides, and `excludeTurns?: boolean`; it does not accept `persistExtendedHistory`. Freshcodex restored-pane attach and any adapter path that resumes a thread before calling `thread/turns/list` must send `excludeTurns: true` so app-server does not populate the resumed `thread.turns` with a full transcript as part of normal snapshot/attach work.
+- `thread/read` params are exactly `{ threadId: string, includeTurns: boolean }`. `includeTurns` is required in the generated TypeScript. The response is `{ thread }`.
+- `thread/turns/list` params are `{ threadId, cursor?, limit?, sortDirection? }`. It does not accept `revision` or `includeBodies`. The response is `{ data, nextCursor, backwardsCursor }`.
+- `turn/start` params are `{ threadId, input, cwd?, approvalPolicy?, approvalsReviewer?, sandboxPolicy?, model?, serviceTier?, effort?, summary?, personality?, outputSchema? }`. Input is an array of generated `UserInput`: text is `{ type: 'text', text, text_elements: [] }`, remote/data images are `{ type: 'image', url }`, local images are `{ type: 'localImage', path }`, skills are `{ type: 'skill', name, path }`, and mentions are `{ type: 'mention', name, path }`.
+- Implemented client-request parameter schemas are outbound safety gates and must be strict generated-shape schemas, not permissive `.passthrough()` bags. They should reject stale Freshell-only fields such as `persistExtendedHistory`, `richClient`, `experimentalRawEvents`, `revision`, `includeBodies`, provider-only runtime values, or misspelled generated fields before a request reaches Codex app-server. `.passthrough()` is acceptable for known result/entity schemas only where forward-compatible extra app-server fields are intentionally preserved or ignored after generated-required fields are enforced.
+- Codex reasoning effort values are generated as `"none" | "minimal" | "low" | "medium" | "high" | "xhigh"`. Freshcodex must not reuse Claude's legacy `"max"` effort value; if `"max"` is present in migrated settings, show a controlled unsupported Freshcodex settings error or map only through an explicit user-visible migration rule added in this plan.
+- Codex approval policy values are generated as `"untrusted" | "on-failure" | "on-request" | "never" | { granular: ... }`. Freshcodex must not send Claude permission modes such as `"bypassPermissions"` as Codex `approvalPolicy`. The generated JSON Schema defaults `AskForApproval.granular.skill_approval` and `AskForApproval.granular.request_permissions` to `false` even though the generated TypeScript type prints them as required; raw Codex protocol schemas must accept those omitted fields and normalize them to `false`.
+- Codex sandbox settings are split across APIs: `thread/start`, `thread/resume`, and `thread/fork` accept string `sandbox?: "read-only" | "workspace-write" | "danger-full-access"`, while `turn/start` accepts structured `sandboxPolicy`. `SandboxPolicy.externalSandbox.networkAccess` uses generated `NetworkAccess` values `"restricted" | "enabled"`, not a free-form payload. Generated JSON Schema defaults omitted sandbox-policy fields (`readOnly.networkAccess`, `externalSandbox.networkAccess`, and `workspaceWrite`'s `writableRoots`, `networkAccess`, `excludeTmpdirEnvVar`, and `excludeSlashTmp`); raw protocol schemas must accept those omitted fields and normalize them before lifecycle responses cross into fresh-agent contracts. Do not send the thread-level `sandbox` string to `turn/start`.
+- `turn/start` returns `{ turn }`. `turn/interrupt` requires `{ threadId, turnId }` and returns `{}`.
+- `thread/fork` accepts `threadId`, runtime overrides, `ephemeral?`, and `excludeTurns?`; generated TypeScript returns `{ thread, model, modelProvider, serviceTier, cwd, instructionSources, approvalPolicy, approvalsReviewer, sandbox, reasoningEffort }`, while generated JSON Schema requires only `thread`, `model`, `modelProvider`, `cwd`, `approvalPolicy`, `approvalsReviewer`, and `sandbox`. Raw protocol schemas must accept omitted `serviceTier`, `instructionSources`, and `reasoningEffort` and normalize them to `null`, `[]`, and `null`.
+- `review/start` accepts `{ threadId, target, delivery? }` where target is `uncommittedChanges`, `baseBranch`, `commit`, or `custom`, and delivery is `inline` or `detached`. It returns `{ turn, reviewThreadId }`; the review thread id must be preserved in fresh-agent action results and extensions so inline and future detached review flows can be tracked correctly.
+- `thread/loaded/list` returns `{ data: string[], nextCursor? }`, not thread summaries. Raw protocol schemas must normalize omitted `nextCursor` to `null`. Any fresh-agent loaded-thread UI or API must expose loaded ids directly or hydrate them through `thread/read`/`thread/list`; it must not pretend this app-server method returns rich session rows.
+- `thread/list` is paginated. Params include `cursor`, `limit`, `sortKey`, `sortDirection`, `modelProviders`, `sourceKinds`, `archived`, `cwd`, `useStateDbOnly`, and `searchTerm`; the TypeScript response is `{ data: Thread[], nextCursor, backwardsCursor }`, while generated JSON Schema requires only `data` and marks cursor fields optional/null. Raw protocol schemas must accept missing cursors and normalize them to `null` before producing fresh-agent page contracts. Freshcodex history/session APIs must preserve both normalized cursors instead of collapsing the response to an array.
+- `model/list` is paginated. Params are `{ cursor?, limit?, includeHidden? }`; the TypeScript response is `{ data: Model[], nextCursor }`, while generated JSON Schema requires only `data` and marks `nextCursor` optional/null. Raw protocol schemas must accept missing `nextCursor` and normalize it to `null` before producing fresh-agent page contracts. A convenience settings helper may accumulate pages for a dropdown, but the adapter/runtime/router/API contract must remain page-shaped so hidden or future large model lists are not silently truncated.
+- Generated `ThreadSourceKind` values are `cli`, `vscode`, `exec`, `appServer`, `subAgent`, `subAgentReview`, `subAgentCompact`, `subAgentThreadSpawn`, `subAgentOther`, and `unknown`. Freshcodex rich history must explicitly request generated user-visible/resumable Codex sources (`cli`, `vscode`, `exec`, and `appServer`) plus every generated `subAgent*` kind, including `subAgentCompact`, so CLI-created threads, app-server-created threads, command/exec sessions, and child-agent sessions are not hidden by the explicit source filter. The `vscode` source is required because local runtime probes against `codex app-server --listen stdio://` on `codex-cli 0.128.0` returned newly created app-server threads with `source: "vscode"` even when the client was Freshell and `serviceName: "freshell"` was supplied. `unknown` must parse and preserve when returned, but it should not be in the default history filter unless the UI exposes an explicit "unknown source" option.
+- Generated `ThreadStartSource` values are only `"startup"` and `"clear"`. Do not use `sessionStartSource` as a Freshell/app-server source marker or send `"appServer"` there.
+- `Thread` has `id`, optional/null `forkedFromId`, `preview`, `ephemeral`, `modelProvider`, Unix-second timestamps, structured `status`, optional/null `path`, `cwd`, `cliVersion`, `source`, optional/null subagent metadata, optional/null `gitInfo`, optional/null `name`, and `turns`. `Turn` has `id`, `items`, `status`, optional/null `error`, optional/null Unix-second `startedAt`/`completedAt` values, and optional/null `durationMs`. Fresh-agent contract timestamps may stay ISO strings for UI consistency, but Codex raw protocol schemas and fixtures must parse numeric app-server timestamps and normalize omitted nullable fields explicitly.
+- Generated JSON Schema requires the core `Thread` metadata envelope even when turn bodies are omitted. At minimum, schema-valid wire fixtures must include `id`, `preview`, `ephemeral`, `modelProvider`, `createdAt`, `updatedAt`, structured `status`, `cwd`, `cliVersion`, `source`, and `turns`. Optional/null fields such as `forkedFromId`, `path`, `agentNickname`, `agentRole`, `gitInfo`, and `name` may be omitted on the JSON wire and must normalize to `null` before provider extensions or fresh-agent contracts depend on them. `turns` is a required array that may be empty; do not mark it optional in `CodexThreadSchema` just because `thread/read { includeTurns: false }` returns an empty list.
+- `ThreadStatus` is structured: `{ type: 'notLoaded' } | { type: 'idle' } | { type: 'systemError' } | { type: 'active', activeFlags: [...] }`; `activeFlags` is required on the active variant. `TurnStatus` is `"completed" | "interrupted" | "failed" | "inProgress"`. Generated TypeScript exposes `Turn.error`, `Turn.startedAt`, `Turn.completedAt`, and `Turn.durationMs` as nullable properties, but generated JSON Schema does not require them; raw protocol schemas must accept omitted values and normalize them to `null`.
+- `Thread.source` uses generated `SessionSource`, not `ThreadSourceKind`. `ThreadSourceKind` is only the filter type for `thread/list`. `SessionSource` values include flat sources such as `"cli"`, `"vscode"`, `"exec"`, and `"appServer"`, but subagent source metadata is represented as `{ subAgent: ... }` with generated `SubAgentSource` variants such as `"review"`, `"compact"`, `{ thread_spawn: ... }`, `"memory_consolidation"`, and `{ other: string }`. Freshcodex protocol schemas, fixtures, history projection, and child-thread metadata must parse and preserve the generated `SessionSource` shape instead of flattening thread metadata to `subAgentReview`/`subAgentCompact` strings.
+- Generated `ThreadItem` variants are exactly `userMessage`, `hookPrompt`, `agentMessage`, `plan`, `reasoning`, `commandExecution`, `fileChange`, `mcpToolCall`, `dynamicToolCall`, `collabAgentToolCall`, `webSearch`, `imageView`, `imageGeneration`, `enteredReviewMode`, `exitedReviewMode`, and `contextCompaction`.
+- `reasoning` items contain both `summary: string[]` and `content: string[]`. Fresh-agent reasoning items must preserve both arrays; a derived joined `text` may be added for convenience, but it must not replace the generated content list.
+- Generated item status leaf types use `"inProgress"` for active work, not `"running"`: `CommandExecutionStatus` and `PatchApplyStatus` are `"inProgress" | "completed" | "failed" | "declined"`, while `McpToolCallStatus`, `DynamicToolCallStatus`, and `CollabAgentToolCallStatus` are `"inProgress" | "completed" | "failed"`. Fresh-agent transcript contracts may keep the UI-facing `"running"` value, but Codex normalization must explicitly map every generated item-level `"inProgress"` to `"running"` and must test that mapping for command, file-change, MCP tool, dynamic tool, and collaboration items.
+- Generated file-change kinds are `{ type: "add" }`, `{ type: "delete" }`, and `{ type: "update", move_path: string | null }`. Fresh-agent file-change items must map `update` with `move_path: null` to `modify`, map `update` with a non-null `move_path` to `rename`, and preserve the generated `move_path` as `movePath` so diff/review UI can show moved or renamed files accurately.
+- `collabAgentToolCall` carries `senderThreadId`, `receiverThreadIds: string[]`, optional `model`, optional `reasoningEffort`, and `agentsStates` keyed by child thread id. Fresh-agent collaboration items must preserve the receiver id array and agent-state metadata under typed fields; do not collapse this to a singular `receiverThreadId` / `newThreadId`, because spawned or resumed child-agent calls can involve multiple receiver threads and the shared shell needs that metadata for child-thread UX.
+- `imageGeneration.status` is generated as an unconstrained string, not a fixed enum. Fresh-agent image-generation items must preserve the raw generated status string and may derive a separate UI bucket if useful; do not narrow the contract to only `pending` / `running` / `completed` / `failed`.
+- Several generated `ThreadItem` variants carry structured details beyond their display label: text `UserInput` parts include `text_elements`, `hookPrompt.fragments[]` include `hookRunId`, `agentMessage` includes `phase` and `memoryCitation`, `commandExecution` includes `source`, `processId`, and `commandActions`, MCP tool calls include `server`, `mcpAppResourceUri`, structured result metadata, and duration, `dynamicToolCall` includes `namespace`, `contentItems`, `success`, and duration, `webSearch` includes structured `action`, and `imageGeneration` includes raw `result` plus optional `savedPath`. Fresh-agent contracts and fixtures must preserve these generated fields under typed item fields rather than flattening them to display-only strings.
+- Generated MCP and dynamic tool-call `arguments` fields are `JsonValue`, not `Record`. Fresh-agent `tool.input` and `dynamic_tool.input` must preserve arbitrary JSON values, including arrays, strings, numbers, booleans, and null; object-shaped tool calls are common but not a wire-contract guarantee.
+- Generated `ServerRequest` variants are exactly `item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, `item/tool/requestUserInput`, `mcpServer/elicitation/request`, `item/permissions/requestApproval`, `item/tool/call`, `account/chatgptAuthTokens/refresh`, `applyPatchApproval`, and `execCommandApproval`. The v2 request variants use `params.threadId` for routing, while the legacy root `applyPatchApproval` and `execCommandApproval` variants use `params.conversationId`; both fields identify the Codex thread and must route to the matching Freshcodex locator.
+- Command approval responses use `{ decision: "accept" | "acceptForSession" | "decline" | "cancel" | amendment-object }`; file-change approval responses use `{ decision: "accept" | "acceptForSession" | "decline" | "cancel" }`; permission responses use `{ permissions, scope, strictAutoReview? }`; user-input responses use `{ answers }`; MCP elicitation responses use `{ action, content, _meta }`; dynamic-tool responses use `{ contentItems, success }`. Legacy `applyPatchApproval` and `execCommandApproval` responses use root `ReviewDecision` values through `{ decision }`, including `"approved"`, `"approved_for_session"`, `"denied"`, `"timed_out"`, `"abort"`, and the generated amendment-object variants; do not answer those legacy requests with v2 `"accept"` / `"decline"` decisions.
+- Generated JSON Schema, not generated TypeScript formatting, is the wire-requiredness source for those legacy root requests. `ApplyPatchApprovalParams` requires `conversationId`, `callId`, and `fileChanges`; optional `reason` and `grantRoot` must normalize to `null` when omitted. `ExecCommandApprovalParams` requires `conversationId`, `callId`, `command`, `cwd`, and `parsedCmd`; optional `approvalId` and `reason` must normalize to `null` when omitted.
+- `account/chatgptAuthTokens/refresh` expects real token fields in a successful result and its generated params do not include a thread locator (`threadId` or `conversationId`). Freshcodex must not fabricate an unsupported success payload for it. If Freshell cannot satisfy this request, respond with a JSON-RPC error envelope on the original server request id and surface a clear unsupported-auth-refresh runtime error to every subscribed Freshcodex pane for that rich runtime instance, since the request is not thread-addressable.
+- Generated `ServerNotification` method names are slash-delimited and must be copied exactly from `ServerNotification.ts`; examples include `thread/status/changed`, `thread/tokenUsage/updated`, `turn/diff/updated`, `turn/plan/updated`, `thread/compacted`, `item/agentMessage/delta`, `item/fileChange/patchUpdated`, `serverRequest/resolved`, `thread/realtime/error`, and `thread/realtime/closed`.
+- Any per-turn body API in Freshell must be an internal facade over `thread/turns/list` results or a server-side page/body cache until Codex exposes a direct turn-read request. Do not implement normal Freshcodex body hydration by repeatedly calling `thread/read { includeTurns: true }` over the full thread.
+- Freshcodex must not opt out of generated notification methods that affect visible state. In particular, do not include `thread/started`, turn lifecycle, item lifecycle, token usage, diff/review, status, compaction, or error notifications in `InitializeCapabilities.optOutNotificationMethods`; suppressing those events would make the live read model stale by construction.
+
+Generated method inventory the executor must keep aligned with the local schema:
+
+- Freshcodex client-request methods to implement or intentionally leave unsupported must be generated from `ClientRequest.ts` during Task 4, not copied by hand. The implementation-required Freshcodex subset is `initialize`, `thread/start`, `thread/resume`, `thread/fork`, `thread/list`, `thread/loaded/list`, `thread/read`, `thread/turns/list`, `turn/start`, `turn/interrupt`, `review/start`, `model/list`, and `modelProvider/capabilities/read`. The explicit unsupported/disabled subset for this plan is every other generated client method, including `thread/archive`, `thread/unsubscribe`, `thread/name/set`, `thread/metadata/update`, `thread/unarchive`, `thread/compact/start`, `thread/shellCommand`, `thread/approveGuardianDeniedAction`, `thread/rollback`, `thread/inject_items`, skills/plugin/marketplace/app/fs/config/account/device/feedback/fuzzy-file-search methods, standalone `command/exec*`, `turn/steer`, MCP direct-call methods, and Windows sandbox setup. Unsupported methods must have clear server/client capability labels if exposed by UI; do not silently proxy arbitrary generated methods through Freshcodex.
+- Server-request methods requiring pending UI state or explicit unblock responses: `item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, `item/tool/requestUserInput`, `mcpServer/elicitation/request`, `item/permissions/requestApproval`, `item/tool/call`, `account/chatgptAuthTokens/refresh`, `applyPatchApproval`, and `execCommandApproval`.
+- Visible thread-located notification methods that must invalidate or patch the matching Freshcodex read model: `error`, `thread/started`, `thread/status/changed`, `thread/archived`, `thread/unarchived`, `thread/closed`, `thread/name/updated`, `thread/goal/updated`, `thread/goal/cleared`, `thread/tokenUsage/updated`, `turn/started`, `hook/started`, `turn/completed`, `hook/completed`, `turn/diff/updated`, `turn/plan/updated`, `item/started`, `item/autoApprovalReview/started`, `item/autoApprovalReview/completed`, `item/completed`, `rawResponseItem/completed`, `item/agentMessage/delta`, `item/plan/delta`, `item/commandExecution/outputDelta`, `item/commandExecution/terminalInteraction`, `item/fileChange/outputDelta`, `item/fileChange/patchUpdated`, `serverRequest/resolved`, `item/mcpToolCall/progress`, `item/reasoning/summaryTextDelta`, `item/reasoning/summaryPartAdded`, `item/reasoning/textDelta`, `thread/compacted`, `model/rerouted`, `model/verification`, `guardianWarning`, `thread/realtime/started`, `thread/realtime/itemAdded`, `thread/realtime/transcript/delta`, `thread/realtime/transcript/done`, `thread/realtime/outputAudio/delta`, `thread/realtime/sdp`, `thread/realtime/error`, and `thread/realtime/closed`. Most of these use `params.threadId`, but `thread/started` uses `params.thread.id`, and `warning` is thread-located only when `params.threadId` is non-null. Notification routing must be generated-method-specific; never fall back to the subscribed session id when a notification has no thread locator.
+- Runtime-global or connection-scoped notification methods must be surfaced as runtime/capability warnings or explicitly ignored by classification, not used to invalidate an arbitrary Freshcodex thread: `command/exec/outputDelta` is scoped to a `processId` from unsupported standalone `command/exec`; `fs/changed` is scoped to a `watchId` from unsupported `fs/watch`; `mcpServer/oauthLogin/completed`, `mcpServer/startupStatus/updated`, `configWarning`, `warning` with `threadId: null`, `windows/worldWritableWarning`, and `windowsSandbox/setupCompleted` have no Freshcodex thread locator. If a future task implements the corresponding app-server feature, it must add an ownership map from that feature's request id/watch id/process id to a Freshcodex locator before treating these as thread-visible.
+- Generated notifications that may be ignored only by an explicit non-visible allowlist: `skills/changed`, `account/updated`, `account/rateLimits/updated`, `app/list/updated`, `remoteControl/status/changed`, `externalAgentConfig/import/completed`, `deprecationNotice`, `fuzzyFileSearch/sessionUpdated`, `fuzzyFileSearch/sessionCompleted`, and `account/login/completed`.
+
+## Executable Schema Traceability Foundation
+
+The repeated planning-review nonconvergence was not a disagreement about product direction; it was evidence that the plan was relying on manual rediscovery of generated Codex protocol details. This plan therefore treats the generated schema snapshot and an executable traceability matrix as the foundation. The examples in this document are regression seeds, not the source of truth. If an example and the checked-in generated schema disagree, the generated schema and traceability tests win.
+
+Task 4 must create `test/fixtures/coding-cli/codex-app-server/schema-traceability.ts` next to the generated schema inventory. That artifact is the required ownership map for every generated Codex surface Freshcodex can encounter:
+
+- Client requests and responses: generated method, generated params schema, generated result schema, strict outbound parser, app-server client method, rich-runtime method, adapter/runtime-manager method, REST or WebSocket exposure, UI capability owner, fixture tests, and explicit supported or unsupported classification.
+- Server notifications: generated method, generated params schema, route classification (`thread`, `runtimeGlobal`, `connectionScoped`, or `nonVisible`), exact locator extraction rule, invalidation or patch behavior, user-visible warning behavior when applicable, and tests proving no notification falls through to subscriber state as a fake thread locator.
+- Server requests: generated method, generated params schema, generated response schema, request id type preservation, route classification, pending request contract, UI response owner, JSON-RPC result or error serializer, and tests proving legacy root approvals route by `conversationId` while auth refresh remains runtime-global.
+- Thread, turn, item, runtime-setting, model, source, status, and leaf types: generated file, wire-required fields, generated-optional/defaulted fields, protocol parser behavior, normalized fresh-agent schema, lossless extension fields, UI renderer or supported-negative behavior, fixtures, and intentional omissions.
+- Shared fresh-agent contracts: shared schema name, producer boundary, server parser, client parser, Redux owner, persistence or pane-lifecycle owner if applicable, UI consumer, fixture, and regression test.
+
+The traceability test is a gate, not documentation. It must fail if any generated method, notification, server request, item variant, runtime leaf enum, or shared fresh-agent schema lacks a classification. It must also fail if a classification says `implemented` but has no parser, normalizer, user-visible behavior or explicit API owner, and at least one test path. Intentional omissions are allowed only when they carry a stable reason, a typed unsupported/error behavior, and a test proving the unsupported path is clear to users or harmlessly non-visible.
+
+This changes how implementation tasks should be executed:
+
+- Do not add one-off Codex protocol facts directly to component or adapter tests first. Add or update the generated snapshot and traceability entry, then write the failing parser/normalizer/UI test derived from that entry.
+- Do not classify a method as implemented merely because `CodexAppServerClient` can send it. A generated method is implemented only when it is represented through the fresh-agent adapter/runtime/API boundary and has a user-visible consumer or an intentionally documented internal owner.
+- Do not add permissive catch-all transcript items, server-request responses, or notification routing fallbacks. Unknown future generated variants should fail the traceability/schema audit until intentionally modeled.
+- Do not repair schema drift by weakening tests. Regenerate the reduced schema snapshot, update traceability, update parsers/normalizers/UI/tests, and then re-run `npm run audit:codex-app-server-schema`.
+
+## User-Visible End State
+
+- The Freshcodex pane picker entry creates a `fresh-agent` pane with `sessionType: 'freshcodex'` and `provider: 'codex'`.
+- Creating, resuming, and refreshing Freshcodex uses Codex app-server thread APIs only over a dedicated stdio app-server runtime. No terminal scraping, no Freshcodex websocket production dependency, and no Claude state path. Existing raw Codex terminal panes keep their loopback websocket app-server launch path because terminal `--remote` attach currently requires a websocket URL.
+- Freshcodex honors Codex runtime settings at create and turn time, including model, sandbox, permission/approval policy, and effort where supported by the generated local app-server schema.
+- Freshcodex model and provider capability choices come from Codex app-server `model/list` and `modelProvider/capabilities/read` when the app-server is available. Model-list REST/API contracts preserve the generated `nextCursor` and the provider-level capability payload; dropdown helpers may cache or aggregate pages, but they must not hard-code model or capability assumptions into the shared shell or smuggle provider capability booleans through unknown model-item fields that shared Zod parsing will strip.
+- Freshcodex can send text and image inputs, interrupt an active turn, fork a thread into a new freshcodex pane, answer Codex command/file/permission approval requests, answer request-user-input prompts, answer MCP elicitations, and reject unsupported dynamic tool calls with a clear response that unblocks the turn.
+- Freshcodex can start a Codex review through `review/start` for uncommitted changes by default, preserve the schema `target`, `delivery`, and returned `reviewThreadId`, and then render review status/output through the shared workspace panel.
+- Freshcodex receives Codex app-server notifications live. Turn started/completed, item started/completed, token usage, status, diff, review, compaction, child-agent/collaboration, and thread metadata notifications invalidate or patch the normalized read model and reach subscribed browsers as `freshAgent.event` without requiring a manual refresh.
+- Unsupported Codex capabilities are disabled with clear labels. Do not silently fall back to raw terminal mode.
+- The Freshcodex transcript renders normalized item cards for user messages, hook prompts, agent messages, plans, reasoning, command executions, file changes/diffs, MCP tool calls, collaboration calls, web searches, image views, image generations, review mode, context compaction, dynamic tool calls, errors, and tool/request prompts. Codex user-message content is preserved as multi-part message content, including mixed text and images; do not collapse a multi-part Codex `userMessage.content` array into a single text-only item.
+- Long transcripts page through `thread/turns/list`, hydrate from page-provided turn bodies or a bounded body cache, and render through virtualization so mobile remains responsive. Freshcodex snapshots must not load every turn body as the normal path.
+- Diff/review/worktree/fork metadata is usable, not just listed. Users can inspect file-change diffs, see review status/output, see fork lineage, see child threads, and identify worktree branch/path.
+- Freshcodex has typed load/create/action errors that point to the failing boundary: app-server unavailable, app-server protocol invalid, fresh-agent contract invalid, stale revision, unsupported capability, unauthorized session, or lost session.
+- Freshclaude still works after the refactor. Hidden `kilroy` still resolves as Claude-backed. `freshopencode` remains disabled and unimplemented.
+- Existing Freshclaude saved layouts, settings, remote tab snapshots, and history stay readable. Existing Freshcodex saved panes must be able to attach after a browser reload or server restart by resuming/loading the Codex app-server thread before snapshot/action work. Do not clear browser storage to force migration.
+
+## Contracts And Invariants
+
+- Durable read-model contracts live in `shared/fresh-agent-contract.ts`; pane lifecycle state stays in `src/store/paneTypes.ts` and must not leak into durable snapshot schemas.
+- `provider` means runtime family: `claude`, `codex`, or later `opencode`.
+- `sessionType` means user-facing identity: `freshclaude`, `freshcodex`, `kilroy`, or disabled `freshopencode`.
+- Every fresh-agent read-model contract and browser/server API that identifies a session must include `sessionType` as well as `provider` and `threadId`. Do not infer user-facing identity from `provider`; multiple session types can share one runtime provider. Use one canonical REST locator shape for fresh-agent thread resources: `/api/fresh-agent/threads/:sessionType/:provider/:threadId` and `/api/fresh-agent/threads/:sessionType/:provider/:threadId/turns...`. Do not keep the old provider-only route as the primary API because it makes `freshclaude`/`kilroy` and future shared-provider clients ambiguous.
+- Fresh-agent live session tracking and action routing must key sessions by the full locator `{ sessionType, provider, threadId }`, not by `sessionId` alone. Claude, Codex, and later OpenCode can all expose opaque ids, and a durable foundation must not depend on cross-provider id uniqueness. WebSocket action messages may keep `sessionId` as the user-facing field name for compatibility, but they must also carry `sessionType` and `provider`, and the runtime manager must validate the full locator before dispatching an action.
+- Fresh-agent client state, thunk caches, pane activity projection, and subscription bookkeeping must use the same canonical full-locator key as the server runtime manager. Do not index `freshAgent.sessions` by bare `sessionId`; a server-side routing fix is incomplete if Redux or pane activity can still collapse two providers that reuse an opaque id.
+- `server/fresh-agent/provider-registry.ts` must model two separate concepts: a session-type descriptor registry and a runtime-provider adapter registry. Runtime adapter lookup by provider must not be overwritten by another session type using the same provider.
+- `src/store/freshAgentSlice.ts` must become an actual fresh-agent slice with fresh-agent action names and contract-shaped state. It must not re-export `agentChatSlice`; `src/store/freshAgentTypes.ts` must not alias `agentChatTypes`; and `src/store/freshAgentThunks.ts` must not alias `agentChatThunks`.
+- All fresh-agent server adapter outputs parse before leaving `server/fresh-agent/runtime-manager.ts`.
+- All fresh-agent REST payloads parse again in `src/lib/api.ts` before UI state sees them.
+- A snapshot, turn page, or turn body with an invalid contract is a controlled error, not partially rendered data.
+- Freshcodex snapshots are lightweight. They may include thread metadata, pending request state, extensions, and at most a bounded initial turn page. They must not call `thread/read { includeTurns: true }` merely to render the normal snapshot.
+- Freshcodex restored-session loading is also page-first. When a browser-restored or server-restarted Freshcodex pane needs to load a thread into the stdio app-server process, the Codex adapter must call `thread/resume` with `excludeTurns: true` and then fetch the visible page through `thread/turns/list`; it must not rely on a default `thread/resume` response that may include all turns.
+- Fresh-agent `revision` is a Freshell normalized read-model revision, not a Codex app-server revision. For Codex, derive it from runtime-manager event ordering and stable thread metadata such as `thread.updatedAt`; preserve the app-server source version separately in `extensions.codex.sourceVersion`. Turn page and turn body requests compare against the Freshell normalized revision. Do not send nonexistent Codex `revision` fields to app-server requests.
+- Codex app-server protocol schemas are owned by `server/coding-cli/codex-app-server/protocol.ts`, and must be cross-checked with `codex app-server generate-json-schema` during implementation.
+- Codex generated-schema traceability is owned by `test/fixtures/coding-cli/codex-app-server/schema-traceability.ts`, and shared fresh-agent contract traceability is owned by `test/fixtures/fresh-agent/contract-traceability.ts`. These are executable completeness gates, not optional docs. Any generated surface or shared contract added without a parser, normalizer, API/action owner, UI behavior or supported-negative owner, and test path must fail the relevant traceability test before implementation can proceed.
+- Codex app-server transports are separated by runtime purpose. `server/coding-cli/codex-app-server/client.ts` owns JSON-RPC request/response semantics over an injected transport; `transport.ts` owns concrete stdio JSONL and websocket framing. `runtime.ts` remains the loopback websocket runtime used by `CodexLaunchPlanner` and raw Codex terminal `--remote` attach. New `rich-runtime.ts` is the Freshcodex-only stdio runtime and must not return or require a `wsUrl`.
+- Codex JSON-RPC messages omit the `jsonrpc` property on the wire and emit `initialized` exactly once after successful `initialize`.
+- Codex request ids must round-trip as generated `string | number`; never coerce server-initiated request ids to numbers before responding. Runtime Zod schemas should use `z.number().int()` for the numeric branch because the generated JSON wire schema constrains numeric `RequestId` values to integers even though TypeScript can only represent that branch as `number`.
+- Fresh-agent pending approval/question/request contracts that represent Codex server-initiated JSON-RPC requests must also carry generated `string | number` request ids. Browser-created request ids, such as `freshAgent.create.requestId`, may remain strings, but Codex server request ids must not be narrowed to `NonEmptyString` in shared response schemas, Redux pending state, or WebSocket response actions.
+- Provider-specific detail is preserved under typed extension schemas, not ad-hoc `Record` blobs in transcript items.
+- A normalized turn is a lifecycle/container boundary, not a single message role. Codex `Turn` objects contain mixed user, assistant, tool, and system items, so role belongs on message transcript items and turn-level `role` must be optional/legacy-only. Do not invent a turn role to satisfy the contract.
+- A Codex app-server item may normalize to zero, one, or many fresh-agent transcript items. In particular, `userMessage.content` can contain multiple text/image/localImage parts. Codex item normalization must return an array and turn normalization must `flatMap` item output while preserving stable derived ids for split content parts.
+- Codex item-status normalization is a separate leaf mapping from thread/turn status normalization. Do not pass raw item status strings through to fresh-agent transcript item contracts: map generated `"inProgress"` to the shared UI `"running"` status and preserve generated terminal states (`"completed"`, `"failed"`, `"declined"`) where the shared item kind supports them.
+- Codex reasoning normalization must preserve both generated `summary` and `content` arrays. Do not collapse `content` into a lossy preview-only field.
+- Codex file-change normalization must preserve generated rename/move metadata. Do not flatten all `{ type: "update" }` changes to `modify`; a non-null `move_path` is a first-class diff/review detail that belongs in the shared contract.
+- Codex collaboration items must preserve all generated receiver thread ids and agent states. The normalized item can add convenience display labels, but the durable contract must keep `receiverThreadIds` as an array and must not replace it with a single `receiverThreadId`.
+- Codex image-generation normalization must preserve the generated raw `status: string`. If the UI wants a small visual state enum, derive it into a separate optional display field instead of rejecting unknown raw statuses at the contract boundary.
+- Codex generated item-detail normalization must be lossless for the fields the local schema exposes today. Display-oriented convenience fields are fine, but the shared contract must keep structured text elements, hook run ids, agent-message phase/memory citations, command source/action metadata, MCP resource/result metadata, dynamic-tool output content, web-search actions, and image-generation `result`/`savedPath` so later UX work does not need another contract migration.
+- Codex tool-call argument normalization must preserve generated `JsonValue` exactly. Do not type fresh-agent tool inputs as object records or coerce non-object arguments into strings for display.
+- Codex `UserInput` content parts include text, image, localImage, skill, and mention. Freshcodex message content and renderers must preserve every generated part type; do not silently drop skill or mention references from existing threads.
+- Freshcodex runtime settings use Codex-shaped values at the app-server boundary. Shared UI/state may keep the historical field name `permissionMode`, but the value sent to Codex must parse as generated `AskForApproval`; `effort` must parse as generated `ReasoningEffort`; and turn-time sandbox overrides must be converted to generated `SandboxPolicy` with a clear error if the selected mode cannot be represented.
+- `FreshAgentRuntimeSettingsSchema` may remain a broad persisted/UI shape only because Freshclaude and Freshcodex share historical field names. Any executable action parser (`freshAgent.create`, `freshAgent.send`, `freshAgent.attach`, review/fork settings, and REST action bodies) must resolve the provider first, then validate settings with a provider-specific schema before the runtime manager or adapter receives the action. A Freshcodex action carrying Claude-only values such as `permissionMode: "bypassPermissions"` or `effort: "max"` must fail in WebSocket/controller parsing with `FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING`, not merely inside the Codex adapter after generic parsing has already accepted it.
+- Codex turn bodies are page-first. `thread/turns/list` returns `Turn` objects with items, so Freshcodex should normalize those page results directly into turn bodies. A server-side LRU turn-body cache may serve `/turns/:turnId` for bodies already loaded from pages; the adapter must not implement body hydration by repeatedly calling `thread/read { includeTurns: true }` over the full thread.
+- Every app-server item/request type documented by the current local generated schema must either have a normalized UI representation or a clear supported-negative response path. Unknown future item types should fail contract validation until intentionally modeled. Do not add a catch-all transcript fallback without explicit approval.
+- Every Codex normalization fixture that claims to model an app-server `Thread`, `Turn`, `ThreadItem`, `ServerRequest`, or `ServerNotification` must first parse through the local generated Codex protocol schemas in `server/coding-cli/codex-app-server/protocol.ts`. Do not write tests against impossible mock shapes. If an example in this plan differs from the generated schema, the generated schema wins and the fixture must be corrected.
+- Codex protocol schemas in `server/coding-cli/codex-app-server/protocol.ts` must reject missing generated JSON-Schema-required fields, while accepting generated-optional wire fields and normalizing them at the protocol boundary. Do not use permissive partial schemas for app-server entities that generated JSON Schema makes required. Known important required examples are `Thread.turns`, `Thread.cwd`, `Thread.source`, `Thread.createdAt`, `Thread.updatedAt`, `Turn.items`, and `Turn.status`. Known important optional/default examples that must parse and normalize are `Thread.forkedFromId`, `Thread.path`, `Thread.agentNickname`, `Thread.agentRole`, `Thread.gitInfo`, `Thread.name`, `Turn.error`, `Turn.startedAt`, `Turn.completedAt`, `Turn.durationMs`, `ThreadListResponse.nextCursor`, `ThreadListResponse.backwardsCursor`, `ThreadTurnsListResponse.nextCursor`, `ThreadTurnsListResponse.backwardsCursor`, `ThreadLoadedListResponse.nextCursor`, and `ModelListResponse.nextCursor`.
+- Every app-server notification method documented by the current local generated schema that can affect visible Freshcodex state must be intentionally handled and routed by its generated locator shape. At minimum, turn lifecycle, item lifecycle, token usage, status, diff/review, thread metadata/name/archive/close, context compaction, collaboration/child-agent, realtime error/close, and app-server error notifications must trigger a fresh-agent invalidation event for the generated target thread or a typed runtime/global warning when no thread locator exists. Unknown future notification methods should be logged at debug level and ignored only if they are explicitly classified as non-visible; visible-state notifications must not be silently dropped. Do not route no-locator notifications to the current subscriber's `sessionId`; that makes global app-server events corrupt unrelated thread state.
+- Server-initiated Codex requests that include a generated thread locator are routed to that Freshcodex thread. For v2 request params the locator is `threadId`; for legacy root `applyPatchApproval` and `execCommandApproval` params the locator is `conversationId`. Server-initiated Codex requests without either locator, currently `account/chatgptAuthTokens/refresh`, are runtime-global; they must be answered on the original JSON-RPC id and broadcast as a typed runtime error to subscribed Freshcodex panes instead of being dropped or attached to an arbitrary thread.
+- Codex server-request response shapes must stay discriminated by generated request method all the way through the shared contract, WebSocket protocol, controller, and adapter. Do not collapse all prompts to Claude-style `answers: Record` or `decision: string`: `item/tool/requestUserInput` responds with `{ answers: Record }`, `mcpServer/elicitation/request` responds with `{ action, content, _meta }`, `item/permissions/requestApproval` responds with `{ permissions, scope, strictAutoReview? }`, and command/file approval responses keep their generated decision payloads.
+- Async pane updates in `FreshAgentView` must use targeted `mergePaneContent` updates unless replacing an entire pane is intentional.
+- Freshcodex tests must be able to render without `state.agentChat.sessions` or Claude restore helpers.
+- Main-branch fixes for auto-title, mobile keyboard/touch target behavior, stale pane hydration, reconnect recovery, and app-server stdio/init hardening must survive the cutover.
+
+## File Structure
+
+### Create
+
+- `shared/fresh-agent-contract.ts` - Zod schemas and exported types for snapshots, turn pages, turn bodies, items, provider extensions, action responses, and contract errors.
+- `src/lib/fresh-agent-api-error.ts` - typed client error helper for contract parse failures and fresh-agent API errors.
+- `server/coding-cli/codex-app-server/transport.ts` - app-server transport abstraction plus stdio JSONL and websocket implementations that own framing, close/error handling, and request/notification delivery.
+- `server/coding-cli/codex-app-server/rich-runtime.ts` - Freshcodex-only stdio app-server runtime that exposes rich thread/turn/fork/request APIs without a terminal `wsUrl`.
+- `src/components/fresh-agent/useFreshAgentThreadController.ts` - controller hook for create/attach/snapshot/action/pagination state.
+- `src/components/fresh-agent/FreshAgentShell.tsx` - pure presentational shell for header, banners, transcript, composer, sidebar, and workspace panel.
+- `src/components/fresh-agent/FreshAgentTranscriptVirtualList.tsx` - virtualized transcript list backed by turn summaries and hydrated bodies.
+- `src/components/fresh-agent/FreshAgentWorkspacePanel.tsx` - typed worktree, child-thread, review, fork, and diff browser.
+- `src/components/fresh-agent/FreshAgentItemCard.tsx` - normalized transcript item rendering.
+- `src/components/fresh-agent/fresh-agent-policy.ts` - small runtime/session policy helpers for labels, action availability, and restore behavior.
+- `test/fixtures/fresh-agent/codex/contract-fixtures.ts` - schema-validated Codex snapshot, turn page, turn body, event, approval, review, and fork fixtures.
+- `test/fixtures/fresh-agent/claude/contract-fixtures.ts` - schema-validated Claude snapshot/page/body fixtures that preserve existing behavior.
+- `test/fixtures/fresh-agent/contract-traceability.ts` - executable owner map from shared fresh-agent schemas to producer boundaries, parser boundaries, state owners, UI consumers, fixtures, and tests.
+- `test/unit/shared/fresh-agent-contract.test.ts`
+- `test/unit/shared/fresh-agent-contract-traceability.test.ts`
+- `test/unit/client/lib/api.fresh-agent-contract.test.ts`
+- `test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx`
+- `test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx`
+- `test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx`
+- `test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx`
+- `test/unit/server/fresh-agent/contract-boundary.test.ts`
+- `test/unit/server/coding-cli/codex-app-server/transport.test.ts` - stdio JSONL and websocket transport framing, request/notification delivery, and close/error behavior.
+- `test/unit/server/coding-cli/codex-app-server/rich-runtime.test.ts` - Freshcodex stdio runtime lifecycle and rich API proxy coverage.
+- `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/` - checked-in schema audit snapshot generated from local `codex app-server generate-ts` / `generate-json-schema`, reduced to the files needed by tests.
+- `test/fixtures/coding-cli/codex-app-server/schema-inventory.ts` - helper that extracts method/type inventories from the checked-in generated schema snapshot so protocol tests do not depend on `/tmp` state.
+- `test/fixtures/coding-cli/codex-app-server/schema-traceability.ts` - executable traceability matrix classifying every generated client request, server request, server notification, item variant, runtime leaf type, response shape, and intentional omission.
+- `test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts`
+- `scripts/audit-codex-app-server-schema.ts` - developer audit script that regenerates the local Codex schema, compares it with the checked-in fixture inventory, and prints the exact methods/types requiring reclassification.
+
+### Modify
+
+- `shared/fresh-agent.ts`
+- `shared/read-models.ts`
+- `shared/ws-protocol.ts`
+- `server/coding-cli/codex-app-server/protocol.ts`
+- `server/coding-cli/codex-app-server/client.ts`
+- `server/coding-cli/codex-app-server/runtime.ts`
+- `server/coding-cli/codex-app-server/launch-planner.ts`
+- `server/fresh-agent/runtime-adapter.ts`
+- `server/fresh-agent/provider-registry.ts`
+- `server/fresh-agent/runtime-manager.ts`
+- `server/fresh-agent/router.ts`
+- `server/fresh-agent/adapters/claude/normalize.ts`
+- `server/fresh-agent/adapters/claude/adapter.ts`
+- `server/fresh-agent/adapters/codex/normalize.ts`
+- `server/fresh-agent/adapters/codex/adapter.ts`
+- `server/index.ts`
+- `server/ws-handler.ts`
+- `src/lib/api.ts`
+- `src/lib/fresh-agent-ws.ts`
+- `src/lib/fresh-agent-registry.ts`
+- `src/lib/pane-activity.ts`
+- `src/lib/session-type-utils.ts`
+- `src/lib/tab-registry-snapshot.ts`
+- `src/store/freshAgentSlice.ts`
+- `src/store/freshAgentThunks.ts`
+- `src/store/freshAgentTypes.ts`
+- `src/store/paneTypes.ts`
+- `src/store/panesSlice.ts`
+- `src/store/paneTreeValidation.ts`
+- `src/store/selectors/sidebarSelectors.ts`
+- `src/store/tabsSlice.ts`
+- `src/store/managed-items.ts`
+- `src/store/settingsThunks.ts`
+- `src/lib/derivePaneTitle.ts`
+- `src/lib/session-utils.ts`
+- `src/components/ExtensionsView.tsx`
+- `src/components/TabsView.tsx`
+- `src/components/fresh-agent/FreshAgentView.tsx`
+- `src/components/fresh-agent/FreshAgentTranscript.tsx`
+- `src/components/fresh-agent/FreshAgentComposer.tsx`
+- `src/components/fresh-agent/FreshAgentDiffPanel.tsx`
+- `src/components/fresh-agent/FreshAgentSidebar.tsx`
+- `src/components/HistoryView.tsx`
+- `src/components/panes/PaneContainer.tsx`
+- `src/components/panes/PanePicker.tsx`
+- `src/components/SettingsView.tsx`
+- `src/hooks/useKeyboardInset.ts` if main merge introduces it
+- `test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs`
+- `test/unit/server/coding-cli/codex-app-server/client.test.ts`
+- `test/unit/server/coding-cli/codex-app-server/runtime.test.ts`
+- `test/unit/server/fresh-agent/codex-normalize.test.ts`
+- `test/unit/server/fresh-agent/codex-adapter.test.ts`
+- `test/unit/server/fresh-agent/claude-normalize.test.ts`
+- `test/unit/server/fresh-agent/claude-adapter.test.ts`
+- `test/unit/server/fresh-agent/router.test.ts`
+- `test/unit/server/fresh-agent/runtime-manager.test.ts`
+- `test/unit/server/ws-handler-fresh-agent.test.ts`
+- `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx`
+- `test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx`
+- `test/e2e-browser/specs/fresh-agent.spec.ts`
+- `test/e2e-browser/specs/fresh-agent-mobile.spec.ts`
+- `test/e2e-browser/perf/scenarios.ts`
+- `docs/index.html`
+
+### Preserve Unless Proven Dead
+
+- `src/components/agent-chat/*`
+- `src/store/agentChatSlice.ts`
+- `src/lib/sdk-message-handler.ts`
+- Legacy `sdk.*` WebSocket protocol
+
+These still back Freshclaude behavior and current regression coverage. This plan removes Freshcodex dependence on them, not the entire legacy Claude path.
+
+## Strategy Gate
+
+The most important decision is to make the shared contract the center of the architecture before adding more UI. The current branch already has the right shape, but the contract is informal: Codex normalizers pass raw-ish records, client API returns `any`, and `FreshAgentView` infers provider behavior directly. Adding more Freshcodex features on top of that would make every later diff/review/fork/mobile improvement fragile.
+
+The correct route is:
+
+- Merge current main first because main contains fixes in exactly the cutover surfaces.
+- Split session-type identity from runtime-provider adapter lookup before depending on either in contract tests. `freshclaude` and `kilroy` sharing the Claude adapter must be an intentional many-to-one mapping, not a Map overwrite side effect.
+- Lock shared Zod contracts for all read-model payloads and action responses.
+- Lock executable traceability for shared fresh-agent schemas and generated Codex schemas before relying on individual examples. The matrix must prove every generated method/request/notification/item/leaf and every shared schema has an owner across parser, normalizer, API/action, UI or supported-negative behavior, fixture, and test.
+- Enforce those contracts on both server and client boundaries.
+- Replace the temporary `freshAgentSlice` re-export of `agentChatSlice` with a real contract-shaped fresh-agent slice. Freshclaude compatibility can be implemented through the Claude adapter and explicit migration/projection code, not by keeping Fresh-agent state as a renamed agent-chat state tree.
+- Replace only Freshcodex's app-server dependency on the experimental websocket transport with a dedicated stdio JSONL rich runtime, while preserving the existing websocket runtime for raw Codex terminal remote attach. Then normalize Codex app-server data fully using app-server generated schemas to avoid guessing method shapes.
+- Model every currently documented app-server item and server-request surface before choosing to fail unknown future variants.
+- Treat app-server method classification as product scope, not just protocol plumbing. If Freshcodex marks `thread/list`, `thread/loaded/list`, `review/start`, `model/list`, or `modelProvider/capabilities/read` as implemented, the plan must wire those methods into history/session projection, review actions, and settings/capability UI rather than leaving them as unused client helpers.
+- Split controller from presentation only after contract fixtures exist.
+- Implement Freshcodex actions through app-server thread/turn primitives and explicit server-request response handling.
+- Finish transcript virtualization and workspace UX so the foundation is good enough for long-term feature growth, not just a thin demo. Freshcodex must stay page-first for transcript bodies: normal snapshots and body hydration should not load the whole app-server thread.
+
+No user decision is required. The plan makes one deliberate scope choice: `freshopencode` stays disabled and unimplemented, while the shared contract remains provider-extensible.
+
+### Task 1: Sync Current Main Without Regressing Fresh-Agent Work
+
+**Files:**
+- Modify as needed by merge: `server/ws-handler.ts`
+- Modify as needed by merge: `shared/ws-protocol.ts`
+- Modify as needed by merge: `server/coding-cli/codex-app-server/protocol.ts`
+- Modify as needed by merge: `server/coding-cli/codex-app-server/runtime.ts`
+- Modify as needed by merge: `src/components/agent-chat/AgentChatView.tsx`
+- Modify as needed by merge: `src/components/agent-chat/AgentChatSettings.tsx`
+- Modify as needed by merge: `src/components/agent-chat/ChatComposer.tsx`
+- Modify as needed by merge: `src/components/agent-chat/PermissionBanner.tsx`
+- Modify as needed by merge: `src/components/agent-chat/QuestionBanner.tsx`
+- Modify as needed by merge: `src/components/agent-chat/ToolStrip.tsx`
+- Modify as needed by merge: `src/store/panesSlice.ts`
+- Modify as needed by merge: `src/lib/ws-client.ts`
+- Modify as needed by merge: `server/title-utils.ts`
+- Create or preserve from main: `shared/title-utils.ts`
+- Create or preserve from main: `src/hooks/useKeyboardInset.ts`
+- Test: `test/unit/client/components/agent-chat/AgentChatView.auto-title.test.tsx`
+- Test: `test/unit/client/components/agent-chat/AgentChatView.mobile-keyboard.test.tsx`
+- Test: `test/unit/client/components/agent-chat/ChatComposer.mobile.test.tsx`
+- Test: `test/unit/client/hooks/useKeyboardInset.test.ts`
+- Test: `test/unit/client/store/panesSlice.test.ts`
+- Test: `test/unit/server/ws-handler-sdk.test.ts`
+- Test: `test/unit/server/coding-cli/codex-app-server/client.test.ts`
+- Test: `test/unit/server/coding-cli/codex-app-server/runtime.test.ts`
+
+- [ ] **Step 1: Identify the merge conflict surface and baseline expectations**
+
+Run:
+
+```bash
+git status --short --branch
+git log --oneline --left-right --cherry-pick HEAD...origin/main --max-count=30
+npm run test:vitest -- \
+ test/unit/server/coding-cli/codex-app-server/client.test.ts \
+ test/unit/server/coding-cli/codex-app-server/runtime.test.ts \
+ test/unit/client/store/panesSlice.test.ts \
+ test/unit/server/ws-handler-sdk.test.ts
+```
+
+Expected: record the exact ahead/behind state. If `git status` shows pre-existing unrelated changes, record those paths and do not stage or overwrite them; stop only if they conflict with this task. If the right-side count is nonzero, logs show main commits that must be merged. If the right-side count is zero, `origin/main` is already contained and this task becomes a verification-only sync gate with no merge commit.
+
+- [ ] **Step 2: Merge `origin/main` into the worktree branch only when needed**
+
+Run:
+
+```bash
+git fetch origin
+git rev-list --left-right --count HEAD...origin/main
+```
+
+If the right-side count is nonzero, run:
+
+```bash
+git merge origin/main
+```
+
+Expected: conflicts are possible in `server/ws-handler.ts`, `shared/ws-protocol.ts`, `src/store/panesSlice.ts`, `src/components/agent-chat/AgentChatView.tsx`, and Codex app-server files. If the right-side count is zero, do not run a no-op merge and do not create an empty commit; proceed to Step 4.
+
+- [ ] **Step 3: Resolve conflicts by preserving both main fixes and fresh-agent behavior**
+
+Skip this step if Step 2 found no right-side `origin/main` commits.
+
+Conflict resolution rules:
+
+```ts
+// Keep main's stale-hydration protection in reducers.
+// Keep fresh-agent pane normalization and legacy agent-chat migration.
+// Keep main's mobile keyboard/touch helpers in agent-chat components.
+// Later tasks port those helpers into fresh-agent components.
+// Keep main's Codex app-server stdout/stderr drain and initialize contract fixes.
+// Keep fresh-agent runtime manager and routes.
+```
+
+Do not delete fresh-agent tests to make the merge pass. Do not revert main's production static routing, reconnect, stale hydration, or app-server fixes.
+
+- [ ] **Step 4: Verify the merge**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/server/coding-cli/codex-app-server/client.test.ts \
+ test/unit/server/coding-cli/codex-app-server/runtime.test.ts \
+ test/unit/client/store/panesSlice.test.ts \
+ test/unit/server/ws-handler-sdk.test.ts \
+ test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \
+ test/unit/server/ws-handler-fresh-agent.test.ts
+```
+
+Expected: all pass. If main introduced new tests, include their exact paths from the merge output.
+
+- [ ] **Step 5: Refactor and verify**
+
+Tighten only conflict-resolved code. Do not start the freshcodex contract work in this commit.
+
+Run:
+
+```bash
+npm run typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit only if the main sync changed tracked files**
+
+If Step 2 found no right-side `origin/main` commits and Step 5 made no tracked changes, do not create an empty commit. Otherwise commit only the files changed by the sync/conflict resolution; do not stage unrelated pre-existing dirty paths.
+
+```bash
+git add \
+ server/ws-handler.ts shared/ws-protocol.ts \
+ server/coding-cli/codex-app-server/protocol.ts \
+ server/coding-cli/codex-app-server/runtime.ts \
+ src/store/panesSlice.ts src/lib/ws-client.ts \
+ src/components/agent-chat/AgentChatView.tsx \
+ src/components/agent-chat/AgentChatSettings.tsx \
+ src/components/agent-chat/ChatComposer.tsx \
+ src/components/agent-chat/PermissionBanner.tsx \
+ src/components/agent-chat/QuestionBanner.tsx \
+ src/components/agent-chat/ToolStrip.tsx \
+ src/hooks/useKeyboardInset.ts shared/title-utils.ts server/title-utils.ts \
+ test/unit/server/coding-cli/codex-app-server/client.test.ts \
+ test/unit/server/coding-cli/codex-app-server/runtime.test.ts \
+ test/unit/client/store/panesSlice.test.ts \
+ test/unit/server/ws-handler-sdk.test.ts
+git commit -m "Sync main into freshcodex contract foundation"
+```
+
+### Task 2: Define The Shared Fresh-Agent Contract
+
+**Files:**
+- Create: `shared/fresh-agent-contract.ts`
+- Modify: `shared/fresh-agent.ts`
+- Modify: `shared/read-models.ts`
+- Test: `test/unit/shared/fresh-agent-contract.test.ts`
+- Test: `test/unit/shared/fresh-agent-contract-traceability.test.ts`
+- Test: `test/fixtures/fresh-agent/codex/contract-fixtures.ts`
+- Test: `test/fixtures/fresh-agent/claude/contract-fixtures.ts`
+- Test: `test/fixtures/fresh-agent/contract-traceability.ts`
+
+- [ ] **Step 1: Write failing contract tests**
+
+Create tests that require:
+
+```ts
+expect(FreshAgentThreadSnapshotSchema.parse(validCodexSnapshot)).toMatchObject({
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ threadId: 'thread-codex-1',
+ status: 'idle',
+})
+
+expect(() => FreshAgentThreadSnapshotSchema.parse({
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ threadId: 'thread-codex-1',
+ revision: 1,
+ status: 'creating',
+})).toThrow(/status/i)
+
+expect(() => FreshAgentTranscriptItemSchema.parse({
+ id: 'bad-item',
+ kind: 'raw',
+ payload: {},
+})).toThrow(/kind/i)
+
+expect(FreshAgentTranscriptItemSchema.parse({
+ id: 'user-message-1',
+ kind: 'message',
+ role: 'user',
+ content: [
+ { kind: 'text', text: 'Use this mockup', textElements: [{ byteRange: { start: 0, end: 4 }, placeholder: 'Use' }] },
+ { kind: 'image', url: 'https://example.test/mockup.png', mediaType: 'image/png' },
+ { kind: 'mention', name: 'README.md', path: '/repo/README.md' },
+ { kind: 'skill', name: 'reviewer', path: '/repo/.codex/skills/reviewer/SKILL.md' },
+ ],
+})).toMatchObject({
+ kind: 'message',
+ role: 'user',
+ content: [
+ { kind: 'text' },
+ { kind: 'image' },
+ { kind: 'mention' },
+ { kind: 'skill' },
+ ],
+})
+
+expect(FreshAgentTranscriptItemSchema.parse({
+ id: 'agent-message-1',
+ kind: 'message',
+ role: 'assistant',
+ phase: 'final_answer',
+ memoryCitation: {
+ entries: [{ path: '/repo/AGENTS.md', lineStart: 1, lineEnd: 4, note: 'Project instruction' }],
+ threadIds: ['thread-memory-1'],
+ },
+ content: [{ kind: 'text', text: 'Done' }],
+})).toMatchObject({
+ kind: 'message',
+ phase: 'final_answer',
+ memoryCitation: expect.objectContaining({ threadIds: ['thread-memory-1'] }),
+})
+```
+
+Also assert that `FreshAgentTurnPageSchema`, `FreshAgentTurnBodySchema`, `FreshAgentThreadListPageSchema`, `FreshAgentModelListPageSchema`, `FreshAgentModelProviderCapabilitiesSchema`, `FreshAgentActionResultSchema`, `FreshAgentCodexExtensionSchema`, and `FreshAgentClaudeExtensionSchema` parse the new fixtures. The thread-list fixture must preserve `items`, `nextCursor`, and `backwardsCursor` because Codex `thread/list` is paginated and Freshcodex history must not collapse the app-server page to an array. The model-list fixture must preserve `items`, `nextCursor`, and provider-level capabilities when available because Codex `model/list` is paginated and Freshcodex settings must not treat the first page as a complete model catalog or drop `modelProvider/capabilities/read` data at the shared contract boundary.
+Also assert that `FreshAgentInputImageSchema`, `FreshAgentRuntimeSettingsSchema`, `FreshAgentCodexRuntimeSettingsSchema`, and `FreshAgentClaudeRuntimeSettingsSchema` parse URL, local-path, data-URL/image-data, model, sandbox, provider-specific permission/approval policy, and provider-specific effort fixtures because those shapes are shared by REST, WebSocket, controller, and adapter code. The broad `FreshAgentRuntimeSettingsSchema` may accept historical Claude and Codex values for persisted data, but the provider-specific schemas are the executable action gate. The test must prove Freshcodex accepts generated Codex effort values (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`) and rejects legacy Claude-only effort values such as `max` through `FreshAgentCodexRuntimeSettingsSchema` before an adapter call. It must also prove Freshcodex accepts generated Codex approval policies (`untrusted`, `on-failure`, `on-request`, `never`, and granular policy objects) and rejects Claude permission modes such as `bypassPermissions` through the same provider-specific parser. Add the mirror assertion that Freshclaude still accepts its existing Claude permission/effort values through `FreshAgentClaudeRuntimeSettingsSchema`.
+Add `test/fixtures/fresh-agent/contract-traceability.ts` and `test/unit/shared/fresh-agent-contract-traceability.test.ts` before implementing the schemas. The traceability test must enumerate every exported durable schema from `shared/fresh-agent-contract.ts` and require an owner for producer boundary, server parse boundary, client parse boundary, state/persistence boundary when applicable, UI consumer, fixture, and test. It should fail for any exported contract schema that is not listed, and it should fail for any listed schema whose owner/test path is empty. This prevents future shared contract expansion from becoming another implicit, unreviewed surface.
+
+Include explicit fixtures for every Codex transcript/request surface the user-visible end state names:
+
+```ts
+expect(FreshAgentTranscriptItemSchema.parse({
+ id: 'compact-1',
+ kind: 'context_compaction',
+ status: 'completed',
+ summary: 'Compacted prior context',
+})).toMatchObject({ kind: 'context_compaction' })
+
+expect(FreshAgentTranscriptItemSchema.parse({
+ id: 'dyn-1',
+ kind: 'dynamic_tool',
+ name: 'unsupported-local-tool',
+ namespace: 'fixture-namespace',
+ status: 'declined',
+ input: ['non-object', { ok: true }],
+ contentItems: [{ type: 'inputText', text: 'Dynamic tool calls are not supported by Freshell yet.' }],
+ success: false,
+ reason: 'Dynamic tool calls are not supported by Freshell yet.',
+})).toMatchObject({ kind: 'dynamic_tool', status: 'declined', input: ['non-object', { ok: true }], success: false })
+
+expect(FreshAgentTranscriptItemSchema.parse({
+ id: 'tool-1',
+ kind: 'tool',
+ server: 'fixture-server',
+ name: 'fixture-tool',
+ status: 'completed',
+ input: 'raw-string-argument',
+ result: { content: [], structuredContent: null, _meta: null },
+})).toMatchObject({ kind: 'tool', input: 'raw-string-argument' })
+
+expect(FreshAgentTranscriptItemSchema.parse({
+ id: 'hook-1',
+ kind: 'hook_prompt',
+ fragments: [{ text: 'Preflight', hookRunId: 'hook-run-1' }],
+})).toMatchObject({
+ kind: 'hook_prompt',
+ fragments: [{ text: 'Preflight', hookRunId: 'hook-run-1' }],
+})
+
+expect(FreshAgentTranscriptItemSchema.parse({
+ id: 'reasoning-1',
+ kind: 'reasoning',
+ summary: ['Checked repository state'],
+ content: ['First reasoning paragraph', 'Second reasoning paragraph'],
+})).toMatchObject({
+ kind: 'reasoning',
+ summary: ['Checked repository state'],
+ content: ['First reasoning paragraph', 'Second reasoning paragraph'],
+})
+
+expect(FreshAgentTranscriptItemSchema.parse({
+ id: 'collab-1',
+ kind: 'collaboration',
+ tool: 'spawnAgent',
+ status: 'running',
+ senderThreadId: 'thread-parent-1',
+ receiverThreadIds: ['thread-child-1', 'thread-child-2'],
+ model: 'configured-model',
+ reasoningEffort: 'high',
+ agentsStates: {
+ 'thread-child-1': { status: 'running', message: 'Working' },
+ 'thread-child-2': { status: 'completed', message: null },
+ },
+})).toMatchObject({
+ kind: 'collaboration',
+ receiverThreadIds: ['thread-child-1', 'thread-child-2'],
+})
+
+expect(FreshAgentTranscriptItemSchema.parse({
+ id: 'web-1',
+ kind: 'web_search',
+ query: 'Freshell Codex',
+ action: { type: 'findInPage', url: 'https://example.test/docs', pattern: 'Codex' },
+})).toMatchObject({
+ kind: 'web_search',
+ action: { type: 'findInPage', pattern: 'Codex' },
+})
+
+expect(FreshAgentTranscriptItemSchema.parse({
+ id: 'image-gen-1',
+ kind: 'image_generation',
+ status: 'provider_specific_status',
+ result: 'https://example.test/generated.png',
+ savedPath: '/repo/generated.png',
+})).toMatchObject({
+ kind: 'image_generation',
+ result: 'https://example.test/generated.png',
+ savedPath: '/repo/generated.png',
+})
+
+expect(FreshAgentQuestionRequestSchema.parse({
+ requestId: 'server-request-1',
+ kind: 'mcp_elicitation',
+ title: 'Confirm MCP input',
+ prompt: 'Choose a value',
+ fields: [],
+})).toMatchObject({ kind: 'mcp_elicitation' })
+
+expect(FreshAgentServerRequestResponseSchema.parse({
+ requestId: 'user-input-1',
+ kind: 'tool_user_input',
+ answers: {
+ choice: { answers: ['a'] },
+ },
+})).toMatchObject({
+ kind: 'tool_user_input',
+ answers: { choice: { answers: ['a'] } },
+})
+
+expect(FreshAgentServerRequestResponseSchema.parse({
+ requestId: 'mcp-elicit-1',
+ kind: 'mcp_elicitation',
+ action: 'accept',
+ content: { value: 'approved' },
+ _meta: null,
+})).toMatchObject({ kind: 'mcp_elicitation', action: 'accept' })
+
+expect(FreshAgentServerRequestResponseSchema.parse({
+ requestId: 'permissions-1',
+ kind: 'permissions_approval',
+ permissions: { filesystem: { read: true } },
+ scope: 'turn',
+ strictAutoReview: true,
+})).toMatchObject({ kind: 'permissions_approval' })
+
+expect(FreshAgentServerRequestResponseSchema.parse({
+ requestId: 'dynamic-tool-1',
+ kind: 'dynamic_tool',
+ contentItems: [{ type: 'inputText', text: 'Dynamic tool calls are not supported by Freshell yet.' }],
+ success: false,
+})).toMatchObject({ kind: 'dynamic_tool', success: false })
+
+expect(FreshAgentServerRequestResponseSchema.parse({
+ requestId: 'legacy-exec-1',
+ kind: 'legacy_exec_approval',
+ decision: 'approved',
+})).toMatchObject({ kind: 'legacy_exec_approval', decision: 'approved' })
+
+expect(FreshAgentServerRequestResponseSchema.parse({
+ requestId: 'legacy-patch-1',
+ kind: 'legacy_patch_approval',
+ decision: 'denied',
+})).toMatchObject({ kind: 'legacy_patch_approval', decision: 'denied' })
+
+expect(FreshAgentServerRequestResponseSchema.parse({
+ requestId: 42,
+ kind: 'tool_user_input',
+ answers: {
+ choice: { answers: ['a'] },
+ },
+})).toMatchObject({ requestId: 42, kind: 'tool_user_input' })
+
+expect(() => FreshAgentServerRequestResponseSchema.parse({
+ requestId: 42.5,
+ kind: 'tool_user_input',
+ answers: {
+ choice: { answers: ['a'] },
+ },
+})).toThrow(/integer/i)
+
+expect(FreshAgentModelListPageSchema.parse({
+ provider: 'codex',
+ items: [],
+ nextCursor: null,
+ providerCapabilities: {
+ namespaceTools: true,
+ imageGeneration: false,
+ webSearch: true,
+ },
+})).toMatchObject({
+ providerCapabilities: { webSearch: true },
+})
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/shared/fresh-agent-contract.test.ts \
+ test/unit/shared/fresh-agent-contract-traceability.test.ts
+```
+
+Expected: FAIL because `shared/fresh-agent-contract.ts` does not exist.
+
+- [ ] **Step 3: Implement the schemas**
+
+Create `shared/fresh-agent-contract.ts` with this shape:
+
+```ts
+import { z } from 'zod'
+
+export const FreshAgentSessionTypeSchema = z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode'])
+export const FreshAgentRuntimeProviderSchema = z.enum(['claude', 'codex', 'opencode'])
+export const FreshAgentThreadStatusSchema = z.enum(['idle', 'running', 'compacting', 'exited', 'lost', 'error'])
+export const FreshAgentRoleSchema = z.enum(['user', 'assistant', 'system'])
+export const FreshAgentTurnSourceSchema = z.enum(['durable', 'live'])
+
+export type FreshAgentSessionType = z.infer
+export type FreshAgentRuntimeProvider = z.infer
+
+const NonEmptyString = z.string().min(1)
+const FreshAgentServerRequestIdSchema = z.union([NonEmptyString, z.number().int()])
+const JsonValue: z.ZodType = z.lazy(() => z.union([
+ z.string(),
+ z.number(),
+ z.boolean(),
+ z.null(),
+ z.array(JsonValue),
+ z.record(z.string(), JsonValue),
+]))
+
+export const FreshAgentTextElementSchema = z.object({
+ byteRange: z.object({
+ start: z.number().int().nonnegative(),
+ end: z.number().int().nonnegative(),
+ }),
+ placeholder: z.string().nullable(),
+})
+
+export const FreshAgentMemoryCitationSchema = z.object({
+ entries: z.array(z.object({
+ path: z.string(),
+ lineStart: z.number().int().nonnegative(),
+ lineEnd: z.number().int().nonnegative(),
+ note: z.string(),
+ })),
+ threadIds: z.array(z.string()),
+})
+
+export const FreshAgentHookPromptFragmentSchema = z.object({
+ text: z.string(),
+ hookRunId: NonEmptyString,
+})
+
+export const FreshAgentCommandActionSchema = z.discriminatedUnion('type', [
+ z.object({ type: z.literal('read'), command: z.string(), name: z.string(), path: z.string() }),
+ z.object({ type: z.literal('listFiles'), command: z.string(), path: z.string().nullable() }),
+ z.object({ type: z.literal('search'), command: z.string(), query: z.string().nullable(), path: z.string().nullable() }),
+ z.object({ type: z.literal('unknown'), command: z.string() }),
+])
+
+export const FreshAgentDynamicToolOutputContentItemSchema = z.discriminatedUnion('type', [
+ z.object({ type: z.literal('inputText'), text: z.string() }),
+ z.object({ type: z.literal('inputImage'), imageUrl: z.string() }),
+])
+
+export const FreshAgentWebSearchActionSchema = z.discriminatedUnion('type', [
+ z.object({ type: z.literal('search'), query: z.string().nullable(), queries: z.array(z.string()).nullable() }),
+ z.object({ type: z.literal('openPage'), url: z.string().nullable() }),
+ z.object({ type: z.literal('findInPage'), url: z.string().nullable(), pattern: z.string().nullable() }),
+ z.object({ type: z.literal('other') }),
+])
+
+export const FreshAgentTextItemSchema = z.object({
+ id: NonEmptyString,
+ kind: z.literal('text'),
+ text: z.string(),
+ role: FreshAgentRoleSchema.optional(),
+})
+
+export const FreshAgentMessageContentPartSchema = z.discriminatedUnion('kind', [
+ z.object({
+ kind: z.literal('text'),
+ text: z.string(),
+ textElements: z.array(FreshAgentTextElementSchema).default([]),
+ }),
+ z.object({
+ kind: z.literal('image'),
+ url: z.string().url().optional(),
+ path: z.string().optional(),
+ data: z.string().optional(),
+ mediaType: z.string().optional(),
+ alt: z.string().optional(),
+ }).refine((value) => Boolean(value.url || value.path || value.data), {
+ message: 'image message content requires url, path, or data',
+ }),
+ z.object({
+ kind: z.literal('mention'),
+ name: NonEmptyString,
+ path: NonEmptyString,
+ }),
+ z.object({
+ kind: z.literal('skill'),
+ name: NonEmptyString,
+ path: NonEmptyString,
+ }),
+])
+
+export const FreshAgentMessageItemSchema = z.object({
+ id: NonEmptyString,
+ kind: z.literal('message'),
+ role: FreshAgentRoleSchema,
+ content: z.array(FreshAgentMessageContentPartSchema).min(1),
+ phase: z.enum(['commentary', 'final_answer']).nullable().optional(),
+ memoryCitation: FreshAgentMemoryCitationSchema.nullable().optional(),
+})
+
+export const FreshAgentReasoningItemSchema = z.object({
+ id: NonEmptyString,
+ kind: z.literal('reasoning'),
+ summary: z.array(z.string()).default([]),
+ content: z.array(z.string()).default([]),
+ text: z.string().optional(),
+})
+
+export const FreshAgentCommandItemSchema = z.object({
+ id: NonEmptyString,
+ kind: z.literal('command'),
+ command: z.string(),
+ cwd: z.string().optional(),
+ source: z.enum(['agent', 'userShell', 'unifiedExecStartup', 'unifiedExecInteraction']).optional(),
+ processId: z.string().nullable().optional(),
+ status: z.enum(['pending', 'running', 'completed', 'failed', 'declined']),
+ commandActions: z.array(FreshAgentCommandActionSchema).default([]),
+ output: z.string().optional(),
+ exitCode: z.number().int().optional(),
+ durationMs: z.number().nonnegative().optional(),
+})
+
+export const FreshAgentFileChangeItemSchema = z.object({
+ id: NonEmptyString,
+ kind: z.literal('file_change'),
+ status: z.enum(['pending', 'running', 'completed', 'failed', 'declined']),
+ changes: z.array(z.object({
+ path: NonEmptyString,
+ changeKind: z.enum(['add', 'modify', 'delete', 'rename', 'unknown']),
+ movePath: z.string().nullable().optional(),
+ diff: z.string().optional(),
+ })),
+})
+
+export const FreshAgentToolItemSchema = z.object({
+ id: NonEmptyString,
+ kind: z.literal('tool'),
+ server: z.string().optional(),
+ name: NonEmptyString,
+ status: z.enum(['pending', 'running', 'completed', 'failed']),
+ input: JsonValue.optional(),
+ mcpAppResourceUri: z.string().optional(),
+ result: JsonValue.optional(),
+ error: z.string().optional(),
+ durationMs: z.number().nonnegative().optional(),
+})
+
+export const FreshAgentTranscriptItemSchema = z.discriminatedUnion('kind', [
+ FreshAgentMessageItemSchema,
+ FreshAgentTextItemSchema,
+ FreshAgentReasoningItemSchema,
+ FreshAgentCommandItemSchema,
+ FreshAgentFileChangeItemSchema,
+ FreshAgentToolItemSchema,
+ z.object({ id: NonEmptyString, kind: z.literal('plan'), text: z.string(), status: z.enum(['pending', 'running', 'completed']).optional() }),
+ z.object({ id: NonEmptyString, kind: z.literal('review'), phase: z.enum(['entered', 'exited']), label: z.string().optional(), text: z.string().optional() }),
+ z.object({ id: NonEmptyString, kind: z.literal('web_search'), query: z.string(), action: FreshAgentWebSearchActionSchema.nullable().optional(), status: z.enum(['pending', 'running', 'completed', 'failed']).optional() }),
+ z.object({ id: NonEmptyString, kind: z.literal('hook_prompt'), fragments: z.array(FreshAgentHookPromptFragmentSchema).default([]), text: z.string().optional() }),
+ z.object({ id: NonEmptyString, kind: z.literal('image'), path: z.string().optional(), url: z.string().optional(), alt: z.string().optional() }),
+ z.object({
+ id: NonEmptyString,
+ kind: z.literal('image_generation'),
+ prompt: z.string().optional(),
+ status: z.string().optional(),
+ displayStatus: z.enum(['pending', 'running', 'completed', 'failed']).optional(),
+ result: z.string().optional(),
+ imageUrl: z.string().optional(),
+ savedPath: z.string().optional(),
+ path: z.string().optional(),
+ }),
+ z.object({
+ id: NonEmptyString,
+ kind: z.literal('collaboration'),
+ tool: NonEmptyString,
+ status: z.enum(['pending', 'running', 'completed', 'failed']),
+ senderThreadId: z.string().optional(),
+ receiverThreadIds: z.array(z.string()).default([]),
+ prompt: z.string().nullable().optional(),
+ model: z.string().nullable().optional(),
+ reasoningEffort: z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']).nullable().optional(),
+ agentsStates: z.record(z.string(), JsonValue).default({}),
+ }),
+ z.object({ id: NonEmptyString, kind: z.literal('context_compaction'), status: z.enum(['pending', 'running', 'completed', 'failed', 'deprecated']), summary: z.string().optional(), beforeTokens: z.number().int().nonnegative().optional(), afterTokens: z.number().int().nonnegative().optional() }),
+ z.object({ id: NonEmptyString, kind: z.literal('dynamic_tool'), name: NonEmptyString, namespace: z.string().nullable().optional(), status: z.enum(['pending', 'running', 'completed', 'failed', 'declined']), input: JsonValue.optional(), contentItems: z.array(FreshAgentDynamicToolOutputContentItemSchema).nullable().optional(), success: z.boolean().nullable().optional(), durationMs: z.number().nonnegative().nullable().optional(), result: JsonValue.optional(), reason: z.string().optional(), error: z.string().optional() }),
+ z.object({ id: NonEmptyString, kind: z.literal('request_prompt'), requestId: FreshAgentServerRequestIdSchema, requestKind: z.enum(['approval', 'question', 'mcp_elicitation', 'dynamic_tool', 'auth_refresh']), title: z.string().optional(), prompt: z.string().optional(), status: z.enum(['pending', 'resolved', 'declined']) }),
+ z.object({ id: NonEmptyString, kind: z.literal('error'), message: z.string(), code: z.string().optional() }),
+])
+
+export const FreshAgentTurnBodySchema = z.object({
+ sessionType: FreshAgentSessionTypeSchema,
+ provider: FreshAgentRuntimeProviderSchema,
+ threadId: NonEmptyString,
+ turnId: NonEmptyString,
+ revision: z.number().int().nonnegative(),
+ ordinal: z.number().int().nonnegative().optional(),
+ source: FreshAgentTurnSourceSchema.optional(),
+ summary: z.string().optional(),
+ startedAt: z.string().optional(),
+ completedAt: z.string().optional(),
+ role: FreshAgentRoleSchema.optional(), // legacy compatibility only; Codex role lives on message items
+ items: z.array(FreshAgentTranscriptItemSchema),
+})
+
+export const FreshAgentTurnSummarySchema = FreshAgentTurnBodySchema.omit({ items: true }).extend({
+ itemCount: z.number().int().nonnegative().default(0),
+ preview: z.string().optional(),
+ body: FreshAgentTurnBodySchema.optional(),
+})
+
+export const FreshAgentTurnPageSchema = z.object({
+ sessionType: FreshAgentSessionTypeSchema,
+ provider: FreshAgentRuntimeProviderSchema,
+ threadId: NonEmptyString,
+ revision: z.number().int().nonnegative(),
+ turns: z.array(FreshAgentTurnSummarySchema),
+ nextCursor: z.string().nullable(),
+ backwardsCursor: z.string().nullable(),
+})
+
+export const FreshAgentSessionSummarySchema = z.object({
+ sessionId: NonEmptyString,
+ sessionType: FreshAgentSessionTypeSchema,
+ provider: FreshAgentRuntimeProviderSchema,
+ runtimeProvider: FreshAgentRuntimeProviderSchema.optional(),
+ title: z.string().optional(),
+ summary: z.string().optional(),
+ cwd: z.string().optional(),
+ createdAt: z.string().optional(),
+ updatedAt: z.string().optional(),
+ source: JsonValue.optional(),
+ archived: z.boolean().optional(),
+ parentThreadId: z.string().nullable().optional(),
+})
+
+export const FreshAgentThreadListPageSchema = z.object({
+ sessionType: FreshAgentSessionTypeSchema,
+ provider: FreshAgentRuntimeProviderSchema,
+ items: z.array(FreshAgentSessionSummarySchema),
+ nextCursor: z.string().nullable(),
+ backwardsCursor: z.string().nullable(),
+})
+
+export const FreshAgentCodexReasoningEffortSchema = z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh'])
+
+export const FreshAgentModelSummarySchema = z.object({
+ id: NonEmptyString,
+ model: NonEmptyString,
+ displayName: z.string(),
+ description: z.string(),
+ hidden: z.boolean(),
+ isDefault: z.boolean(),
+ defaultReasoningEffort: FreshAgentCodexReasoningEffortSchema,
+ supportedReasoningEfforts: z.array(z.object({
+ reasoningEffort: FreshAgentCodexReasoningEffortSchema,
+ description: z.string(),
+ })).default([]),
+ inputModalities: z.array(z.enum(['text', 'image'])).default([]),
+ supportsPersonality: z.boolean().default(false),
+ additionalSpeedTiers: z.array(z.string()).default([]),
+})
+
+export const FreshAgentModelProviderCapabilitiesSchema = z.object({
+ namespaceTools: z.boolean(),
+ imageGeneration: z.boolean(),
+ webSearch: z.boolean(),
+})
+
+export const FreshAgentModelListPageSchema = z.object({
+ provider: z.literal('codex'),
+ items: z.array(FreshAgentModelSummarySchema),
+ nextCursor: z.string().nullable(),
+ providerCapabilities: FreshAgentModelProviderCapabilitiesSchema.optional(),
+})
+
+const BooleanQueryParam = z.union([
+ z.boolean(),
+ z.enum(['true', 'false']).transform((value) => value === 'true'),
+])
+
+export const FreshAgentModelListQuerySchema = z.object({
+ cursor: z.string().min(1).optional(),
+ limit: z.coerce.number().int().positive().max(200).optional(),
+ includeHidden: BooleanQueryParam.optional(),
+})
+
+export const FreshAgentTurnPageQuerySchema = z.object({
+ cursor: z.string().min(1).optional(),
+ priority: z.enum(['visible', 'background']).optional(),
+ revision: z.coerce.number().int().nonnegative(),
+ limit: z.coerce.number().int().positive().max(200).optional(),
+ sortDirection: z.enum(['asc', 'desc']).optional(),
+})
+
+export const FreshAgentCapabilitiesSchema = z.object({
+ send: z.boolean(),
+ interrupt: z.boolean(),
+ approvals: z.boolean(),
+ questions: z.boolean(),
+ fork: z.boolean(),
+ review: z.boolean(),
+ worktrees: z.boolean(),
+ diffs: z.boolean(),
+ childThreads: z.boolean(),
+ turnPaging: z.boolean(),
+ turnBodies: z.boolean(),
+})
+
+export const FreshAgentReviewTargetSchema = z.discriminatedUnion('type', [
+ z.object({ type: z.literal('uncommittedChanges') }),
+ z.object({ type: z.literal('baseBranch'), branch: NonEmptyString }),
+ z.object({ type: z.literal('commit'), sha: NonEmptyString, title: z.string().nullable().optional() }),
+ z.object({ type: z.literal('custom'), instructions: NonEmptyString }),
+])
+
+export const FreshAgentInputImageSchema = z.discriminatedUnion('kind', [
+ z.object({ kind: z.literal('url'), url: z.string().url(), mediaType: z.string().optional() }),
+ z.object({ kind: z.literal('local'), path: z.string().min(1), mediaType: z.string().optional() }),
+ z.object({ kind: z.literal('data'), data: z.string().min(1), mediaType: z.string().min(1) }),
+])
+
+export const FreshAgentLegacyClaudeEffortSchema = z.enum(['low', 'medium', 'high', 'max'])
+export const FreshAgentCodexApprovalPolicySchema = z.union([
+ z.enum(['untrusted', 'on-failure', 'on-request', 'never']),
+ z.object({
+ granular: z.object({
+ sandbox_approval: z.boolean(),
+ rules: z.boolean(),
+ skill_approval: z.boolean().optional().default(false),
+ request_permissions: z.boolean().optional().default(false),
+ mcp_elicitations: z.boolean(),
+ }),
+ }),
+])
+export const FreshAgentLegacyClaudePermissionModeSchema = z.enum(['default', 'plan', 'acceptEdits', 'bypassPermissions'])
+
+const FreshAgentRuntimeSettingsBaseSchema = z.object({
+ model: z.string().min(1).optional(),
+ sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(),
+})
+
+export const FreshAgentCodexRuntimeSettingsSchema = FreshAgentRuntimeSettingsBaseSchema.extend({
+ permissionMode: FreshAgentCodexApprovalPolicySchema.optional(),
+ effort: FreshAgentCodexReasoningEffortSchema.optional(),
+})
+
+export const FreshAgentClaudeRuntimeSettingsSchema = FreshAgentRuntimeSettingsBaseSchema.extend({
+ permissionMode: FreshAgentLegacyClaudePermissionModeSchema.optional(),
+ effort: FreshAgentLegacyClaudeEffortSchema.optional(),
+})
+
+export const FreshAgentRuntimeSettingsSchema = z.object({
+ model: z.string().min(1).optional(),
+ sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(),
+ // Historical field name retained for persisted pane/settings compatibility only.
+ // Executable actions must call parseFreshAgentRuntimeSettingsForProvider after
+ // resolving the provider so Freshcodex cannot accept Claude-only values.
+ permissionMode: z.union([FreshAgentCodexApprovalPolicySchema, FreshAgentLegacyClaudePermissionModeSchema]).optional(),
+ effort: z.union([FreshAgentCodexReasoningEffortSchema, FreshAgentLegacyClaudeEffortSchema]).optional(),
+})
+
+export function parseFreshAgentRuntimeSettingsForProvider(
+ provider: FreshAgentRuntimeProvider,
+ value: unknown | undefined,
+): FreshAgentRuntimeSettings | undefined {
+ if (value === undefined) return undefined
+ switch (provider) {
+ case 'codex':
+ return FreshAgentCodexRuntimeSettingsSchema.parse(value)
+ case 'claude':
+ return FreshAgentClaudeRuntimeSettingsSchema.parse(value)
+ case 'opencode':
+ throw new Error('Freshopencode runtime settings are not implemented')
+ }
+}
+
+export const FreshAgentThreadSnapshotSchema = z.object({
+ sessionType: FreshAgentSessionTypeSchema,
+ provider: FreshAgentRuntimeProviderSchema,
+ threadId: NonEmptyString,
+ revision: z.number().int().nonnegative(),
+ status: FreshAgentThreadStatusSchema,
+ summary: z.string().optional(),
+ capabilities: FreshAgentCapabilitiesSchema,
+ tokenUsage: z.object({
+ inputTokens: z.number().int().nonnegative(),
+ outputTokens: z.number().int().nonnegative(),
+ cachedTokens: z.number().int().nonnegative().optional(),
+ totalTokens: z.number().int().nonnegative(),
+ contextTokens: z.number().int().nonnegative().optional(),
+ compactPercent: z.number().nonnegative().optional(),
+ compactThresholdTokens: z.number().int().nonnegative().optional(),
+ }).optional(),
+ initialTurnPage: FreshAgentTurnPageSchema.optional(),
+ pendingApprovals: z.array(FreshAgentApprovalRequestSchema).default([]),
+ pendingQuestions: z.array(FreshAgentQuestionRequestSchema).default([]),
+ worktrees: z.array(FreshAgentWorktreeRefSchema).default([]),
+ diffs: z.array(FreshAgentDiffRefSchema).default([]),
+ childThreads: z.array(FreshAgentChildThreadRefSchema).default([]),
+ extensions: FreshAgentExtensionsSchema.default({}),
+})
+```
+
+Define the referenced approval, question, worktree, diff, child-thread, Claude extension, Codex extension, and action-result schemas in the same file. In the actual implementation file, place these definitions before `FreshAgentThreadSnapshotSchema` or use `z.lazy` for any recursive reference. Do not leave these as implicit shapes for the executor to invent; they are part of the shared contract boundary and must be parsed by server, client API, Redux, and UI tests.
+
+```ts
+export const FreshAgentApprovalRequestSchema = z.object({
+ requestId: FreshAgentServerRequestIdSchema,
+ kind: z.enum(['command', 'file_change', 'permissions', 'legacy_exec', 'legacy_patch']),
+ title: z.string(),
+ prompt: z.string().optional(),
+ threadId: z.string().optional(),
+ turnId: z.string().nullable().optional(),
+ itemId: z.string().nullable().optional(),
+ command: z.string().optional(),
+ cwd: z.string().optional(),
+ fileChanges: z.array(z.object({
+ path: NonEmptyString,
+ changeKind: z.enum(['add', 'modify', 'delete', 'rename', 'unknown']),
+ movePath: z.string().nullable().optional(),
+ diff: z.string().optional(),
+ })).default([]),
+ permissions: z.object({
+ network: JsonValue.optional(),
+ fileSystem: JsonValue.optional(),
+ }).optional(),
+ reason: z.string().nullable().optional(),
+ status: z.enum(['pending', 'resolved', 'declined']).default('pending'),
+})
+
+export const FreshAgentQuestionFieldSchema = z.object({
+ id: NonEmptyString,
+ label: z.string(),
+ prompt: z.string().optional(),
+ type: z.enum(['text', 'single_select', 'multi_select', 'boolean', 'number', 'json']).default('text'),
+ options: z.array(z.object({
+ value: z.string(),
+ label: z.string(),
+ description: z.string().optional(),
+ })).default([]),
+ required: z.boolean().default(false),
+ schema: JsonValue.optional(),
+})
+
+export const FreshAgentQuestionRequestSchema = z.object({
+ requestId: FreshAgentServerRequestIdSchema,
+ kind: z.enum(['tool_user_input', 'mcp_elicitation', 'auth_refresh']),
+ title: z.string(),
+ prompt: z.string().optional(),
+ threadId: z.string().optional(),
+ turnId: z.string().nullable().optional(),
+ itemId: z.string().nullable().optional(),
+ fields: z.array(FreshAgentQuestionFieldSchema).default([]),
+ status: z.enum(['pending', 'resolved', 'declined']).default('pending'),
+})
+
+export const FreshAgentWorktreeRefSchema = z.object({
+ id: NonEmptyString,
+ path: NonEmptyString,
+ cwd: z.string().optional(),
+ branch: z.string().nullable().optional(),
+ baseBranch: z.string().nullable().optional(),
+ headSha: z.string().nullable().optional(),
+ dirty: z.boolean().optional(),
+ active: z.boolean().default(false),
+})
+
+export const FreshAgentDiffFileSchema = z.object({
+ path: NonEmptyString,
+ changeKind: z.enum(['add', 'modify', 'delete', 'rename', 'unknown']),
+ movePath: z.string().nullable().optional(),
+ additions: z.number().int().nonnegative().optional(),
+ deletions: z.number().int().nonnegative().optional(),
+ diff: z.string().optional(),
+})
+
+export const FreshAgentDiffRefSchema = z.object({
+ id: NonEmptyString,
+ title: z.string(),
+ status: z.enum(['pending', 'running', 'completed', 'failed']).optional(),
+ files: z.array(FreshAgentDiffFileSchema).default([]),
+ reviewThreadId: z.string().nullable().optional(),
+ summary: z.string().optional(),
+})
+
+export const FreshAgentChildThreadRefSchema = z.object({
+ threadId: NonEmptyString,
+ parentThreadId: z.string().nullable().optional(),
+ title: z.string().optional(),
+ status: FreshAgentThreadStatusSchema.optional(),
+ source: JsonValue.optional(),
+ depth: z.number().int().nonnegative().optional(),
+ agentNickname: z.string().nullable().optional(),
+ agentRole: z.string().nullable().optional(),
+ model: z.string().nullable().optional(),
+ reasoningEffort: FreshAgentCodexReasoningEffortSchema.nullable().optional(),
+})
+
+export const FreshAgentCodexExtensionSchema = z.object({
+ model: z.string().nullable().optional(),
+ modelProvider: z.string().nullable().optional(),
+ serviceTier: z.enum(['fast', 'flex']).nullable().optional(),
+ cwd: z.string().nullable().optional(),
+ cliVersion: z.string().nullable().optional(),
+ source: JsonValue.optional(),
+ gitInfo: JsonValue.nullable().optional(),
+ forkedFromId: z.string().nullable().optional(),
+ activeFlags: z.array(z.enum(['waitingOnApproval', 'waitingOnUserInput'])).default([]),
+ approvalsReviewer: z.enum(['user', 'auto_review', 'guardian_subagent']).nullable().optional(),
+ sandbox: JsonValue.optional(),
+ approvalPolicy: JsonValue.optional(),
+ review: z.object({
+ reviewThreadId: z.string().nullable().optional(),
+ target: FreshAgentReviewTargetSchema.optional(),
+ delivery: z.enum(['inline', 'detached']).optional(),
+ status: z.enum(['pending', 'running', 'completed', 'failed']).optional(),
+ summary: z.string().optional(),
+ }).optional(),
+})
+
+export const FreshAgentClaudeExtensionSchema = z.object({
+ sessionFile: z.string().nullable().optional(),
+ projectPath: z.string().nullable().optional(),
+ permissionMode: FreshAgentLegacyClaudePermissionModeSchema.optional(),
+ effort: FreshAgentLegacyClaudeEffortSchema.optional(),
+})
+
+export const FreshAgentExtensionsSchema = z.object({
+ codex: FreshAgentCodexExtensionSchema.optional(),
+ claude: FreshAgentClaudeExtensionSchema.optional(),
+})
+
+export const FreshAgentActionResultSchema = z.discriminatedUnion('type', [
+ z.object({ type: z.literal('send'), sessionId: NonEmptyString, turnId: z.string().optional() }),
+ z.object({ type: z.literal('interrupt'), sessionId: NonEmptyString, interrupted: z.boolean() }),
+ z.object({ type: z.literal('fork'), sourceSessionId: NonEmptyString, session: FreshAgentSessionSummarySchema, parentThreadId: z.string().nullable().optional() }),
+ z.object({ type: z.literal('review_start'), sessionId: NonEmptyString, turnId: NonEmptyString, reviewThreadId: NonEmptyString, target: FreshAgentReviewTargetSchema, delivery: z.enum(['inline', 'detached']) }),
+ z.object({ type: z.literal('server_request_response'), sessionId: NonEmptyString, requestId: FreshAgentServerRequestIdSchema, responseKind: z.string() }),
+ z.object({ type: z.literal('kill'), sessionId: NonEmptyString, success: z.boolean() }),
+ z.object({ type: z.literal('error'), code: NonEmptyString, message: z.string(), retryable: z.boolean().optional() }),
+])
+```
+
+Export inferred types for every schema. Keep provider extension schemas typed and narrow.
+
+Also define a generated-shape-preserving response schema for pending server requests. This schema is the shared surface used by WebSocket actions, controller props, and the Codex adapter when it responds on the original JSON-RPC server request id:
+
+```ts
+export const FreshAgentToolUserInputAnswerSchema = z.object({
+ answers: z.array(z.string()),
+})
+
+export const FreshAgentMcpElicitationActionSchema = z.enum(['accept', 'decline', 'cancel'])
+
+export const FreshAgentLegacyCodexReviewDecisionSchema = z.union([
+ z.enum(['approved', 'approved_for_session', 'denied', 'timed_out', 'abort']),
+ z.object({
+ approved_execpolicy_amendment: z.object({
+ proposed_execpolicy_amendment: z.record(z.string(), JsonValue),
+ }),
+ }),
+ z.object({
+ network_policy_amendment: z.object({
+ network_policy_amendment: z.record(z.string(), JsonValue),
+ }),
+ }),
+])
+
+export const FreshAgentServerRequestResponseSchema = z.discriminatedUnion('kind', [
+ z.object({
+ requestId: FreshAgentServerRequestIdSchema,
+ kind: z.literal('command_approval'),
+ decision: z.union([
+ z.enum(['accept', 'acceptForSession', 'decline', 'cancel']),
+ z.record(z.string(), JsonValue),
+ ]),
+ }),
+ z.object({
+ requestId: FreshAgentServerRequestIdSchema,
+ kind: z.literal('file_change_approval'),
+ decision: z.enum(['accept', 'acceptForSession', 'decline', 'cancel']),
+ }),
+ z.object({
+ requestId: FreshAgentServerRequestIdSchema,
+ kind: z.literal('permissions_approval'),
+ permissions: z.record(z.string(), JsonValue),
+ scope: z.enum(['turn', 'session']),
+ strictAutoReview: z.boolean().optional(),
+ }),
+ z.object({
+ requestId: FreshAgentServerRequestIdSchema,
+ kind: z.literal('tool_user_input'),
+ answers: z.record(z.string(), FreshAgentToolUserInputAnswerSchema),
+ }),
+ z.object({
+ requestId: FreshAgentServerRequestIdSchema,
+ kind: z.literal('mcp_elicitation'),
+ action: FreshAgentMcpElicitationActionSchema,
+ content: JsonValue.nullable(),
+ _meta: JsonValue.nullable(),
+ }),
+ z.object({
+ requestId: FreshAgentServerRequestIdSchema,
+ kind: z.literal('dynamic_tool'),
+ contentItems: z.array(FreshAgentDynamicToolOutputContentItemSchema),
+ success: z.boolean(),
+ }),
+ z.object({
+ requestId: FreshAgentServerRequestIdSchema,
+ kind: z.literal('legacy_exec_approval'),
+ decision: FreshAgentLegacyCodexReviewDecisionSchema,
+ }),
+ z.object({
+ requestId: FreshAgentServerRequestIdSchema,
+ kind: z.literal('legacy_patch_approval'),
+ decision: FreshAgentLegacyCodexReviewDecisionSchema,
+ }),
+])
+```
+
+The implementation may narrow `permissions`, `scope`, MCP `content`, dynamic-tool output content, and legacy review-decision amendment payloads further when the Codex generated response schemas are modeled in `server/coding-cli/codex-app-server/protocol.ts`, but the shared action contract must not reduce them to strings or Claude-style answers. Even when Freshcodex auto-declines unsupported dynamic tool calls without user input, the response shape must stay contract-modeled so tests can prove the app-server turn is unblocked with the generated `DynamicToolCallResponse` envelope. Legacy root `applyPatchApproval` and `execCommandApproval` must likewise stay distinguishable from v2 command/file approvals because their generated decision enum uses root `ReviewDecision`, not v2 `accept` / `decline` values.
+
+Define referenced schemas before any schema that uses them, or wrap recursive references in `z.lazy`, so module evaluation cannot hit a temporal-dead-zone `ReferenceError`.
+
+```ts
+export type FreshAgentThreadSnapshot = z.infer
+export type FreshAgentTurnPage = z.infer
+export type FreshAgentTurnBody = z.infer
+export type FreshAgentTranscriptItem = z.infer
+export type FreshAgentThreadListPage = z.infer
+export type FreshAgentModelSummary = z.infer
+export type FreshAgentModelProviderCapabilities = z.infer
+export type FreshAgentModelListPage = z.infer
+export type FreshAgentModelListQuery = z.infer
+export type FreshAgentCodexRuntimeSettings = z.infer
+export type FreshAgentClaudeRuntimeSettings = z.infer
+export type FreshAgentRuntimeSettings = z.infer
+export type FreshAgentServerRequestId = z.infer
+export type FreshAgentServerRequestResponse = z.infer
+```
+
+Create `test/fixtures/fresh-agent/contract-traceability.ts` with a small, explicit data shape so completeness is testable:
+
+```ts
+export type FreshAgentContractTraceabilityEntry = {
+ schemaName: string
+ producerBoundary: string
+ serverParser: string
+ clientParser: string
+ stateOwner: string
+ persistenceOwner: string | null
+ uiConsumer: string
+ fixtureOwner: string
+ testOwner: string
+ notes?: string
+}
+
+export const freshAgentContractTraceability: FreshAgentContractTraceabilityEntry[] = [
+ {
+ schemaName: 'FreshAgentThreadSnapshotSchema',
+ producerBoundary: 'server/fresh-agent/runtime-manager.ts',
+ serverParser: 'FreshAgentThreadSnapshotSchema.parse',
+ clientParser: 'src/lib/api.ts parseFreshAgentThreadSnapshot',
+ stateOwner: 'src/store/freshAgentSlice.ts',
+ persistenceOwner: null,
+ uiConsumer: 'src/components/fresh-agent/useFreshAgentThreadController.ts',
+ fixtureOwner: 'test/fixtures/fresh-agent/codex/contract-fixtures.ts',
+ testOwner: 'test/unit/shared/fresh-agent-contract.test.ts',
+ },
+ // Replace this comment with one entry for every exported durable FreshAgent*Schema before committing.
+]
+```
+
+The test should compute exported durable schema names from `shared/fresh-agent-contract.ts` text and compare them to `freshAgentContractTraceability.map((entry) => entry.schemaName)`. Exclude private helper schemas that are not exported, but do not exclude exported schemas merely because only one provider currently uses them. The placeholder comment above must not survive the task; the traceability test must fail if any entry field is blank or if any exported durable schema lacks an entry.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/shared/fresh-agent-contract.test.ts \
+ test/unit/shared/fresh-agent-contract-traceability.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Refactor and verify**
+
+Move any duplicate provider/session enum literals from `shared/fresh-agent.ts` into imports from `shared/fresh-agent-contract.ts` where that reduces duplication without creating circular imports.
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/shared/fresh-agent-contract.test.ts \
+ test/unit/shared/fresh-agent-contract-traceability.test.ts \
+ test/unit/shared/fresh-agent-registry.test.ts
+npm run typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add \
+ shared/fresh-agent-contract.ts shared/fresh-agent.ts shared/read-models.ts \
+ test/unit/shared/fresh-agent-contract.test.ts \
+ test/unit/shared/fresh-agent-contract-traceability.test.ts \
+ test/fixtures/fresh-agent/codex/contract-fixtures.ts \
+ test/fixtures/fresh-agent/claude/contract-fixtures.ts \
+ test/fixtures/fresh-agent/contract-traceability.ts
+git commit -m "Add strict fresh-agent read-model contracts"
+```
+
+### Task 3: Enforce Contracts At Server And Client Boundaries
+
+**Files:**
+- Modify: `server/index.ts`
+- Modify: `server/fresh-agent/runtime-adapter.ts`
+- Modify: `server/fresh-agent/provider-registry.ts`
+- Modify: `server/fresh-agent/runtime-manager.ts`
+- Modify: `server/fresh-agent/router.ts`
+- Modify: `server/fresh-agent/adapters/claude/normalize.ts`
+- Modify: `server/fresh-agent/adapters/codex/normalize.ts`
+- Modify: `src/lib/api.ts`
+- Modify: `src/lib/fresh-agent-ws.ts`
+- Modify: `src/store/freshAgentSlice.ts`
+- Modify: `src/store/freshAgentThunks.ts`
+- Modify: `src/store/freshAgentTypes.ts`
+- Modify: `src/lib/pane-activity.ts`
+- Create: `src/lib/fresh-agent-api-error.ts`
+- Test: `test/unit/server/fresh-agent/contract-boundary.test.ts`
+- Test: `test/unit/server/fresh-agent/provider-registry.test.ts`
+- Test: `test/unit/server/fresh-agent/router.test.ts`
+- Test: `test/unit/server/fresh-agent/runtime-manager.test.ts`
+- Test: `test/unit/client/lib/api.fresh-agent-contract.test.ts`
+- Test: `test/unit/client/store/freshAgentSlice.test.ts`
+- Test: `test/unit/client/lib/pane-activity.test.ts`
+- Test: `test/unit/server/fresh-agent/claude-normalize.test.ts`
+- Test: `test/unit/server/fresh-agent/claude-adapter.test.ts`
+
+- [ ] **Step 1: Write failing boundary tests**
+
+Add tests for these cases:
+
+```ts
+it('rejects invalid adapter snapshots with a clear contract error', async () => {
+ const manager = new FreshAgentRuntimeManager({ registry: registryReturningInvalidSnapshot })
+ await expect(manager.getSnapshot({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-1' }))
+ .rejects.toMatchObject({ code: 'FRESH_AGENT_CONTRACT_INVALID' })
+})
+
+it('returns 502 when adapter output violates the fresh-agent contract', async () => {
+ const response = await request(app).get('/api/fresh-agent/threads/freshcodex/codex/thread-1')
+ expect(response.status).toBe(502)
+ expect(response.body.code).toBe('FRESH_AGENT_CONTRACT_INVALID')
+})
+
+it('does not expose provider-only fresh-agent thread routes', async () => {
+ const response = await request(app).get('/api/fresh-agent/threads/codex/thread-1')
+ expect([400, 404]).toContain(response.status)
+})
+
+it('surfaces a controlled client load error for invalid snapshot payloads', async () => {
+ mockFetchJson({ provider: 'codex', status: 'creating' })
+ await expect(getFreshAgentThreadSnapshot('freshcodex', 'codex', 'thread-1'))
+ .rejects.toMatchObject({ code: 'FRESH_AGENT_CONTRACT_INVALID' })
+})
+
+it('keeps session-type identity separate from runtime adapter lookup', () => {
+ const registry = createFreshAgentProviderRegistry({
+ sessionTypes: [
+ { sessionType: 'freshclaude', runtimeProvider: 'claude', label: 'Freshclaude' },
+ { sessionType: 'kilroy', runtimeProvider: 'claude', label: 'Kilroy', hidden: true },
+ { sessionType: 'freshcodex', runtimeProvider: 'codex', label: 'Freshcodex' },
+ ],
+ runtimeAdapters: [
+ { runtimeProvider: 'claude', adapter: claudeAdapter },
+ { runtimeProvider: 'codex', adapter: codexAdapter },
+ ],
+ })
+ expect(registry.resolveBySessionType('freshclaude')?.adapter).toBe(claudeAdapter)
+ expect(registry.resolveBySessionType('kilroy')?.adapter).toBe(claudeAdapter)
+ expect(registry.resolveByRuntimeProvider('claude')?.adapter).toBe(claudeAdapter)
+})
+
+it('freshAgentSlice is independent from legacy agentChatSlice', () => {
+ expect(freshAgentReducer).not.toBe(agentChatReducer)
+ const state = freshAgentReducer(undefined, freshAgentSnapshotReceived(validCodexSnapshot))
+ const key = makeFreshAgentSessionKey({
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ sessionId: 'thread-codex-1',
+ })
+ expect(state.sessions[key]).toMatchObject({
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ })
+})
+
+it('freshAgentSlice keeps colliding opaque ids separate by full locator', () => {
+ const codexSnapshot = { ...validCodexSnapshot, sessionType: 'freshcodex', provider: 'codex', threadId: 'shared-thread-id' }
+ const claudeSnapshot = { ...validClaudeSnapshot, sessionType: 'freshclaude', provider: 'claude', threadId: 'shared-thread-id' }
+ let state = freshAgentReducer(undefined, freshAgentSnapshotReceived(codexSnapshot))
+ state = freshAgentReducer(state, freshAgentSnapshotReceived(claudeSnapshot))
+ expect(Object.keys(state.sessions)).toEqual(expect.arrayContaining([
+ 'freshcodex:codex:shared-thread-id',
+ 'freshclaude:claude:shared-thread-id',
+ ]))
+})
+
+it('freshAgentThunks and activity projection do not read fresh-agent state through agent-chat bridges', () => {
+ expect(String(loadFreshAgentTurnBody.typePrefix)).toMatch(/^freshAgent\//)
+ const activity = resolvePaneActivity({
+ paneId: 'pane-1',
+ content: { kind: 'fresh-agent', sessionType: 'freshcodex', provider: 'codex', sessionId: 'thread-1', createRequestId: 'req-1', status: 'running' },
+ isOnlyPane: true,
+ codexActivityByTerminalId: {},
+ opencodeActivityByTerminalId: {},
+ paneRuntimeActivityByPaneId: {},
+ agentChatSessions: {},
+ freshAgentSessions: {
+ [makeFreshAgentSessionKey({ sessionType: 'freshcodex', provider: 'codex', sessionId: 'thread-1' })]: {
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ status: 'running',
+ },
+ },
+ })
+ expect(activity).toEqual({ isBusy: true, source: 'fresh-agent' })
+})
+
+it('routes fresh-agent actions by the full session locator instead of bare session id', async () => {
+ const manager = new FreshAgentRuntimeManager({ registry })
+ manager.attach({ sessionType: 'freshclaude', provider: 'claude', sessionId: 'shared-thread-id' })
+ manager.attach({ sessionType: 'freshcodex', provider: 'codex', sessionId: 'shared-thread-id' })
+ await manager.send({
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ sessionId: 'shared-thread-id',
+ }, { text: 'Route to Codex' })
+ expect(codexAdapter.send).toHaveBeenCalledWith('shared-thread-id', expect.objectContaining({ text: 'Route to Codex' }))
+ expect(claudeAdapter.send).not.toHaveBeenCalled()
+})
+
+it('rejects action messages whose locator does not match the attached session record', async () => {
+ const manager = new FreshAgentRuntimeManager({ registry })
+ manager.attach({ sessionType: 'freshcodex', provider: 'codex', sessionId: 'thread-1' })
+ await expect(manager.send({
+ sessionType: 'freshclaude',
+ provider: 'claude',
+ sessionId: 'thread-1',
+ }, { text: 'Wrong runtime' })).rejects.toMatchObject({ code: 'FRESH_AGENT_SESSION_LOCATOR_MISMATCH' })
+})
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/server/fresh-agent/contract-boundary.test.ts \
+ test/unit/server/fresh-agent/provider-registry.test.ts \
+ test/unit/server/fresh-agent/runtime-manager.test.ts \
+ test/unit/server/fresh-agent/router.test.ts \
+ test/unit/client/lib/api.fresh-agent-contract.test.ts \
+ test/unit/client/store/freshAgentSlice.test.ts \
+ test/unit/client/lib/pane-activity.test.ts \
+ test/unit/server/fresh-agent/claude-normalize.test.ts \
+ test/unit/server/fresh-agent/claude-adapter.test.ts
+```
+
+Expected: FAIL because boundary parsing is not implemented, client helpers return `any`, provider lookup still conflates session type with runtime provider, fresh-agent state/thunks are still legacy agent-chat aliases, and fresh-agent pane activity still reads through `agentChatSessions`.
+
+- [ ] **Step 3: Implement boundary parsing**
+
+In `server/fresh-agent/runtime-adapter.ts`, replace `unknown` read-model returns:
+
+```ts
+import type {
+ FreshAgentThreadSnapshot,
+ FreshAgentTurnBody,
+ FreshAgentTurnPage,
+} from '../../shared/fresh-agent-contract.js'
+
+getSnapshot?(thread: FreshAgentThreadLocator, revision?: number): Promise
+getTurnPage?(thread: FreshAgentThreadLocator, query: FreshAgentTurnPageQuery): Promise
+getTurnBody?(thread: FreshAgentThreadLocator & { turnId: string }, revision: number): Promise
+```
+
+`FreshAgentThreadLocator` should be `{ sessionType: FreshAgentSessionType; provider: FreshAgentRuntimeProvider; threadId: string }`, not provider/thread id only.
+
+In `server/fresh-agent/provider-registry.ts`, split the registry inputs:
+
+```ts
+type FreshAgentSessionTypeRegistration = {
+ sessionType: FreshAgentSessionType
+ runtimeProvider: FreshAgentRuntimeProvider
+ label: string
+ hidden?: boolean
+ disabled?: boolean
+}
+
+type FreshAgentRuntimeAdapterRegistration = {
+ runtimeProvider: FreshAgentRuntimeProvider
+ adapter: FreshAgentRuntimeAdapter
+}
+```
+
+`resolveBySessionType(sessionType)` should return the matching session descriptor plus the adapter registered for that descriptor's runtime provider. `resolveByRuntimeProvider(provider)` should return the adapter registered for that provider without depending on whichever session type was registered last. Add an invariant test that `freshclaude` and `kilroy` both resolve to the Claude adapter and cannot overwrite each other.
+
+Update every registry construction site in the same task. In `server/index.ts`, replace the current array of combined registrations with separate session descriptors and runtime adapters:
+
+```ts
+const freshAgentRuntimeManager = new FreshAgentRuntimeManager({
+ registry: createFreshAgentProviderRegistry({
+ sessionTypes: [
+ { sessionType: 'freshclaude', runtimeProvider: 'claude', label: 'Freshclaude' },
+ { sessionType: 'kilroy', runtimeProvider: 'claude', label: 'Kilroy', hidden: true },
+ { sessionType: 'freshcodex', runtimeProvider: 'codex', label: 'Freshcodex' },
+ ],
+ runtimeAdapters: [
+ { runtimeProvider: 'claude', adapter: claudeFreshAgentAdapter },
+ { runtimeProvider: 'codex', adapter: codexFreshAgentAdapter },
+ ],
+ }),
+})
+```
+
+Update existing runtime-manager tests that construct the registry so Task 3 remains typecheckable on its own. Do not add a legacy overload that accepts the old combined array; that would keep the ambiguous many-session-to-one-provider model alive.
+
+In a shared fresh-agent locator module or in `shared/fresh-agent-contract.ts`, export the canonical locator key helper so server runtime state, Redux state, pane activity, and tests cannot drift:
+
+```ts
+export type FreshAgentSessionLocator = {
+ sessionType: FreshAgentSessionType
+ provider: FreshAgentRuntimeProvider
+ sessionId: string
+}
+
+export type FreshAgentSessionKey = `${FreshAgentSessionType}:${FreshAgentRuntimeProvider}:${string}`
+
+export function makeFreshAgentSessionKey(locator: FreshAgentSessionLocator): FreshAgentSessionKey {
+ return `${locator.sessionType}:${locator.provider}:${locator.sessionId}`
+}
+```
+
+In `runtime-manager.ts`, import that helper and add:
+
+```ts
+
+export class FreshAgentSessionLocatorMismatchError extends Error {
+ readonly code = 'FRESH_AGENT_SESSION_LOCATOR_MISMATCH' as const
+}
+
+export class FreshAgentContractValidationError extends Error {
+ readonly code = 'FRESH_AGENT_CONTRACT_INVALID' as const
+ constructor(readonly surface: string, readonly issues: unknown) {
+ super(`Fresh-agent ${surface} violated the shared contract`)
+ }
+}
+
+function parseSnapshot(value: unknown): FreshAgentThreadSnapshot {
+ const parsed = FreshAgentThreadSnapshotSchema.safeParse(value)
+ if (!parsed.success) throw new FreshAgentContractValidationError('snapshot', parsed.error.issues)
+ return parsed.data
+}
+```
+
+Use `FreshAgentSessionLocator` for `attach`, `subscribe`, `send`, `interrupt`, `kill`, `fork`, `respondToServerRequest`, and `startReview` routing. Internally store sessions by `makeFreshAgentSessionKey(locator)`, and when an action arrives for a bare `sessionId` from older compatibility paths, resolve it only if exactly one tracked session has that id; otherwise throw `FreshAgentSessionLocatorMismatchError` with a clear error requiring `sessionType` and `provider`. Parse snapshot, page, body, fork/action responses before returning them.
+
+In `router.ts`, map `FreshAgentContractValidationError` to HTTP 502:
+
+```ts
+return res.status(502).json({
+ error: error.message,
+ code: error.code,
+ details: error.issues,
+})
+```
+
+In `src/lib/api.ts`, parse fresh-agent helpers with the schemas and throw `FreshAgentApiPayloadError` from `src/lib/fresh-agent-api-error.ts` when parsing fails. Update helper signatures and REST paths to carry session identity explicitly and to use the canonical locator route `/api/fresh-agent/threads/:sessionType/:provider/:threadId`:
+
+```ts
+getFreshAgentThreadSnapshot(sessionType, provider, threadId, options): Promise
+getFreshAgentTurnPage(sessionType, provider, threadId, query): Promise
+getFreshAgentTurnBody(sessionType, provider, threadId, turnId, revision): Promise
+```
+
+The router must accept `sessionType` in the request path, validate it with `FreshAgentSessionTypeSchema`, and pass it to the runtime manager. Do not reconstruct `sessionType` from `provider`, and do not leave a provider-only fresh-agent thread route active except as an explicit temporary backwards-compatibility redirect that rejects ambiguous shared-provider cases and is removed before final verification.
+The fresh-agent turn-page REST query should use `FreshAgentTurnPageQuerySchema` from `shared/fresh-agent-contract.ts`, not the legacy `AgentTimelinePageQuerySchema`, because Freshcodex needs `sortDirection` for newest-first pages and must not expose `includeBodies` as a Codex app-server parameter. The router may keep an `includeBodies` compatibility branch only for non-Codex providers that still need it, but Freshcodex requests should use `sortDirection` plus bounded `limit`, and the Codex adapter must not forward Freshell-only `revision`, `priority`, or `includeBodies` fields to `thread/turns/list`.
+
+Replace `src/store/freshAgentSlice.ts`, `src/store/freshAgentTypes.ts`, and `src/store/freshAgentThunks.ts` with independent fresh-agent reducer, contract-shaped types, and thunk type prefixes. Store sessions by `FreshAgentSessionKey`, not by bare `sessionId`; each session value should still retain `sessionId`, `sessionType`, and `provider` for rendering and debugging. Keep action names fresh-agent-specific, for example `freshAgentCreateRegistered`, `freshAgentCreateFailed`, `freshAgentSnapshotReceived`, `freshAgentEventReceived`, and `freshAgentSessionLost`. `src/lib/fresh-agent-ws.ts` should dispatch these actions directly and should not import `agentChatSlice` actions.
+
+Update `src/lib/pane-activity.ts` so `agent-chat` panes continue to use `agentChatSessions`, while `fresh-agent` panes use the new fresh-agent session state by computing `makeFreshAgentSessionKey({ sessionType, provider, sessionId })` from pane content. `resolvePaneActivity`, `getBusyPaneIdsForTab`, and `collectBusySessionKeys` should accept `freshAgentSessions` separately from `agentChatSessions`; do not keep the current behavior where a Freshcodex pane has to appear in `agentChat.sessions` before activity, busy badges, or session keys work, and do not look up Freshcodex activity by bare `sessionId`.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/server/fresh-agent/contract-boundary.test.ts \
+ test/unit/server/fresh-agent/provider-registry.test.ts \
+ test/unit/server/fresh-agent/runtime-manager.test.ts \
+ test/unit/server/fresh-agent/router.test.ts \
+ test/unit/client/lib/api.fresh-agent-contract.test.ts \
+ test/unit/client/store/freshAgentSlice.test.ts \
+ test/unit/client/lib/pane-activity.test.ts \
+ test/unit/server/fresh-agent/claude-normalize.test.ts \
+ test/unit/server/fresh-agent/claude-adapter.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Refactor and verify**
+
+Remove duplicate local `FreshAgentSnapshot` types from client code only after Task 6 has shell/controller types in place. For now, ensure `api.ts` returns the exported contract types:
+
+```ts
+export async function getFreshAgentThreadSnapshot(...): Promise
+export async function getFreshAgentTurnPage(...): Promise
+export async function getFreshAgentTurnBody(...): Promise
+```
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/server/fresh-agent/contract-boundary.test.ts \
+ test/unit/server/fresh-agent/provider-registry.test.ts \
+ test/unit/server/fresh-agent/runtime-manager.test.ts \
+ test/unit/server/fresh-agent/router.test.ts \
+ test/unit/client/lib/api.fresh-agent-contract.test.ts \
+ test/unit/client/store/freshAgentSlice.test.ts \
+ test/unit/client/lib/pane-activity.test.ts \
+ test/unit/server/fresh-agent/claude-normalize.test.ts \
+ test/unit/server/fresh-agent/claude-adapter.test.ts \
+ test/unit/client/components/fresh-agent/FreshAgentView.test.tsx
+npm run typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add \
+ server/index.ts \
+ server/fresh-agent/runtime-adapter.ts server/fresh-agent/provider-registry.ts \
+ server/fresh-agent/runtime-manager.ts \
+ server/fresh-agent/router.ts server/fresh-agent/adapters/claude/normalize.ts \
+ server/fresh-agent/adapters/codex/normalize.ts src/lib/api.ts \
+ src/lib/fresh-agent-ws.ts src/store/freshAgentSlice.ts src/store/freshAgentThunks.ts \
+ src/store/freshAgentTypes.ts src/lib/pane-activity.ts \
+ src/lib/fresh-agent-api-error.ts \
+ test/unit/server/fresh-agent/contract-boundary.test.ts \
+ test/unit/server/fresh-agent/provider-registry.test.ts \
+ test/unit/server/fresh-agent/runtime-manager.test.ts \
+ test/unit/server/fresh-agent/router.test.ts \
+ test/unit/client/lib/api.fresh-agent-contract.test.ts \
+ test/unit/client/store/freshAgentSlice.test.ts \
+ test/unit/client/lib/pane-activity.test.ts \
+ test/unit/server/fresh-agent/claude-normalize.test.ts \
+ test/unit/server/fresh-agent/claude-adapter.test.ts
+git commit -m "Validate fresh-agent payloads at runtime boundaries"
+```
+
+### Task 4: Bring Codex App-Server Protocol Support Up To Freshcodex Needs
+
+**Files:**
+- Modify: `server/index.ts`
+- Modify: `server/coding-cli/codex-app-server/protocol.ts`
+- Modify: `server/coding-cli/codex-app-server/client.ts`
+- Modify: `server/coding-cli/codex-app-server/runtime.ts`
+- Modify: `server/coding-cli/codex-app-server/launch-planner.ts`
+- Create: `server/coding-cli/codex-app-server/transport.ts`
+- Create: `server/coding-cli/codex-app-server/rich-runtime.ts`
+- Modify: `server/fresh-agent/adapters/codex/adapter.ts`
+- Modify: `package.json`
+- Create: `scripts/audit-codex-app-server-schema.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ClientRequest.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ServerRequest.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ServerNotification.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/RequestId.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/RequestId.json`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCRequest.json`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCResponse.json`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCError.json`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCNotification.json`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCMessage.json`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/ApplyPatchApprovalParams.json`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/ApplyPatchApprovalResponse.json`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/ExecCommandApprovalParams.json`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/ExecCommandApprovalResponse.json`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/v2/ThreadReadResponse.json`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/v2/ThreadStartResponse.json`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/v2/ModelListResponse.json`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ReasoningEffort.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/InputModality.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/SubAgentSource.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/InitializeParams.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/InitializeResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ApplyPatchApprovalParams.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ApplyPatchApprovalResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ExecCommandApprovalParams.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ExecCommandApprovalResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ReviewDecision.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CommandExecutionRequestApprovalResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/FileChangeRequestApprovalResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/PermissionsRequestApprovalResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ToolRequestUserInputResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/McpServerElicitationRequestResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/DynamicToolCallParams.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/DynamicToolCallResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ChatgptAuthTokensRefreshResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/AskForApproval.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/SandboxMode.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/SandboxPolicy.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/NetworkAccess.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/Thread.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/Turn.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnError.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadItem.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/UserInput.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TextElement.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ByteRange.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/HookPromptFragment.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/MessagePhase.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/MemoryCitation.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/MemoryCitationEntry.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CommandAction.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CommandExecutionSource.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/McpToolCallResult.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/McpToolCallError.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/DynamicToolCallOutputContentItem.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/WebSearchAction.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadStatus.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadActiveFlag.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnStatus.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CommandExecutionStatus.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/PatchApplyStatus.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/PatchChangeKind.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/McpToolCallStatus.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/DynamicToolCallStatus.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CollabAgentToolCallStatus.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/SessionSource.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadStartParams.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadStartSource.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadStartResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadResumeParams.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadResumeResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadListParams.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadListResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadSourceKind.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadSortKey.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/SortDirection.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadLoadedListParams.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadLoadedListResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadReadParams.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadReadResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadTurnsListParams.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadTurnsListResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnStartParams.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnStartResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnInterruptParams.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnInterruptResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ReviewStartParams.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ReviewStartResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/Model.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ReasoningEffortOption.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ModelListParams.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ModelListResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ModelProviderCapabilitiesReadParams.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ModelProviderCapabilitiesReadResponse.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/schema-inventory.ts`
+- Create: `test/fixtures/coding-cli/codex-app-server/schema-traceability.ts`
+- Modify: `test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs`
+- Test: `test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts`
+- Test: `test/unit/server/coding-cli/codex-app-server/transport.test.ts`
+- Test: `test/unit/server/coding-cli/codex-app-server/client.test.ts`
+- Test: `test/unit/server/coding-cli/codex-app-server/rich-runtime.test.ts`
+- Test: `test/unit/server/coding-cli/codex-app-server/runtime.test.ts`
+- Test: `test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts`
+- Test: `test/unit/server/fresh-agent/codex-adapter.test.ts`
+
+- [ ] **Step 1: Generate local app-server schema and write failing protocol tests**
+
+Run this inspection command before editing code, then copy the listed generated files into `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/`:
+
+```bash
+rm -rf /tmp/freshell-codex-app-server-schema
+codex app-server generate-json-schema --out /tmp/freshell-codex-app-server-schema
+codex app-server generate-ts --out /tmp/freshell-codex-app-server-schema-ts
+find /tmp/freshell-codex-app-server-schema -maxdepth 3 -type f | sort | rg 'JSONRPC|Initialize|Thread|Turn|Approval|Request|Item|Fork|Interrupt|ServerRequest|Model|Capabilities'
+```
+
+Use the generated schema to verify exact parameter and response names for `initialize`, `initialized`, `thread/start`, `thread/read`, `thread/turns/list`, `turn/start`, `turn/interrupt`, `thread/fork`, `model/list`, `modelProvider/capabilities/read`, server notifications, approval server requests, and user-input server requests. The current local schema uses `thread/read { includeTurns: boolean }`, `thread/turns/list { cursor?, limit?, sortDirection? }`, `thread/turns/list -> { data, nextCursor?, backwardsCursor? }`, `model/list { cursor?, limit?, includeHidden? }`, `model/list -> { data, nextCursor? }`, `turn/start -> { turn }`, `turn/interrupt { threadId, turnId }`, `thread/fork -> { thread, ...metadata }`, and has no `thread/turn/read`; tests must encode those facts so a future implementation does not accidentally keep the stale API. Tests must also prove `thread/start` and `thread/resume` do not send stale fields such as `richClient`, `experimentalRawEvents`, or `persistExtendedHistory`.
+
+Add generated inventory assertions for both methods and field-level requiredness. Tests must parse method names and important required fields from the checked-in generated schema snapshot through `test/fixtures/coding-cli/codex-app-server/schema-inventory.ts`, not from `/tmp`, so normal test runs and CI do not depend on an external `codex` executable. The generated `*.ts` snapshot files intentionally import many sibling type files that this reduced fixture does not check in, so `schema-inventory.ts` must read them as raw UTF-8 text with `fs`/`import.meta.url` path resolution and extract discriminant strings and required object fields. For wire-only constraints that TypeScript cannot express, such as `RequestId`'s integer numeric branch, `schema-inventory.ts` must also read the checked-in generated JSON Schema files. Do not import generated snapshot modules into the test module graph unless the entire generated dependency tree is checked in. The developer audit script may call the local `codex` executable and compare against the checked-in snapshot, but unit tests must be deterministic.
+
+Create `test/fixtures/coding-cli/codex-app-server/schema-traceability.ts` and `test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts` in the same red step. This test must use `schema-inventory.ts` as its input and fail until every generated Codex surface is classified. The matrix entries should be data, not prose comments, so tests can assert completeness and cross-field consistency:
+
+```ts
+type GeneratedCodexTraceabilityEntry = {
+ generatedKind: 'clientRequest' | 'serverRequest' | 'serverNotification' | 'threadItem' | 'runtimeLeaf' | 'entity' | 'response'
+ generatedName: string
+ generatedSource: string
+ support: 'implemented' | 'unsupported' | 'nonVisible' | 'runtimeGlobal' | 'connectionScoped'
+ protocolParser: string
+ outboundStrict?: boolean
+ normalizer?: string
+ freshAgentSchema?: string
+ runtimeOwner?: string
+ apiOrActionOwner?: string
+ uiOwner?: string
+ route?: { kind: 'thread' | 'runtimeGlobal' | 'connectionScoped' | 'nonVisible'; locator: string | null }
+ requiredFieldsCoveredBy?: string[]
+ optionalDefaultsCoveredBy?: string[]
+ fixtureOwners: string[]
+ testOwners: string[]
+ intentionalOmission?: { reason: string; behavior: string; test: string }
+}
+```
+
+The failing tests must check:
+
+```ts
+expect(unclassifiedGeneratedClientRequests()).toEqual([])
+expect(unclassifiedGeneratedServerRequests()).toEqual([])
+expect(unclassifiedGeneratedServerNotifications()).toEqual([])
+expect(unclassifiedGeneratedThreadItemVariants()).toEqual([])
+expect(unclassifiedGeneratedRuntimeLeaves()).toEqual([])
+expect(implementedEntriesMissingOwners()).toEqual([])
+expect(strictOutboundEntriesWithPassthrough()).toEqual([])
+expect(unsupportedEntriesMissingUserVisibleBehavior()).toEqual([])
+expect(notificationEntriesWithImplicitSubscriberRouting()).toEqual([])
+expect(serverRequestEntriesMissingGeneratedResponseSchema()).toEqual([])
+expect(itemEntriesMissingFreshAgentSchemaOrLosslessExtension()).toEqual([])
+expect(codexEntriesReferencingUnknownFreshAgentSchemas()).toEqual([])
+```
+
+This is the durable replacement for manual issue-by-issue discovery. Each later test in Task 4 and Task 5 can still assert concrete fixtures, but those fixtures must be derived from traceability entries. A new Codex schema release that adds one generated method, request, notification, item variant, enum value, response shape, or required/defaulted field must make the traceability gate red before it can silently reach the adapter or UI.
+
+Field inventory tests must fail if `protocol.ts` accepts a generated-required entity with missing required fields or rejects generated-defaulted fields that may be omitted on the JSON wire. Add `schema-inventory.ts` helpers for both required fields and defaulted JSON-schema properties, for example `requiredFieldsForGeneratedJsonSchema(...)` and `defaultedFieldsForGeneratedJsonSchema(...)`. At minimum, assert these local schema facts:
+
+```ts
+expect(requiredFieldsForGeneratedJsonSchema('v2/ThreadReadResponse.json', 'Thread')).toEqual(expect.arrayContaining([
+ 'id',
+ 'preview',
+ 'ephemeral',
+ 'modelProvider',
+ 'createdAt',
+ 'updatedAt',
+ 'status',
+ 'cwd',
+ 'cliVersion',
+ 'source',
+ 'turns',
+]))
+expect(() => CodexThreadSchema.parse({ id: 'thread-missing-required-fields' })).toThrow(/turns|cwd|createdAt/i)
+expect(CodexThreadSchema.parse({
+ id: 'thread-optional-null-fields',
+ preview: '',
+ ephemeral: false,
+ modelProvider: 'openai',
+ createdAt: 1,
+ updatedAt: 1,
+ status: { type: 'idle' },
+ cwd: '/repo',
+ cliVersion: '0.128.0',
+ source: 'vscode',
+ turns: [],
+})).toMatchObject({
+ forkedFromId: null,
+ path: null,
+ agentNickname: null,
+ agentRole: null,
+ gitInfo: null,
+ name: null,
+})
+expect(requestIdTypeFromGeneratedTs()).toEqual('string | number')
+expect(requestIdNumericTypeFromGeneratedJsonSchema()).toEqual('integer')
+expect(CodexRequestIdSchema.parse(42)).toBe(42)
+expect(() => CodexRequestIdSchema.parse(42.5)).toThrow(/integer/i)
+expect(CodexThreadTurnsListResultSchema.parse({ data: [] })).toMatchObject({ data: [], nextCursor: null, backwardsCursor: null })
+expect(CodexThreadReadResultSchema.parse({ thread: schemaValidThread({ turns: [] }) }).thread.turns).toEqual([])
+expect(sourceKindValuesFromGeneratedSchema()).toEqual(expect.arrayContaining([
+ 'cli',
+ 'vscode',
+ 'exec',
+ 'appServer',
+ 'subAgent',
+ 'subAgentReview',
+ 'subAgentCompact',
+ 'subAgentThreadSpawn',
+ 'subAgentOther',
+]))
+expect(threadStartSourceValuesFromGeneratedSchema()).toEqual(['startup', 'clear'])
+expect(CodexThreadListResultSchema.parse({ data: [] })).toMatchObject({ data: [], nextCursor: null, backwardsCursor: null })
+expect(CodexModelListResultSchema.parse({ data: [] })).toMatchObject({ data: [], nextCursor: null })
+expect(requiredFieldsForGeneratedJsonSchema('v2/ThreadStartResponse.json', 'ThreadStartResponse')).toEqual(expect.arrayContaining([
+ 'thread',
+ 'model',
+ 'modelProvider',
+ 'cwd',
+ 'approvalPolicy',
+ 'approvalsReviewer',
+ 'sandbox',
+]))
+expect(() => CodexThreadStartResultSchema.parse({ thread: schemaValidThread({ turns: [] }) })).toThrow(/model|cwd|approvalPolicy|sandbox/i)
+expect(() => CodexThreadResumeResultSchema.parse({ thread: schemaValidThread({ turns: [] }) })).toThrow(/model|cwd|approvalPolicy|sandbox/i)
+expect(() => CodexThreadForkResultSchema.parse({ thread: schemaValidThread({ turns: [] }), model: 'fixture', modelProvider: 'fixture', cwd: '/repo' })).toThrow(/approvalPolicy|sandbox/i)
+expect(CodexThreadForkResultSchema.parse({
+ thread: schemaValidThread({ turns: [] }),
+ model: 'fixture',
+ modelProvider: 'fixture-provider',
+ cwd: '/repo',
+ approvalPolicy: 'on-request',
+ approvalsReviewer: 'user',
+ sandbox: { type: 'dangerFullAccess' },
+})).toMatchObject({
+ serviceTier: null,
+ instructionSources: [],
+ reasoningEffort: null,
+})
+expect(requiredFieldsForGeneratedJsonSchema('v2/ModelListResponse.json', 'Model')).toEqual(expect.arrayContaining([
+ 'id',
+ 'model',
+ 'displayName',
+ 'description',
+ 'hidden',
+ 'supportedReasoningEfforts',
+ 'defaultReasoningEffort',
+ 'isDefault',
+]))
+expect(requiredFieldsForGeneratedType('v2/ReasoningEffortOption.ts', 'ReasoningEffortOption')).toEqual(['reasoningEffort', 'description'])
+expect(() => CodexModelSchema.parse({ id: 'model-missing-required-fields' })).toThrow(/displayName|defaultReasoningEffort/i)
+expect(CodexModelSchema.parse({
+ id: 'model-defaulted-fields',
+ model: 'model-defaulted-fields',
+ displayName: 'Defaulted Fields',
+ description: '',
+ hidden: false,
+ supportedReasoningEfforts: [],
+ defaultReasoningEffort: 'medium',
+ isDefault: false,
+})).toMatchObject({
+ inputModalities: ['text', 'image'],
+ supportsPersonality: false,
+ additionalSpeedTiers: [],
+ upgrade: null,
+ upgradeInfo: null,
+ availabilityNux: null,
+})
+expect(requiredFieldsForGeneratedType('v2/ModelProviderCapabilitiesReadResponse.ts', 'ModelProviderCapabilitiesReadResponse')).toEqual([
+ 'namespaceTools',
+ 'imageGeneration',
+ 'webSearch',
+])
+expect(CodexModelProviderCapabilitiesReadResultSchema.parse({
+ namespaceTools: true,
+ imageGeneration: false,
+ webSearch: true,
+})).toEqual({ namespaceTools: true, imageGeneration: false, webSearch: true })
+expect(requiredFieldsForGeneratedJsonSchema('ApplyPatchApprovalParams.json', 'ApplyPatchApprovalParams')).toEqual(expect.arrayContaining([
+ 'conversationId',
+ 'callId',
+ 'fileChanges',
+]))
+expect(requiredFieldsForGeneratedJsonSchema('ApplyPatchApprovalParams.json', 'ApplyPatchApprovalParams')).not.toEqual(expect.arrayContaining([
+ 'reason',
+ 'grantRoot',
+]))
+expect(CodexLegacyApplyPatchApprovalParamsSchema.parse({
+ conversationId: 'thread-1',
+ callId: 'patch-1',
+ fileChanges: {},
+})).toMatchObject({
+ reason: null,
+ grantRoot: null,
+})
+expect(requiredFieldsForGeneratedJsonSchema('ExecCommandApprovalParams.json', 'ExecCommandApprovalParams')).toEqual(expect.arrayContaining([
+ 'conversationId',
+ 'callId',
+ 'command',
+ 'cwd',
+ 'parsedCmd',
+]))
+expect(requiredFieldsForGeneratedJsonSchema('ExecCommandApprovalParams.json', 'ExecCommandApprovalParams')).not.toEqual(expect.arrayContaining([
+ 'approvalId',
+ 'reason',
+]))
+expect(CodexLegacyExecCommandApprovalParamsSchema.parse({
+ conversationId: 'thread-1',
+ callId: 'exec-1',
+ command: ['npm', 'test'],
+ cwd: '/repo',
+ parsedCmd: [],
+})).toMatchObject({
+ approvalId: null,
+ reason: null,
+})
+expect(reviewDecisionValuesFromGeneratedSchema()).toEqual(expect.arrayContaining([
+ 'approved',
+ 'approved_for_session',
+ 'denied',
+ 'timed_out',
+ 'abort',
+ 'approved_execpolicy_amendment',
+ 'network_policy_amendment',
+]))
+expect(CodexLegacyApplyPatchApprovalResponseSchema.parse({ decision: 'approved' })).toEqual({ decision: 'approved' })
+expect(CodexLegacyExecCommandApprovalResponseSchema.parse({ decision: 'denied' })).toEqual({ decision: 'denied' })
+expect(reasoningEffortValuesFromGeneratedSchema()).toEqual(['none', 'minimal', 'low', 'medium', 'high', 'xhigh'])
+expect(inputModalityValuesFromGeneratedSchema()).toEqual(['text', 'image'])
+expect(askForApprovalValuesFromGeneratedSchema()).toEqual(expect.arrayContaining([
+ 'untrusted',
+ 'on-failure',
+ 'on-request',
+ 'never',
+ 'granular',
+]))
+expect(defaultedFieldsForGeneratedJsonSchema('v2/ThreadStartResponse.json', 'AskForApproval.granular')).toEqual(expect.objectContaining({
+ skill_approval: false,
+ request_permissions: false,
+}))
+expect(CodexApprovalPolicySchema.parse({
+ granular: {
+ sandbox_approval: true,
+ rules: true,
+ mcp_elicitations: false,
+ },
+})).toEqual({
+ granular: {
+ sandbox_approval: true,
+ rules: true,
+ skill_approval: false,
+ request_permissions: false,
+ mcp_elicitations: false,
+ },
+})
+expect(sandboxModeValuesFromGeneratedSchema()).toEqual(['read-only', 'workspace-write', 'danger-full-access'])
+expect(sandboxPolicyVariantsFromGeneratedSchema()).toEqual(expect.arrayContaining([
+ 'dangerFullAccess',
+ 'readOnly',
+ 'externalSandbox',
+ 'workspaceWrite',
+]))
+expect(networkAccessValuesFromGeneratedSchema()).toEqual(['restricted', 'enabled'])
+expect(defaultedFieldsForGeneratedJsonSchema('v2/ThreadStartResponse.json', 'SandboxPolicy.readOnly')).toEqual({ networkAccess: false })
+expect(defaultedFieldsForGeneratedJsonSchema('v2/ThreadStartResponse.json', 'SandboxPolicy.externalSandbox')).toEqual({ networkAccess: 'restricted' })
+expect(defaultedFieldsForGeneratedJsonSchema('v2/ThreadStartResponse.json', 'SandboxPolicy.workspaceWrite')).toEqual({
+ writableRoots: [],
+ networkAccess: false,
+ excludeTmpdirEnvVar: false,
+ excludeSlashTmp: false,
+})
+expect(CodexSandboxPolicySchema.parse({ type: 'readOnly' })).toEqual({ type: 'readOnly', networkAccess: false })
+expect(CodexSandboxPolicySchema.parse({ type: 'externalSandbox' })).toEqual({ type: 'externalSandbox', networkAccess: 'restricted' })
+expect(CodexSandboxPolicySchema.parse({ type: 'workspaceWrite' })).toEqual({
+ type: 'workspaceWrite',
+ writableRoots: [],
+ networkAccess: false,
+ excludeTmpdirEnvVar: false,
+ excludeSlashTmp: false,
+})
+expect(userInputVariantsFromGeneratedSchema()).toEqual(['text', 'image', 'localImage', 'skill', 'mention'])
+expect(threadStatusVariantsFromGeneratedSchema()).toEqual(['notLoaded', 'idle', 'systemError', 'active'])
+expect(() => CodexThreadStatusSchema.parse({ type: 'active' })).toThrow(/activeFlags/i)
+expect(turnStatusValuesFromGeneratedSchema()).toEqual(['completed', 'interrupted', 'failed', 'inProgress'])
+expect(commandExecutionStatusValuesFromGeneratedSchema()).toEqual(['inProgress', 'completed', 'failed', 'declined'])
+expect(patchApplyStatusValuesFromGeneratedSchema()).toEqual(['inProgress', 'completed', 'failed', 'declined'])
+expect(patchChangeKindVariantsFromGeneratedSchema()).toEqual(['add', 'delete', 'update'])
+expect(patchChangeKindFieldsFromGeneratedSchema('update')).toEqual(expect.arrayContaining(['move_path']))
+expect(mcpToolCallStatusValuesFromGeneratedSchema()).toEqual(['inProgress', 'completed', 'failed'])
+expect(dynamicToolCallStatusValuesFromGeneratedSchema()).toEqual(['inProgress', 'completed', 'failed'])
+expect(collabAgentToolCallStatusValuesFromGeneratedSchema()).toEqual(['inProgress', 'completed', 'failed'])
+expect(imageGenerationStatusTypeFromGeneratedSchema()).toEqual('string')
+expect(requiredFieldsForGeneratedType('v2/HookPromptFragment.ts', 'HookPromptFragment')).toEqual(expect.arrayContaining(['text', 'hookRunId']))
+expect(requiredFieldsForGeneratedType('v2/TextElement.ts', 'TextElement')).toEqual(expect.arrayContaining(['byteRange', 'placeholder']))
+expect(requiredFieldsForGeneratedType('v2/MemoryCitation.ts', 'MemoryCitation')).toEqual(expect.arrayContaining(['entries', 'threadIds']))
+expect(messagePhaseValuesFromGeneratedSchema()).toEqual(['commentary', 'final_answer'])
+expect(commandActionVariantsFromGeneratedSchema()).toEqual(['read', 'listFiles', 'search', 'unknown'])
+expect(commandExecutionSourceValuesFromGeneratedSchema()).toEqual(['agent', 'userShell', 'unifiedExecStartup', 'unifiedExecInteraction'])
+expect(requiredFieldsForGeneratedType('v2/McpToolCallResult.ts', 'McpToolCallResult')).toEqual(expect.arrayContaining(['content', 'structuredContent', '_meta']))
+expect(dynamicToolCallOutputContentItemVariantsFromGeneratedSchema()).toEqual(['inputText', 'inputImage'])
+expect(webSearchActionVariantsFromGeneratedSchema()).toEqual(['search', 'openPage', 'findInPage', 'other'])
+expect(threadItemVariantFieldsFromGeneratedSchema('imageGeneration')).toEqual(expect.arrayContaining(['status', 'revisedPrompt', 'result', 'savedPath']))
+expect(threadItemFieldTypeFromGeneratedSchema('mcpToolCall', 'arguments')).toEqual('JsonValue')
+expect(threadItemFieldTypeFromGeneratedSchema('dynamicToolCall', 'arguments')).toEqual('JsonValue')
+expect(serverRequestFieldTypeFromGeneratedSchema('item/tool/call', 'arguments')).toEqual('JsonValue')
+expect(requiredFieldsForGeneratedJsonSchema('v2/ThreadReadResponse.json', 'Turn')).toEqual(expect.arrayContaining([
+ 'id',
+ 'items',
+ 'status',
+]))
+expect(CodexTurnSchema.parse({ id: 'turn-missing-optional-nullables', items: [], status: 'completed' })).toMatchObject({
+ error: null,
+ startedAt: null,
+ completedAt: null,
+ durationMs: null,
+})
+expect(sessionSourceVariantsFromGeneratedSchema()).toEqual(expect.arrayContaining([
+ 'cli',
+ 'vscode',
+ 'exec',
+ 'appServer',
+ 'custom',
+ 'subAgent',
+ 'unknown',
+]))
+expect(subAgentSourceVariantsFromGeneratedSchema()).toEqual(expect.arrayContaining([
+ 'review',
+ 'compact',
+ 'thread_spawn',
+ 'memory_consolidation',
+ 'other',
+]))
+expect(CodexThreadSchema.parse(schemaValidThread({
+ source: { subAgent: { thread_spawn: {
+ parent_thread_id: 'thread-parent-1',
+ depth: 1,
+ agent_path: null,
+ agent_nickname: 'reviewer',
+ agent_role: 'review',
+ } } },
+ turns: [],
+}))).toMatchObject({
+ source: { subAgent: { thread_spawn: expect.objectContaining({ parent_thread_id: 'thread-parent-1' }) } },
+})
+```
+
+This is required because `thread/read { includeTurns: false }` returns a schema-valid `Thread` with `turns: []`, not a partial object with `turns` omitted. Do not loosen `protocol.ts` to make impossible mocks easier to write.
+It is also required because `ThreadSourceKind` and `SessionSource` are different generated types: `sourceKinds` filters use flattened subagent source-kind strings, while the `Thread.source` metadata returned in `Thread` objects preserves nested subagent details. The checked-in schema snapshot and inventory tests must cover both so Freshcodex history filters and child-thread metadata do not accidentally share one lossy source enum.
+Add fixture helpers such as `schemaValidThread`, `schemaValidTurn`, `schemaValidCodexItem`, `schemaValidThreadLifecycleResult`, and `schemaValidModel`; adapter/runtime tests must use those helpers instead of `{ thread: { id } }`, `{ turn: { id } }`, `{ item: { id } }`, `{ model: { id } }`, or partial lifecycle/model responses.
+
+Add a package script so the schema audit is runnable from normal verification commands:
+
+```json
+{
+ "scripts": {
+ "audit:codex-app-server-schema": "tsx scripts/audit-codex-app-server-schema.ts"
+ }
+}
+```
+
+Compare generated method names to two explicit sets:
+
+- implemented in Freshcodex rich runtime: `initialize`, `thread/start`, `thread/resume`, `thread/fork`, `thread/list`, `thread/loaded/list`, `thread/read`, `thread/turns/list`, `turn/start`, `turn/interrupt`, `review/start`, `model/list`, `modelProvider/capabilities/read`
+- explicitly unsupported in Freshcodex rich runtime: every other generated method
+
+The traceability matrix must own these sets. The test must fail if a new generated client method appears in the checked-in schema snapshot without being classified, and must fail if a method outside the implemented set is accidentally proxied through as a generic request. It must also fail if an implemented client request parser accepts a field that is not in the generated parameter type. Add negative assertions that `CodexThreadStartParamsSchema`, `CodexThreadResumeParamsSchema`, `CodexThreadForkParamsSchema`, and `CodexTurnStartParamsSchema` reject stale fields such as `persistExtendedHistory`, `richClient`, `experimentalRawEvents`, `revision`, `includeBodies`, and thread-level `sandbox` on `turn/start`. `scripts/audit-codex-app-server-schema.ts` must fail when the local generated schema differs from the checked-in snapshot and print the new method/type names or required-field changes that require updating fixtures and classification.
+
+Add the same generated-inventory coverage for `ServerNotification` routing. Every method in the checked-in `ServerNotification.ts` snapshot must be classified as exactly one of `thread`, `runtimeGlobal`, `connectionScoped`, or `nonVisible`. The classification test must prove `thread/started` extracts `params.thread.id`, ordinary thread events extract `params.threadId`, `warning` branches on nullable `params.threadId`, and no-locator `command/exec/outputDelta` / `fs/changed` do not produce a Freshcodex thread invalidation until a future feature records a process/watch owner.
+
+Add transport tests requiring stdio JSONL framing and websocket preservation:
+
+```ts
+const transport = new CodexStdioJsonlTransport(fakeChildProcess)
+await transport.send({ id: 1, method: 'initialize', params: initializeParams })
+expect(fakeChild.stdinLines).toEqual([
+ JSON.stringify({ id: 1, method: 'initialize', params: initializeParams }),
+])
+fakeChild.stdout.push(JSON.stringify({ id: 1, result: initializeResponse }) + '\n')
+expect(await transport.nextMessage()).toEqual({ id: 1, result: initializeResponse })
+
+const wsTransport = new CodexWebSocketTransport({ wsUrl: 'ws://127.0.0.1:43123' })
+await wsTransport.send({ id: 2, method: 'thread/start', params: threadStartParams })
+expect(fakeWebSocket.sentMessages).toContainEqual(JSON.stringify({ id: 2, method: 'thread/start', params: threadStartParams }))
+```
+
+Then add client/runtime tests requiring:
+
+```ts
+await expect(client.initialize()).resolves.toMatchObject({
+ userAgent: expect.any(String),
+ codexHome: expect.any(String),
+ platformFamily: expect.any(String),
+ platformOs: expect.any(String),
+})
+const initializeRequest = fakeTransport.sent.find((message) => message.method === 'initialize')
+expect(initializeRequest).toMatchObject({
+ params: {
+ capabilities: expect.objectContaining({
+ experimentalApi: false,
+ }),
+ },
+})
+expect(initializeRequest.params.capabilities.optOutNotificationMethods ?? [])
+ .not.toEqual(expect.arrayContaining([
+ 'thread/started',
+ 'turn/started',
+ 'turn/completed',
+ 'item/started',
+ 'item/completed',
+ 'thread/tokenUsage/updated',
+ 'turn/diff/updated',
+ 'error',
+ ]))
+expect(fakeTransport.sent).toContainEqual({ method: 'initialized' })
+
+await expect(client.readThread({ threadId: 'thread-1', includeTurns: true }))
+ .resolves.toMatchObject({ thread: { id: 'thread-1' } })
+
+await expect(client.resumeThread({ threadId: 'thread-1', excludeTurns: true }))
+ .resolves.toMatchObject({ thread: { id: 'thread-1', turns: [] } })
+expect(fakeTransport.sent).toContainEqual(expect.objectContaining({
+ method: 'thread/resume',
+ params: expect.objectContaining({
+ threadId: 'thread-1',
+ excludeTurns: true,
+ }),
+}))
+expect(fakeTransport.sent.find((message) => message.method === 'thread/resume')?.params)
+ .not.toHaveProperty('persistExtendedHistory')
+
+await expect(client.listThreadTurns({ threadId: 'thread-1', limit: 25, sortDirection: 'desc' }))
+ .resolves.toMatchObject({ data: expect.any(Array), nextCursor: null })
+
+await expect(client.startTurn({
+ threadId: 'thread-1',
+ input: [{ type: 'text', text: 'Implement this', text_elements: [] }],
+})).resolves.toMatchObject({ turn: { id: expect.any(String) } })
+
+await expect(client.interruptTurn({ threadId: 'thread-1', turnId: 'turn-1' })).resolves.toEqual({})
+
+await expect(client.forkThread({ threadId: 'thread-1', excludeTurns: true }))
+ .resolves.toMatchObject({ thread: { id: expect.any(String) } })
+
+await expect(client.listThreads({ limit: 25 }))
+ .resolves.toMatchObject({ data: expect.any(Array) })
+
+await expect(client.listLoadedThreads({}))
+ .resolves.toMatchObject({ data: ['thread-1'], nextCursor: null })
+
+await expect(client.startReview({ threadId: 'thread-1', target: { type: 'uncommittedChanges' }, delivery: 'inline' }))
+ .resolves.toMatchObject({ turn: expect.any(Object), reviewThreadId: 'thread-1' })
+
+await expect(client.listModels({ limit: 25 }))
+ .resolves.toMatchObject({ data: expect.any(Array), nextCursor: null })
+
+await expect(client.readModelProviderCapabilities({}))
+ .resolves.toEqual({
+ namespaceTools: expect.any(Boolean),
+ imageGeneration: expect.any(Boolean),
+ webSearch: expect.any(Boolean),
+ })
+
+await expect(runtime.startTurn({ threadId: 'thread-1', input: [{ type: 'text', text: 'Hello', text_elements: [] }] }))
+ .resolves.toMatchObject({ turn: { id: expect.any(String) } })
+
+expect('readThreadTurn' in client).toBe(false) // no public method; direct turn read is not in the generated schema
+expect('readThreadTurn' in runtime).toBe(false) // the raw websocket runtime must not keep a fake turn-read API either
+
+await expect(websocketRuntime.startThread({ cwd: '/repo' }))
+ .resolves.toMatchObject({ threadId: expect.any(String), wsUrl: expect.stringMatching(/^ws:\/\/127\.0\.0\.1:\d+$/) })
+await expect(richRuntime.startThread({ cwd: '/repo' }))
+ .resolves.toMatchObject({ threadId: expect.any(String) })
+expect(await richRuntime.ensureReady()).not.toHaveProperty('wsUrl')
+```
+
+Add server-request tests in `client.test.ts`:
+
+```ts
+it('surfaces server-initiated approval requests and responds on the same JSON-RPC id', async () => {
+ const seen: unknown[] = []
+ client.onServerRequest((request) => seen.push(request))
+ await fakeServer.sendRequest({ id: 'approval-99', method: 'item/commandExecution/requestApproval', params: approvalParams })
+ await client.respondToServerRequest('approval-99', { decision: 'accept' })
+ expect(fakeServer.responses).toContainEqual({ id: 'approval-99', result: { decision: 'accept' } })
+})
+
+it('surfaces runtime-global server requests without inventing a thread id', async () => {
+ const seen: unknown[] = []
+ client.onServerRequest((request) => seen.push(request))
+ await fakeServer.sendRequest({
+ id: 'auth-refresh-1',
+ method: 'account/chatgptAuthTokens/refresh',
+ params: { reason: 'unauthorized', previousAccountId: null },
+ })
+ await client.respondToServerRequestError('auth-refresh-1', {
+ code: -32050,
+ message: 'Freshell cannot refresh Codex ChatGPT auth tokens from this runtime.',
+ })
+ expect(seen).toContainEqual(expect.objectContaining({ id: 'auth-refresh-1', method: 'account/chatgptAuthTokens/refresh' }))
+ expect(fakeServer.responses).toContainEqual({
+ id: 'auth-refresh-1',
+ error: expect.objectContaining({ code: -32050 }),
+ })
+})
+```
+
+Add notification forwarding tests in `client.test.ts` and `rich-runtime.test.ts`:
+
+```ts
+it('forwards app-server notifications without treating them as request responses', async () => {
+ const notifications: unknown[] = []
+ client.onNotification((notification) => notifications.push(notification))
+ await fakeServer.sendNotification({ method: 'turn/started', params: { threadId: 'thread-1', turn: schemaValidTurn({ id: 'turn-1' }) } })
+ expect(notifications).toContainEqual({ method: 'turn/started', params: expect.objectContaining({ threadId: 'thread-1' }) })
+ expect(client.pendingRequestCountForTest()).toBe(0)
+})
+
+it('classifies notification thread locators from generated params instead of subscriber state', async () => {
+ await expect(notificationRouteFor(CodexServerNotificationSchema.parse({
+ method: 'thread/started',
+ params: { thread: schemaValidThread({ id: 'thread-from-nested-thread' }) },
+ }))).toEqual({ kind: 'thread', threadId: 'thread-from-nested-thread' })
+ await expect(notificationRouteFor(CodexServerNotificationSchema.parse({
+ method: 'warning',
+ params: { threadId: null, message: 'Global warning' },
+ }))).toEqual({ kind: 'runtimeGlobal' })
+ await expect(notificationRouteFor(CodexServerNotificationSchema.parse({
+ method: 'command/exec/outputDelta',
+ params: { processId: 'process-1', stream: 'stdout', deltaBase64: '', capReached: false },
+ }))).toEqual({ kind: 'connectionScoped', owner: 'unsupported-command-exec' })
+})
+
+it('lets the rich stdio runtime subscribe to notifications and server requests for a specific Freshcodex session', async () => {
+ const seen: unknown[] = []
+ const unsubscribe = await richRuntime.subscribe('thread-1', (event) => seen.push(event))
+ await fakeServer.sendNotification({ method: 'item/completed', params: { threadId: 'thread-1', turnId: 'turn-1', item: schemaValidCodexItem({ type: 'plan', id: 'item-1', text: 'Plan' }) } })
+ expect(seen).toContainEqual(expect.objectContaining({ method: 'item/completed' }))
+ unsubscribe()
+})
+
+it('does not deliver no-locator global or connection-scoped notifications as thread invalidations', async () => {
+ const seen: unknown[] = []
+ const unsubscribe = await richRuntime.subscribe('thread-1', (event) => seen.push(event))
+ await fakeServer.sendNotification({ method: 'thread/started', params: { thread: schemaValidThread({ id: 'thread-2' }) } })
+ await fakeServer.sendNotification({ method: 'fs/changed', params: { watchId: 'watch-1', changedPaths: ['/repo/file.ts'] } })
+ await fakeServer.sendNotification({ method: 'warning', params: { threadId: null, message: 'Global warning' } })
+ expect(seen).not.toContainEqual(expect.objectContaining({ threadId: 'thread-1', reason: 'thread/started' }))
+ expect(seen).not.toContainEqual(expect.objectContaining({ threadId: 'thread-1', reason: 'fs/changed' }))
+ expect(seen).toContainEqual(expect.objectContaining({ method: 'warning', route: { kind: 'runtimeGlobal' } }))
+ unsubscribe()
+})
+
+it('shuts down the rich stdio app-server child without touching the raw websocket runtime', async () => {
+ await richRuntime.ensureReady()
+ await richRuntime.shutdown()
+ expect(fakeStdioChild.killed).toBe(true)
+ expect(websocketRuntime.status()).toBe('running')
+})
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts \
+ test/unit/server/coding-cli/codex-app-server/transport.test.ts \
+ test/unit/server/coding-cli/codex-app-server/client.test.ts \
+ test/unit/server/coding-cli/codex-app-server/rich-runtime.test.ts \
+ test/unit/server/coding-cli/codex-app-server/runtime.test.ts \
+ test/unit/server/fresh-agent/codex-adapter.test.ts
+```
+
+Expected: FAIL because the client still owns WebSocket directly, emits `"jsonrpc": "2.0"`, does not send `initialized`, parses the old initialize result, exposes stale turn-read behavior, lacks turn, fork, interrupt, and server-request response methods, and has no Freshcodex-only stdio rich runtime.
+
+- [ ] **Step 3: Implement app-server protocol methods**
+
+Implement `schema-traceability.ts` before filling in broad protocol/client behavior. Keep it data-only and importable by tests without starting Codex. The entries should reference the exact parser/export names that this step adds to `protocol.ts`, the rich-runtime/client method names that own supported requests, and the UI/API owners that later tasks must satisfy. During this step it is acceptable for Task 5 UI owners to point at planned owners such as `FreshAgentItemCard` or `FreshAgentWorkspacePanel`, but they must be concrete file/component names and later tasks must update or satisfy them. Do not leave placeholder owners such as `TODO`, `unknown`, or `adapter`.
+
+Update `protocol.ts` with schema names matching the generated app-server schema. The implementation must include generated response schemas for every server request that Freshell answers, not only request-param schemas. The checked-in schema snapshot and `schema-inventory.ts` should cover `CommandExecutionRequestApprovalResponse`, `FileChangeRequestApprovalResponse`, `PermissionsRequestApprovalResponse`, `ToolRequestUserInputResponse`, `McpServerElicitationRequestResponse`, `DynamicToolCallResponse`, `ChatgptAuthTokensRefreshResponse`, root `ApplyPatchApprovalResponse`, root `ExecCommandApprovalResponse`, and root `ReviewDecision` so tests fail when Codex changes the payload shape Freshell sends back to unblock a turn.
+
+The implementation must include, at minimum:
+
+```ts
+export const CodexRequestIdSchema = z.union([z.string().min(1), z.number().int()])
+
+export const CodexInitializeResultSchema = z.object({
+ userAgent: z.string().min(1),
+ codexHome: z.string().min(1),
+ platformFamily: z.string().min(1),
+ platformOs: z.string().min(1),
+})
+
+export const CodexThreadReadParamsSchema = z.object({
+ threadId: z.string().min(1),
+ includeTurns: z.boolean(),
+}).strict()
+
+export const CodexThreadTurnsListParamsSchema = z.object({
+ threadId: z.string().min(1),
+ cursor: z.string().nullable().optional(),
+ limit: z.number().int().nonnegative().optional(),
+ sortDirection: z.enum(['asc', 'desc']).nullable().optional(),
+}).strict()
+
+export const CodexThreadTurnsListResultSchema = z.object({
+ data: z.array(z.lazy(() => CodexTurnSchema)),
+ nextCursor: z.string().nullable().optional().default(null),
+ backwardsCursor: z.string().nullable().optional().default(null),
+})
+
+export const CodexThreadReadResultSchema = z.object({
+ thread: z.lazy(() => CodexThreadSchema),
+})
+
+export const CodexThreadSourceKindSchema = z.enum([
+ 'cli',
+ 'vscode',
+ 'exec',
+ 'appServer',
+ 'subAgent',
+ 'subAgentReview',
+ 'subAgentCompact',
+ 'subAgentThreadSpawn',
+ 'subAgentOther',
+ 'unknown',
+])
+export const CodexSubAgentSourceSchema = z.union([
+ z.literal('review'),
+ z.literal('compact'),
+ z.object({
+ thread_spawn: z.object({
+ parent_thread_id: z.string().min(1),
+ depth: z.number().int().nonnegative(),
+ agent_path: z.unknown().nullable(),
+ agent_nickname: z.string().nullable(),
+ agent_role: z.string().nullable(),
+ }),
+ }),
+ z.literal('memory_consolidation'),
+ z.object({ other: z.string() }),
+])
+export const CodexSessionSourceSchema = z.union([
+ z.literal('cli'),
+ z.literal('vscode'),
+ z.literal('exec'),
+ z.literal('appServer'),
+ z.object({ custom: z.string() }),
+ z.object({ subAgent: CodexSubAgentSourceSchema }),
+ z.literal('unknown'),
+])
+export const CodexThreadActiveFlagSchema = z.enum(['waitingOnApproval', 'waitingOnUserInput'])
+export const CodexThreadStatusSchema = z.discriminatedUnion('type', [
+ z.object({ type: z.literal('notLoaded') }),
+ z.object({ type: z.literal('idle') }),
+ z.object({ type: z.literal('systemError') }),
+ z.object({ type: z.literal('active'), activeFlags: z.array(CodexThreadActiveFlagSchema) }),
+])
+export const CodexThreadSortKeySchema = z.enum(['created_at', 'updated_at'])
+
+export const CodexThreadListParamsSchema = z.object({
+ cursor: z.string().nullable().optional(),
+ limit: z.number().int().nonnegative().optional(),
+ sortKey: CodexThreadSortKeySchema.nullable().optional(),
+ sortDirection: z.enum(['asc', 'desc']).nullable().optional(),
+ modelProviders: z.array(z.string()).nullable().optional(),
+ sourceKinds: z.array(CodexThreadSourceKindSchema).nullable().optional(),
+ archived: z.boolean().nullable().optional(),
+ cwd: z.union([z.string(), z.array(z.string())]).nullable().optional(),
+ useStateDbOnly: z.boolean().optional(),
+ searchTerm: z.string().nullable().optional(),
+}).strict()
+
+export const CodexThreadLoadedListParamsSchema = z.object({
+ cursor: z.string().nullable().optional(),
+ limit: z.number().int().nonnegative().optional(),
+}).strict()
+
+export const CodexThreadListResultSchema = z.object({
+ data: z.array(z.lazy(() => CodexThreadSchema)),
+ nextCursor: z.string().nullable().optional().default(null),
+ backwardsCursor: z.string().nullable().optional().default(null),
+})
+
+export const CodexThreadLoadedListResultSchema = z.object({
+ data: z.array(z.string().min(1)),
+ nextCursor: z.string().nullable().optional().default(null),
+})
+
+export const CodexModelProviderCapabilitiesReadResultSchema = z.object({
+ namespaceTools: z.boolean(),
+ imageGeneration: z.boolean(),
+ webSearch: z.boolean(),
+})
+
+export const CodexReasoningEffortSchema = z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh'])
+
+export const CodexModelSchema = z.object({
+ id: z.string().min(1),
+ model: z.string().min(1),
+ upgrade: z.string().nullable().optional().default(null),
+ upgradeInfo: z.unknown().nullable().optional().default(null),
+ availabilityNux: z.unknown().nullable().optional().default(null),
+ displayName: z.string(),
+ description: z.string(),
+ hidden: z.boolean(),
+ supportedReasoningEfforts: z.array(z.object({
+ reasoningEffort: CodexReasoningEffortSchema,
+ description: z.string(),
+ })),
+ defaultReasoningEffort: CodexReasoningEffortSchema,
+ inputModalities: z.array(z.enum(['text', 'image'])).optional().default(['text', 'image']),
+ supportsPersonality: z.boolean().optional().default(false),
+ additionalSpeedTiers: z.array(z.string()).optional().default([]),
+ isDefault: z.boolean(),
+})
+
+export const CodexModelListResultSchema = z.object({
+ data: z.array(CodexModelSchema),
+ nextCursor: z.string().nullable().optional().default(null),
+})
+
+export const CodexModelListParamsSchema = z.object({
+ cursor: z.string().nullable().optional(),
+ limit: z.number().int().nonnegative().optional(),
+ includeHidden: z.boolean().nullable().optional(),
+}).strict()
+
+export const CodexModelProviderCapabilitiesReadParamsSchema = z.object({}).strict()
+
+export const CodexTurnInputItemSchema = z.discriminatedUnion('type', [
+ z.object({ type: z.literal('text'), text: z.string(), text_elements: z.array(z.unknown()).default([]) }),
+ z.object({ type: z.literal('image'), url: z.string().url() }),
+ z.object({ type: z.literal('localImage'), path: z.string().min(1) }),
+ z.object({ type: z.literal('skill'), name: z.string().min(1), path: z.string().min(1) }),
+ z.object({ type: z.literal('mention'), name: z.string().min(1), path: z.string().min(1) }),
+])
+
+export const CodexApprovalPolicySchema = z.union([
+ z.enum(['untrusted', 'on-failure', 'on-request', 'never']),
+ z.object({
+ granular: z.object({
+ sandbox_approval: z.boolean(),
+ rules: z.boolean(),
+ skill_approval: z.boolean().optional().default(false),
+ request_permissions: z.boolean().optional().default(false),
+ mcp_elicitations: z.boolean(),
+ }),
+ }),
+])
+export const CodexSandboxPolicySchema = z.discriminatedUnion('type', [
+ z.object({ type: z.literal('dangerFullAccess') }),
+ z.object({ type: z.literal('readOnly'), networkAccess: z.boolean().optional().default(false) }),
+ z.object({ type: z.literal('externalSandbox'), networkAccess: z.enum(['restricted', 'enabled']).optional().default('restricted') }),
+ z.object({
+ type: z.literal('workspaceWrite'),
+ writableRoots: z.array(z.string()).optional().default([]),
+ networkAccess: z.boolean().optional().default(false),
+ excludeTmpdirEnvVar: z.boolean().optional().default(false),
+ excludeSlashTmp: z.boolean().optional().default(false),
+ }),
+])
+
+export const CodexServiceTierSchema = z.enum(['fast', 'flex'])
+export const CodexApprovalsReviewerSchema = z.enum(['user', 'auto_review', 'guardian_subagent'])
+export const CodexThreadSandboxModeSchema = z.enum(['read-only', 'workspace-write', 'danger-full-access'])
+export const CodexReasoningSummarySchema = z.enum(['auto', 'concise', 'detailed', 'none'])
+export const CodexPersonalitySchema = z.enum(['none', 'friendly', 'pragmatic'])
+export const CodexThreadStartSourceSchema = z.enum(['startup', 'clear'])
+export const CodexRuntimeConfigSchema = z.record(z.string(), JsonValue)
+
+const CodexThreadRuntimeOverridesSchema = z.object({
+ model: z.string().nullable().optional(),
+ modelProvider: z.string().nullable().optional(),
+ serviceTier: CodexServiceTierSchema.nullable().optional(),
+ cwd: z.string().nullable().optional(),
+ approvalPolicy: CodexApprovalPolicySchema.nullable().optional(),
+ approvalsReviewer: CodexApprovalsReviewerSchema.nullable().optional(),
+ sandbox: CodexThreadSandboxModeSchema.nullable().optional(),
+ config: CodexRuntimeConfigSchema.nullable().optional(),
+ baseInstructions: z.string().nullable().optional(),
+ developerInstructions: z.string().nullable().optional(),
+})
+
+export const CodexThreadStartParamsSchema = CodexThreadRuntimeOverridesSchema.extend({
+ serviceName: z.string().nullable().optional(),
+ personality: CodexPersonalitySchema.nullable().optional(),
+ ephemeral: z.boolean().nullable().optional(),
+ sessionStartSource: CodexThreadStartSourceSchema.nullable().optional(),
+}).strict()
+
+export const CodexThreadResumeParamsSchema = CodexThreadRuntimeOverridesSchema.extend({
+ threadId: z.string().min(1),
+ personality: CodexPersonalitySchema.nullable().optional(),
+ excludeTurns: z.boolean().optional(),
+}).strict()
+
+export const CodexTurnStartParamsSchema = z.object({
+ threadId: z.string().min(1),
+ input: z.array(CodexTurnInputItemSchema).min(1),
+ cwd: z.string().nullable().optional(),
+ approvalPolicy: CodexApprovalPolicySchema.nullable().optional(),
+ approvalsReviewer: CodexApprovalsReviewerSchema.nullable().optional(),
+ sandboxPolicy: CodexSandboxPolicySchema.nullable().optional(),
+ model: z.string().nullable().optional(),
+ serviceTier: CodexServiceTierSchema.nullable().optional(),
+ effort: CodexReasoningEffortSchema.nullable().optional(),
+ summary: CodexReasoningSummarySchema.nullable().optional(),
+ personality: CodexPersonalitySchema.nullable().optional(),
+ outputSchema: JsonValue.nullable().optional(),
+}).strict()
+
+export const CodexThreadForkParamsSchema = CodexThreadRuntimeOverridesSchema.extend({
+ threadId: z.string().min(1),
+ ephemeral: z.boolean().optional(),
+ excludeTurns: z.boolean().optional(),
+}).strict()
+
+export const CodexReviewTargetSchema = z.discriminatedUnion('type', [
+ z.object({ type: z.literal('uncommittedChanges') }),
+ z.object({ type: z.literal('baseBranch'), branch: z.string().min(1) }),
+ z.object({ type: z.literal('commit'), sha: z.string().min(1), title: z.string().nullable() }),
+ z.object({ type: z.literal('custom'), instructions: z.string().min(1) }),
+])
+
+export const CodexReviewStartParamsSchema = z.object({
+ threadId: z.string().min(1),
+ target: CodexReviewTargetSchema,
+ delivery: z.enum(['inline', 'detached']).nullable().optional(),
+}).strict()
+```
+
+Use generated schema field names. Do not guess against tests. Delete the stale `CodexThreadTurnRead*` schemas unless a future generated schema actually contains a direct turn-read client request.
+
+Model the response schemas with the generated shapes, not Freshell convenience shapes:
+
+```ts
+const CodexThreadLifecycleResultSchema = z.object({
+ thread: z.lazy(() => CodexThreadSchema),
+ model: z.string().min(1),
+ modelProvider: z.string().min(1),
+ serviceTier: CodexServiceTierSchema.nullable().optional().default(null),
+ cwd: z.string().min(1),
+ instructionSources: z.array(z.string()).optional().default([]),
+ approvalPolicy: CodexApprovalPolicySchema,
+ approvalsReviewer: CodexApprovalsReviewerSchema,
+ sandbox: CodexSandboxPolicySchema,
+ reasoningEffort: CodexReasoningEffortSchema.nullable().optional().default(null),
+})
+
+export const CodexThreadStartResultSchema = CodexThreadLifecycleResultSchema
+export const CodexThreadResumeResultSchema = CodexThreadLifecycleResultSchema
+export const CodexThreadForkResultSchema = CodexThreadLifecycleResultSchema
+
+export const CodexTurnStartResultSchema = z.object({
+ turn: z.lazy(() => CodexTurnSchema),
+})
+
+export const CodexTurnInterruptParamsSchema = z.object({
+ threadId: z.string().min(1),
+ turnId: z.string().min(1),
+})
+
+export const CodexTurnInterruptResultSchema = z.object({}).passthrough()
+
+```
+
+Also model generated app-server read shapes used by later normalization and UI tasks:
+
+```ts
+export const CodexUserInputSchema = z.discriminatedUnion('type', [...])
+export const CodexThreadItemSchema = z.discriminatedUnion('type', [...])
+export const CodexTurnSchema = z.object({
+ id: z.string().min(1),
+ items: z.array(CodexThreadItemSchema),
+ status: CodexTurnStatusSchema,
+ error: z.object({
+ message: z.string(),
+ codexErrorInfo: z.unknown().nullable(),
+ additionalDetails: z.string().nullable(),
+ }).nullable().optional().default(null),
+ startedAt: z.number().nullable().optional().default(null),
+ completedAt: z.number().nullable().optional().default(null),
+ durationMs: z.number().nullable().optional().default(null),
+})
+export const CodexThreadSchema = z.object({
+ id: z.string().min(1),
+ forkedFromId: z.string().nullable().optional().default(null),
+ preview: z.string(),
+ ephemeral: z.boolean(),
+ modelProvider: z.string(),
+ createdAt: z.number(),
+ updatedAt: z.number(),
+ status: CodexThreadStatusSchema,
+ path: z.string().nullable().optional().default(null),
+ cwd: z.string().min(1),
+ cliVersion: z.string(),
+ source: CodexSessionSourceSchema,
+ agentNickname: z.string().nullable().optional().default(null),
+ agentRole: z.string().nullable().optional().default(null),
+ gitInfo: CodexGitInfoSchema.nullable().optional().default(null),
+ name: z.string().nullable().optional().default(null),
+ turns: z.array(CodexTurnSchema),
+}).passthrough()
+export const CodexServerRequestSchema = z.discriminatedUnion('method', [...])
+export const CodexServerNotificationSchema = z.discriminatedUnion('method', [...])
+```
+
+The object schemas and discriminated unions must be generated-schema faithful enough that Task 5 fixtures cannot use impossible app-server thread, turn, item, request, response, or notification shapes. It is acceptable to use `.passthrough()` for extra future fields on known result/entity variants, but implemented client-request parameter schemas must be `.strict()` so stale outbound fields fail before reaching Codex. Do not make generated-required fields optional and do not use a catch-all unknown item variant.
+
+Model the legacy root approval request/response schemas explicitly alongside the v2 request schemas because the current generated `ServerRequest` union still includes them:
+
+```ts
+export const CodexLegacyReviewDecisionSchema = z.union([
+ z.enum(['approved', 'approved_for_session', 'denied', 'timed_out', 'abort']),
+ z.object({
+ approved_execpolicy_amendment: z.object({
+ proposed_execpolicy_amendment: z.record(z.string(), JsonValue),
+ }),
+ }),
+ z.object({
+ network_policy_amendment: z.object({
+ network_policy_amendment: z.record(z.string(), JsonValue),
+ }),
+ }),
+])
+
+export const CodexLegacyApplyPatchApprovalParamsSchema = z.object({
+ conversationId: z.string().min(1),
+ callId: z.string().min(1),
+ fileChanges: z.record(z.string(), JsonValue),
+ reason: z.string().nullable().optional().default(null),
+ grantRoot: z.string().nullable().optional().default(null),
+})
+export const CodexLegacyExecCommandApprovalParamsSchema = z.object({
+ conversationId: z.string().min(1),
+ callId: z.string().min(1),
+ command: z.array(z.string()),
+ cwd: z.string(),
+ parsedCmd: z.array(JsonValue),
+ approvalId: z.string().nullable().optional().default(null),
+ reason: z.string().nullable().optional().default(null),
+})
+export const CodexLegacyApplyPatchApprovalResponseSchema = z.object({
+ decision: CodexLegacyReviewDecisionSchema,
+})
+export const CodexLegacyExecCommandApprovalResponseSchema = z.object({
+ decision: CodexLegacyReviewDecisionSchema,
+})
+```
+
+Do not route these legacy requests through the runtime-global/auth-refresh path just because their params do not have `threadId`; their generated `conversationId` is the Codex thread id.
+
+Create `transport.ts` as the only app-server framing owner:
+
+```ts
+export type CodexRpcMessage = {
+ id?: string | number
+ method?: string
+ params?: unknown
+ result?: unknown
+ error?: unknown
+}
+
+export interface CodexAppServerTransport {
+ send(message: CodexRpcMessage): Promise
+ onMessage(listener: (message: CodexRpcMessage) => void): () => void
+ close(): Promise
+}
+
+export class CodexStdioJsonlTransport implements CodexAppServerTransport {}
+export class CodexWebSocketTransport implements CodexAppServerTransport {}
+```
+
+The stdio implementation should split stdout on newlines, parse one JSON message per line, reject malformed app-server output with a clear transport error, and never add a `jsonrpc` property. The websocket implementation should preserve the existing loopback app-server terminal launch behavior while using the same no-`jsonrpc` envelope semantics as stdio.
+
+Update `client.ts`:
+
+```ts
+type CodexRequestId = string | number
+type ServerRequest = { id: CodexRequestId; method: string; params: unknown }
+
+onNotification(listener: (notification: { method: string; params?: unknown }) => void): () => void
+onServerRequest(listener: (request: ServerRequest) => void): () => void
+respondToServerRequest(id: CodexRequestId, result: unknown): Promise
+respondToServerRequestError(id: CodexRequestId, error: { code: number; message: string; data?: unknown }): Promise
+readThread(params: CodexThreadReadParams): Promise
+listThreadTurns(params: CodexThreadTurnsListParams): Promise
+listThreads(params: CodexThreadListParams): Promise
+listLoadedThreads(params: CodexThreadLoadedListParams): Promise
+startTurn(params: CodexTurnStartParams): Promise
+interruptTurn(params: CodexTurnInterruptParams): Promise
+forkThread(params: CodexThreadForkParams): Promise
+startReview(params: CodexReviewStartParams): Promise
+listModels(params: CodexModelListParams): Promise
+readModelProviderCapabilities(params: CodexModelProviderCapabilitiesReadParams): Promise
+```
+
+Update message handling so app-server requests with `id` and `method` are not ignored, and so notifications without `id` reach subscribers. Keep request timeout behavior for client-initiated calls. `initialize` must send `capabilities.experimentalApi: false` because the checked-in protocol snapshot and method classification are non-experimental; do not send `experimentalApi: true` unless this plan is updated to generate and classify `--experimental` schema artifacts. `initialize` also must not opt out of any visible-state notification method; remove the existing `thread/started` opt-out. After a successful `initialize`, send exactly one `initialized` notification on the same transport before non-initialize requests. The client constructor should receive a `CodexAppServerTransport` instead of a `{ wsUrl }` endpoint. Server-request responses must support both result and error envelopes so unsupported required requests such as auth-token refresh can unblock the app-server without sending an invalid success shape.
+
+Keep `runtime.ts` as the websocket remote runtime for raw Codex terminal panes and `CodexLaunchPlanner`. It should spawn:
+
+```ts
+spawn(command, [...commandArgs, 'app-server', '--listen', wsUrl], {
+ stdio: ['ignore', 'pipe', 'pipe'],
+})
+```
+
+and use `CodexWebSocketTransport` internally. Its `startThread` and `resumeThread` continue returning `{ threadId, wsUrl }`; do not break `server/terminal-registry.ts`, `server/agent-api/router.ts`, or `CodexLaunchPlanner`.
+
+Create `rich-runtime.ts` for Freshcodex. It should spawn:
+
+```ts
+spawn(command, [...commandArgs, 'app-server', '--listen', 'stdio://'], {
+ stdio: ['pipe', 'pipe', 'pipe'],
+})
+```
+
+and use `CodexStdioJsonlTransport` internally. Proxy the new rich methods after `ensureReady()`. Freshcodex adapter dependencies must use this rich runtime and must not receive or depend on `wsUrl`.
+
+`rich-runtime.ts` must also expose:
+
+```ts
+subscribe(threadId: string, listener: (event: CodexRuntimeEvent) => void): Promise<() => void>
+onServerRequest(listener: (request: CodexServerRequest) => void): () => void
+onRuntimeError(listener: (error: CodexRuntimeError) => void): () => void
+```
+
+The runtime should forward notifications and server requests from `client.ts` without buffering them behind a snapshot call. Server requests use `params.threadId` for v2 requests and `params.conversationId` for legacy root approvals. Notifications need their own generated-method-specific router: common thread-visible notifications use `params.threadId`, `thread/started` uses `params.thread.id`, `warning` is thread-visible only when `params.threadId` is non-null, and connection-scoped/global notifications such as `command/exec/outputDelta`, `fs/changed`, `configWarning`, MCP startup/OAuth status, and Windows sandbox warnings have no Freshcodex thread locator. Notifications or server requests without a thread locator but with visible global impact, such as app-server errors, null-thread warnings, and `account/chatgptAuthTokens/refresh`, should still reach subscribers as typed runtime events or runtime errors. They must not be attached to whichever thread happened to subscribe.
+
+Keep the branch typecheckable at the end of Task 4. Because this task removes the nonexistent `thread/turn/read` client/runtime API, also update `server/fresh-agent/adapters/codex/adapter.ts` enough to stop depending on `readThreadTurn` or a websocket-only `{ wsUrl }` result. This is a narrow compile-preserving bridge before Task 5's full normalization:
+
+```ts
+type CodexFreshAgentRichRuntimePort = {
+ startThread(params: CodexThreadStartParams): Promise<{ threadId: string }>
+ resumeThread(params: CodexThreadResumeParams): Promise<{ threadId: string }>
+ readThread(params: CodexThreadReadParams): Promise
+ listThreadTurns(params: CodexThreadTurnsListParams): Promise
+ subscribe?(threadId: string, listener: (event: unknown) => void): Promise<() => void> | (() => void)
+}
+```
+
+For this bridge only, `getTurnBody` should return a typed `FreshAgentUnsupportedCapabilityError` or cache-miss style error rather than calling a fake Codex RPC. Task 5 replaces that bridge with the bounded page/body cache and full event/request handling. Do not keep any `readThreadTurn` method on the client, raw websocket runtime, rich stdio runtime, or adapter port.
+
+Wire both Codex runtimes in `server/index.ts` in the same task:
+
+```ts
+const codexAppServerRuntime = new CodexAppServerRuntime()
+const codexRichAppServerRuntime = new CodexRichAppServerRuntime()
+const codexLaunchPlanner = new CodexLaunchPlanner(codexAppServerRuntime)
+const codexFreshAgentAdapter = createCodexFreshAgentAdapter({
+ runtime: codexRichAppServerRuntime,
+})
+```
+
+The raw websocket runtime remains exclusively for `CodexLaunchPlanner` and raw Codex terminal `--remote` attach. The rich stdio runtime is passed to the Freshcodex adapter. On server shutdown, call `await codexRichAppServerRuntime.shutdown()` next to the existing raw runtime shutdown so the stdio app-server process cannot be orphaned.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts \
+ test/unit/server/coding-cli/codex-app-server/transport.test.ts \
+ test/unit/server/coding-cli/codex-app-server/client.test.ts \
+ test/unit/server/coding-cli/codex-app-server/rich-runtime.test.ts \
+ test/unit/server/coding-cli/codex-app-server/runtime.test.ts \
+ test/unit/server/fresh-agent/codex-adapter.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Refactor and verify**
+
+Keep `client.ts` as the only JSON-RPC envelope owner. `runtime.ts` and `rich-runtime.ts` should remain thin lifecycle/proxy layers with separate responsibilities.
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts \
+ test/unit/server/coding-cli/codex-app-server/transport.test.ts \
+ test/unit/server/coding-cli/codex-app-server/client.test.ts \
+ test/unit/server/coding-cli/codex-app-server/rich-runtime.test.ts \
+ test/unit/server/coding-cli/codex-app-server/runtime.test.ts \
+ test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts \
+ test/unit/server/fresh-agent/codex-adapter.test.ts \
+ test/integration/server/codex-session-flow.test.ts
+npm run audit:codex-app-server-schema
+npm run typecheck:server
+```
+
+Expected: PASS. If `npm run audit:codex-app-server-schema` fails because the installed `codex` schema differs from the checked-in snapshot or because the traceability matrix has unclassified generated surfaces, do not proceed by weakening tests; regenerate the snapshot, update protocol schemas, update traceability classifications, and rerun this task.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add \
+ server/index.ts \
+ server/coding-cli/codex-app-server/protocol.ts \
+ server/coding-cli/codex-app-server/transport.ts \
+ server/coding-cli/codex-app-server/client.ts \
+ server/coding-cli/codex-app-server/rich-runtime.ts \
+ server/coding-cli/codex-app-server/runtime.ts \
+ server/coding-cli/codex-app-server/launch-planner.ts \
+ server/fresh-agent/adapters/codex/adapter.ts \
+ package.json \
+ test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ClientRequest.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ServerRequest.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ServerNotification.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/RequestId.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/RequestId.json \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCRequest.json \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCResponse.json \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCError.json \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCNotification.json \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/JSONRPCMessage.json \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/ApplyPatchApprovalParams.json \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/ApplyPatchApprovalResponse.json \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/ExecCommandApprovalParams.json \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/ExecCommandApprovalResponse.json \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/v2/ThreadReadResponse.json \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/v2/ThreadStartResponse.json \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/json/v2/ModelListResponse.json \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ReasoningEffort.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/InputModality.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/SubAgentSource.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/InitializeParams.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/InitializeResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ApplyPatchApprovalParams.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ApplyPatchApprovalResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ExecCommandApprovalParams.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ExecCommandApprovalResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/ReviewDecision.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CommandExecutionRequestApprovalResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/FileChangeRequestApprovalResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/PermissionsRequestApprovalResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ToolRequestUserInputResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/McpServerElicitationRequestResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/DynamicToolCallParams.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/DynamicToolCallResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ChatgptAuthTokensRefreshResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/AskForApproval.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/SandboxMode.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/SandboxPolicy.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/NetworkAccess.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/Thread.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/Turn.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnError.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadItem.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/UserInput.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TextElement.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ByteRange.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/HookPromptFragment.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/MessagePhase.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/MemoryCitation.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/MemoryCitationEntry.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CommandAction.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CommandExecutionSource.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/McpToolCallResult.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/McpToolCallError.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/DynamicToolCallOutputContentItem.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/WebSearchAction.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadStatus.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadActiveFlag.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnStatus.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CommandExecutionStatus.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/PatchApplyStatus.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/PatchChangeKind.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/McpToolCallStatus.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/DynamicToolCallStatus.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/CollabAgentToolCallStatus.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/SessionSource.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadStartParams.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadStartSource.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadStartResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadResumeParams.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadResumeResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadListParams.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadListResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadSourceKind.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadSortKey.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/SortDirection.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadLoadedListParams.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadLoadedListResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadReadParams.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadReadResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadTurnsListParams.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ThreadTurnsListResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnStartParams.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnStartResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnInterruptParams.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/TurnInterruptResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ReviewStartParams.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ReviewStartResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/Model.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ReasoningEffortOption.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ModelListParams.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ModelListResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ModelProviderCapabilitiesReadParams.ts \
+ test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/v2/ModelProviderCapabilitiesReadResponse.ts \
+ test/fixtures/coding-cli/codex-app-server/schema-inventory.ts \
+ test/fixtures/coding-cli/codex-app-server/schema-traceability.ts \
+ scripts/audit-codex-app-server-schema.ts \
+ test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts \
+ test/unit/server/coding-cli/codex-app-server/transport.test.ts \
+ test/unit/server/coding-cli/codex-app-server/client.test.ts \
+ test/unit/server/coding-cli/codex-app-server/rich-runtime.test.ts \
+ test/unit/server/coding-cli/codex-app-server/runtime.test.ts \
+ test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts \
+ test/unit/server/fresh-agent/codex-adapter.test.ts \
+ test/integration/server/codex-session-flow.test.ts
+git commit -m "Extend Codex app-server client for rich turns"
+```
+
+### Task 5: Fully Normalize Codex Snapshots, Pages, Bodies, And Events
+
+**Files:**
+- Modify: `server/coding-cli/codex-app-server/rich-runtime.ts`
+- Modify: `server/fresh-agent/adapters/codex/normalize.ts`
+- Modify: `server/fresh-agent/adapters/codex/adapter.ts`
+- Modify: `server/fresh-agent/runtime-adapter.ts`
+- Modify: `server/fresh-agent/runtime-manager.ts`
+- Modify: `shared/ws-protocol.ts`
+- Modify: `server/ws-handler.ts`
+- Modify: `src/lib/fresh-agent-ws.ts`
+- Modify: `src/store/paneTypes.ts`
+- Modify: `test/fixtures/fresh-agent/codex/contract-fixtures.ts`
+- Modify: `test/fixtures/coding-cli/codex-app-server/schema-inventory.ts`
+- Modify: `test/fixtures/coding-cli/codex-app-server/schema-traceability.ts`
+- Test: `test/unit/server/fresh-agent/codex-normalize.test.ts`
+- Test: `test/unit/server/fresh-agent/codex-adapter.test.ts`
+- Test: `test/unit/server/fresh-agent/runtime-manager.test.ts`
+- Test: `test/unit/server/ws-handler-fresh-agent.test.ts`
+- Test: `test/unit/client/lib/fresh-agent-ws.test.ts`
+
+- [ ] **Step 1: Write failing normalization and event tests**
+
+Require all documented Codex item variants to normalize into `FreshAgentTranscriptItemSchema` variants. Build every raw fixture with a helper that first parses the fixture through `CodexThreadItemSchema` or `CodexTurnSchema`:
+
+Drive this fixture list from `schema-traceability.ts`, not from hand-maintained test arrays. The normalization test should iterate every traceability entry with `generatedKind: 'threadItem'` and require either a `normalizer` plus `freshAgentSchema` fixture that parses, or an `intentionalOmission` with a typed supported-negative behavior. The concrete examples below are minimum regression fixtures for the historical nonconvergence findings; the traceability matrix is what proves the list is exhaustive.
+
+```ts
+function parseCodexItemFixture(value: unknown): CodexThreadItem {
+ return CodexThreadItemSchema.parse(value)
+}
+
+function parseCodexTurnFixture(value: unknown): CodexTurn {
+ return CodexTurnSchema.parse(value)
+}
+```
+
+If the generated schema requires fields not shown in a short example below, the test fixture must include those fields. For Codex CLI 0.128.0, for example, `commandExecution` requires `cwd`, `source`, and `commandActions`; `agentMessage` includes `phase` and `memoryCitation`; `imageGeneration` includes `result`; and `contextCompaction` contains only `id`.
+
+```ts
+expect(normalizeCodexItem(parseCodexItemFixture({
+ type: 'userMessage',
+ id: 'u1',
+ content: [{ type: 'text', text: 'Do it', text_elements: [{ byteRange: { start: 0, end: 2 }, placeholder: null }] }],
+})))
+ .toEqual([{ id: 'u1', kind: 'message', role: 'user', content: [{ kind: 'text', text: 'Do it', textElements: [{ byteRange: { start: 0, end: 2 }, placeholder: null }] }] }])
+
+expect(normalizeCodexItem(parseCodexItemFixture({
+ type: 'userMessage',
+ id: 'u2',
+ content: [
+ { type: 'text', text: 'Use this mockup', text_elements: [{ byteRange: { start: 0, end: 4 }, placeholder: 'Use' }] },
+ { type: 'image', url: 'https://example.test/mockup.png' },
+ { type: 'localImage', path: '/tmp/mockup.png' },
+ { type: 'mention', name: 'README.md', path: '/repo/README.md' },
+ { type: 'skill', name: 'reviewer', path: '/repo/.codex/skills/reviewer/SKILL.md' },
+ ],
+}))).toEqual([{
+ id: 'u2',
+ kind: 'message',
+ role: 'user',
+ content: [
+ { kind: 'text', text: 'Use this mockup', textElements: [{ byteRange: { start: 0, end: 4 }, placeholder: 'Use' }] },
+ { kind: 'image', url: 'https://example.test/mockup.png' },
+ { kind: 'image', path: '/tmp/mockup.png' },
+ { kind: 'mention', name: 'README.md', path: '/repo/README.md' },
+ { kind: 'skill', name: 'reviewer', path: '/repo/.codex/skills/reviewer/SKILL.md' },
+ ],
+}])
+
+expect(normalizeCodexItem(parseCodexItemFixture({ type: 'hookPrompt', id: 'h1', fragments: [{ text: 'Preflight', hookRunId: 'hook-1' }] })))
+ .toEqual([expect.objectContaining({ id: 'h1', kind: 'hook_prompt', fragments: [{ text: 'Preflight', hookRunId: 'hook-1' }] })])
+
+expect(normalizeCodexItem(parseCodexItemFixture({
+ type: 'agentMessage',
+ id: 'a1',
+ text: 'Done',
+ phase: 'final_answer',
+ memoryCitation: {
+ entries: [{ path: '/repo/AGENTS.md', lineStart: 1, lineEnd: 4, note: 'Project instruction' }],
+ threadIds: ['thread-memory-1'],
+ },
+})))
+ .toEqual([{
+ id: 'a1',
+ kind: 'message',
+ role: 'assistant',
+ phase: 'final_answer',
+ memoryCitation: {
+ entries: [{ path: '/repo/AGENTS.md', lineStart: 1, lineEnd: 4, note: 'Project instruction' }],
+ threadIds: ['thread-memory-1'],
+ },
+ content: [{ kind: 'text', text: 'Done' }],
+ }])
+
+expect(normalizeCodexItem(parseCodexItemFixture({
+ type: 'reasoning',
+ id: 'r1',
+ summary: ['Checked repository state'],
+ content: ['First reasoning paragraph', 'Second reasoning paragraph'],
+})))
+ .toEqual([expect.objectContaining({
+ id: 'r1',
+ kind: 'reasoning',
+ summary: ['Checked repository state'],
+ content: ['First reasoning paragraph', 'Second reasoning paragraph'],
+ })])
+
+expect(normalizeCodexItem(parseCodexItemFixture({
+ type: 'commandExecution',
+ id: 'c1',
+ command: 'npm test',
+ cwd: '/repo',
+ processId: null,
+ source: 'agent',
+ status: 'completed',
+ commandActions: [{ type: 'search', command: 'rg Freshcodex', query: 'Freshcodex', path: '/repo' }],
+ aggregatedOutput: 'ok',
+ exitCode: 0,
+ durationMs: 10,
+})))
+ .toEqual([expect.objectContaining({
+ id: 'c1',
+ kind: 'command',
+ command: 'npm test',
+ source: 'agent',
+ commandActions: [{ type: 'search', command: 'rg Freshcodex', query: 'Freshcodex', path: '/repo' }],
+ status: 'completed',
+ output: 'ok',
+ durationMs: 10,
+ })])
+
+expect(normalizeCodexItem(parseCodexItemFixture({
+ type: 'commandExecution',
+ id: 'c-running',
+ command: 'npm test',
+ cwd: '/repo',
+ processId: null,
+ source: 'agent',
+ status: 'inProgress',
+ commandActions: [],
+ aggregatedOutput: null,
+ exitCode: null,
+ durationMs: null,
+})))
+ .toEqual([expect.objectContaining({ id: 'c-running', kind: 'command', status: 'running' })])
+
+expect(normalizeCodexItem(parseCodexItemFixture({ type: 'fileChange', id: 'f1', status: 'completed', changes: [{ path: 'src/a.ts', kind: { type: 'update', move_path: null }, diff: '@@' }] })))
+ .toEqual([expect.objectContaining({ id: 'f1', kind: 'file_change', changes: [{ path: 'src/a.ts', changeKind: 'modify', diff: '@@' }] })])
+
+expect(normalizeCodexItem(parseCodexItemFixture({
+ type: 'fileChange',
+ id: 'f-rename',
+ status: 'completed',
+ changes: [{ path: 'src/new-name.ts', kind: { type: 'update', move_path: 'src/old-name.ts' }, diff: '@@' }],
+})))
+ .toEqual([expect.objectContaining({
+ id: 'f-rename',
+ kind: 'file_change',
+ changes: [{ path: 'src/new-name.ts', changeKind: 'rename', movePath: 'src/old-name.ts', diff: '@@' }],
+ })])
+
+expect(normalizeCodexItem(parseCodexItemFixture({
+ type: 'fileChange',
+ id: 'f-running',
+ status: 'inProgress',
+ changes: [{ path: 'src/a.ts', kind: { type: 'update', move_path: null }, diff: '' }],
+})))
+ .toEqual([expect.objectContaining({ id: 'f-running', kind: 'file_change', status: 'running' })])
+
+expect(() => normalizeCodexItem({ type: 'newUnknownItem', id: 'u1' }))
+ .toThrow(/unsupported Codex item/i)
+
+expect(normalizeCodexItem(parseCodexItemFixture({ type: 'contextCompaction', id: 'compact-1' })))
+ .toEqual([expect.objectContaining({ id: 'compact-1', kind: 'context_compaction' })])
+
+expect(normalizeCodexItem(parseCodexItemFixture({
+ type: 'dynamicToolCall',
+ id: 'dyn-1',
+ namespace: null,
+ tool: 'tool-x',
+ arguments: ['non-object', { ok: true }],
+ status: 'failed',
+ contentItems: [{ type: 'inputText', text: 'Dynamic tool calls are not supported by Freshell yet.' }],
+ success: false,
+ durationMs: null,
+})))
+ .toEqual([expect.objectContaining({
+ id: 'dyn-1',
+ kind: 'dynamic_tool',
+ namespace: null,
+ input: ['non-object', { ok: true }],
+ status: 'failed',
+ contentItems: [{ type: 'inputText', text: 'Dynamic tool calls are not supported by Freshell yet.' }],
+ success: false,
+ })])
+
+expect(normalizeCodexItem(parseCodexItemFixture({
+ type: 'mcpToolCall',
+ id: 'mcp-completed',
+ server: 'fixture-server',
+ tool: 'fixture-tool',
+ status: 'completed',
+ arguments: 'raw-string-argument',
+ mcpAppResourceUri: 'mcp://fixture/resource',
+ result: { content: [{ type: 'text', text: 'ok' }], structuredContent: { ok: true }, _meta: null },
+ error: null,
+ durationMs: 12,
+})))
+ .toEqual([expect.objectContaining({
+ id: 'mcp-completed',
+ kind: 'tool',
+ server: 'fixture-server',
+ name: 'fixture-tool',
+ status: 'completed',
+ input: 'raw-string-argument',
+ mcpAppResourceUri: 'mcp://fixture/resource',
+ result: { content: [{ type: 'text', text: 'ok' }], structuredContent: { ok: true }, _meta: null },
+ durationMs: 12,
+ })])
+
+expect(normalizeCodexItem(parseCodexItemFixture({
+ type: 'mcpToolCall',
+ id: 'mcp-running',
+ server: 'fixture-server',
+ tool: 'fixture-tool',
+ status: 'inProgress',
+ arguments: {},
+ result: null,
+ error: null,
+ durationMs: null,
+})))
+ .toEqual([expect.objectContaining({ id: 'mcp-running', kind: 'tool', server: 'fixture-server', name: 'fixture-tool', status: 'running' })])
+
+expect(normalizeCodexItem(parseCodexItemFixture({
+ type: 'dynamicToolCall',
+ id: 'dyn-running',
+ namespace: 'fixture-namespace',
+ tool: 'tool-x',
+ arguments: {},
+ status: 'inProgress',
+ contentItems: null,
+ success: null,
+ durationMs: null,
+})))
+ .toEqual([expect.objectContaining({ id: 'dyn-running', kind: 'dynamic_tool', namespace: 'fixture-namespace', status: 'running' })])
+
+expect(normalizeCodexItem(parseCodexItemFixture({
+ type: 'collabAgentToolCall',
+ id: 'collab-running',
+ tool: 'spawnAgent',
+ status: 'inProgress',
+ senderThreadId: 'thread-1',
+ receiverThreadIds: ['thread-child-1', 'thread-child-2'],
+ prompt: 'Review this',
+ model: 'configured-model',
+ reasoningEffort: 'high',
+ agentsStates: {
+ 'thread-child-1': { status: 'running', message: 'Working' },
+ 'thread-child-2': { status: 'completed', message: null },
+ },
+})))
+ .toEqual([expect.objectContaining({
+ id: 'collab-running',
+ kind: 'collaboration',
+ status: 'running',
+ receiverThreadIds: ['thread-child-1', 'thread-child-2'],
+ agentsStates: expect.objectContaining({ 'thread-child-1': expect.any(Object) }),
+ })])
+
+expect(normalizeCodexItem(parseCodexItemFixture({ type: 'imageGeneration', id: 'img-gen-1', status: 'completed', revisedPrompt: 'diagram', result: 'https://example.test/generated.png' })))
+ .toEqual([expect.objectContaining({ id: 'img-gen-1', kind: 'image_generation', prompt: 'diagram', result: 'https://example.test/generated.png' })])
+
+expect(normalizeCodexItem(parseCodexItemFixture({ type: 'imageGeneration', id: 'img-gen-custom', status: 'provider_specific_status', revisedPrompt: null, result: 'https://example.test/generated.png', savedPath: '/repo/generated.png' })))
+ .toEqual([expect.objectContaining({ id: 'img-gen-custom', kind: 'image_generation', status: 'provider_specific_status', result: 'https://example.test/generated.png', savedPath: '/repo/generated.png' })])
+
+expect(normalizeCodexItem(parseCodexItemFixture({
+ type: 'webSearch',
+ id: 'web-1',
+ query: 'Freshcodex',
+ action: { type: 'findInPage', url: 'https://example.test/docs', pattern: 'Codex' },
+})))
+ .toEqual([expect.objectContaining({
+ id: 'web-1',
+ kind: 'web_search',
+ query: 'Freshcodex',
+ action: { type: 'findInPage', url: 'https://example.test/docs', pattern: 'Codex' },
+ })])
+```
+
+Add table-driven coverage for every local generated `ThreadItem` type: `userMessage`, `hookPrompt`, `agentMessage`, `plan`, `reasoning`, `commandExecution`, `fileChange`, `mcpToolCall`, `dynamicToolCall`, `collabAgentToolCall`, `webSearch`, `imageView`, `imageGeneration`, `enteredReviewMode`, `exitedReviewMode`, and `contextCompaction`. The table must derive the expected type names from `schema-inventory.ts` and fail if the checked-in generated `ThreadItem.ts` contains a variant with no schema-valid fixture.
+
+Require adapter methods:
+
+```ts
+runtime.startTurn.mockResolvedValue({ turn: schemaValidTurn({ id: 'turn-1' }) })
+await adapter.send?.('thread-1', { text: 'Ship it' })
+expect(runtime.startTurn).toHaveBeenCalledWith(expect.objectContaining({
+ threadId: 'thread-1',
+ input: [{ type: 'text', text: 'Ship it', text_elements: [] }],
+}))
+
+await adapter.send?.('thread-1', {
+ text: 'Use this mockup',
+ images: [{ kind: 'url', url: 'https://example.test/mockup.png', mediaType: 'image/png' }],
+ runtimeSettings: {
+ model: 'configured-model',
+ sandbox: 'workspace-write',
+ permissionMode: 'on-request',
+ effort: 'xhigh',
+ },
+})
+expect(runtime.startTurn).toHaveBeenCalledWith(expect.objectContaining({
+ threadId: 'thread-1',
+ model: 'configured-model',
+ sandboxPolicy: expect.anything(),
+ approvalPolicy: expect.anything(),
+ effort: 'xhigh',
+ input: [
+ { type: 'text', text: 'Use this mockup', text_elements: [] },
+ { type: 'image', url: 'https://example.test/mockup.png' },
+ ],
+}))
+
+await adapter.send?.('thread-1', {
+ images: [
+ { kind: 'data', mediaType: 'image/png', data: 'AQID' },
+ { kind: 'local', path: '/repo/mockup.png', mediaType: 'image/png' },
+ ],
+})
+expect(runtime.startTurn).toHaveBeenLastCalledWith(expect.objectContaining({
+ threadId: 'thread-1',
+ input: [
+ { type: 'image', url: 'data:image/png;base64,AQID' },
+ { type: 'localImage', path: '/repo/mockup.png' },
+ ],
+}))
+
+await expect(adapter.send?.('thread-1', {
+ text: 'Invalid codex settings',
+ runtimeSettings: { permissionMode: 'bypassPermissions', effort: 'max' },
+})).rejects.toMatchObject({ code: 'FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING' })
+
+await adapter.interrupt?.('thread-1')
+expect(runtime.interruptTurn).toHaveBeenCalledWith({ threadId: 'thread-1', turnId: 'turn-1' })
+
+await expect(adapter.interrupt?.('thread-without-active-turn'))
+ .rejects.toMatchObject({ code: 'FRESH_AGENT_NO_ACTIVE_TURN' })
+
+runtime.readThread.mockResolvedValue({
+ thread: schemaValidThread({
+ id: 'thread-resumed-running',
+ status: { type: 'active', activeFlags: [] },
+ turns: [],
+ }),
+})
+runtime.listThreadTurns.mockResolvedValue({
+ data: [schemaValidTurn({ id: 'turn-running-1', status: 'inProgress', items: [] })],
+ nextCursor: null,
+ backwardsCursor: null,
+})
+await adapter.getSnapshot?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-resumed-running' })
+await adapter.interrupt?.('thread-resumed-running')
+expect(runtime.interruptTurn).toHaveBeenCalledWith({
+ threadId: 'thread-resumed-running',
+ turnId: 'turn-running-1',
+})
+
+runtime.forkThread.mockResolvedValue(schemaValidThreadLifecycleResult({ thread: schemaValidThread({ id: 'thread-fork-1', turns: [] }) }))
+await expect(adapter.fork?.('thread-1', { excludeTurns: true }))
+ .resolves.toMatchObject({ sessionId: 'thread-fork-1', parentThreadId: 'thread-1' })
+
+runtime.readThread
+ .mockRejectedValueOnce({ code: 'FRESH_AGENT_LOST_SESSION' })
+ .mockResolvedValueOnce({ thread: schemaValidThread({ id: 'thread-restored-1', turns: [] }) })
+runtime.resumeThread.mockResolvedValue(schemaValidThreadLifecycleResult({
+ thread: schemaValidThread({ id: 'thread-restored-1', turns: [] }),
+}))
+await adapter.getSnapshot?.({
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ threadId: 'thread-restored-1',
+}, {
+ cwd: '/repo',
+ runtimeSettings: { model: 'configured-model', sandbox: 'workspace-write', permissionMode: 'on-request', effort: 'xhigh' },
+})
+expect(runtime.resumeThread).toHaveBeenCalledWith(expect.objectContaining({
+ threadId: 'thread-restored-1',
+ excludeTurns: true,
+ cwd: '/repo',
+}))
+expect(runtime.listThreadTurns).toHaveBeenCalledWith(expect.objectContaining({
+ threadId: 'thread-restored-1',
+ limit: expect.any(Number),
+}))
+
+await expect(adapter.startReview?.('thread-1')).resolves.toMatchObject({
+ turnId: expect.any(String),
+ reviewThreadId: 'thread-1',
+ target: { type: 'uncommittedChanges' },
+ delivery: 'inline',
+})
+expect(runtime.startReview).toHaveBeenCalledWith({
+ threadId: 'thread-1',
+ target: { type: 'uncommittedChanges' },
+ delivery: 'inline',
+})
+
+await expect(adapter.listThreads?.({ limit: 25 })).resolves.toMatchObject({
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ items: [expect.objectContaining({ sessionType: 'freshcodex', provider: 'codex', runtimeProvider: 'codex' })],
+ nextCursor: null,
+ backwardsCursor: null,
+})
+
+runtime.listThreads.mockResolvedValue({
+ data: [schemaValidThread({
+ id: 'thread-child-1',
+ source: { subAgent: { thread_spawn: {
+ parent_thread_id: 'thread-parent-1',
+ depth: 1,
+ agent_path: null,
+ agent_nickname: 'reviewer',
+ agent_role: 'review',
+ } } },
+ turns: [],
+ })],
+ nextCursor: null,
+ backwardsCursor: null,
+})
+await expect(adapter.listThreads?.({ limit: 25 })).resolves.toMatchObject({
+ items: [expect.objectContaining({
+ sessionId: 'thread-child-1',
+ source: expect.objectContaining({
+ subAgent: expect.objectContaining({
+ thread_spawn: expect.objectContaining({ parent_thread_id: 'thread-parent-1' }),
+ }),
+ }),
+ parentThreadId: 'thread-parent-1',
+ })],
+})
+
+runtime.listModels.mockResolvedValue({
+ data: [schemaValidModel({ id: 'model-page-1', model: 'model-page-1' })],
+ nextCursor: 'next-model-page',
+})
+await expect(adapter.listModels?.({ limit: 25 })).resolves.toMatchObject({
+ items: [expect.objectContaining({ id: 'model-page-1' })],
+ nextCursor: 'next-model-page',
+})
+
+await expect(adapter.getTurnPage?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1' }, { revision: 7, limit: 25, sortDirection: 'desc' }))
+ .resolves.toMatchObject({ provider: 'codex', threadId: 'thread-new-1' })
+
+await adapter.getTurnPage?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1' }, { revision: 7, limit: 25, sortDirection: 'desc' })
+await expect(adapter.getTurnBody?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1', turnId: 'turn-1' }, 7))
+ .resolves.toMatchObject({ provider: 'codex', threadId: 'thread-new-1', turnId: 'turn-1' })
+expect(runtime.readThread).not.toHaveBeenCalledWith({ threadId: 'thread-new-1', includeTurns: true })
+```
+
+Require server-request approval mapping:
+
+```ts
+emitServerRequest('item/commandExecution/requestApproval', { threadId: 'thread-1', turnId: 'turn-1', itemId: 'cmd-1', command: 'npm test' })
+expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.snapshot.invalidate' }))
+expect(await adapter.getSnapshot?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-1' }))
+ .toMatchObject({ pendingApprovals: [{ requestId: expect.stringContaining('cmd-1') }] })
+```
+
+Add table-driven server-request coverage for every local generated `ServerRequest` method. Build each request with a helper that parses `{ id, method, params }` through `CodexServerRequestSchema` before the adapter sees it, because several request variants have required structured params beyond `threadId`. `item/commandExecution/requestApproval`, `item/fileChange/requestApproval`, and `item/permissions/requestApproval` become pending approvals; `item/tool/requestUserInput` and `mcpServer/elicitation/request` become pending questions; `item/tool/call` receives an explicit generated-shape dynamic-tool result such as `{ contentItems: [{ type: 'inputText', text: 'Dynamic tool calls are not supported by Freshell yet.' }], success: false }`; `account/chatgptAuthTokens/refresh` receives a JSON-RPC error response on the same request id because its success shape requires real token fields and, because it has no thread locator, also emits a runtime-global `freshAgent.error` or equivalent runtime event to all subscribed Freshcodex panes for that rich runtime. Legacy root `applyPatchApproval` and `execCommandApproval` are mapped to pending approval prompts when generated schema still includes them, but their thread locator is `params.conversationId`, not `params.threadId`, and their response decision schema is root `ReviewDecision`, not v2 command/file approval decisions. `serverRequest/resolved` must remove matching pending approval/question/request state by generated `requestId`.
+
+The same table must verify response serialization for each interactive request method through the fresh-agent action contract, not only through low-level client tests:
+
+```ts
+await adapter.respondToServerRequest?.('thread-1', {
+ requestId: 42,
+ kind: 'tool_user_input',
+ answers: { choice: { answers: ['a'] } },
+})
+expect(runtime.respondToServerRequest).toHaveBeenCalledWith(42, {
+ answers: { choice: { answers: ['a'] } },
+})
+
+await adapter.respondToServerRequest?.('thread-1', {
+ requestId: 'user-input-1',
+ kind: 'tool_user_input',
+ answers: { choice: { answers: ['a'] } },
+})
+expect(runtime.respondToServerRequest).toHaveBeenCalledWith('user-input-1', {
+ answers: { choice: { answers: ['a'] } },
+})
+
+await adapter.respondToServerRequest?.('thread-1', {
+ requestId: 'mcp-elicit-1',
+ kind: 'mcp_elicitation',
+ action: 'accept',
+ content: { selected: true },
+ _meta: null,
+})
+expect(runtime.respondToServerRequest).toHaveBeenCalledWith('mcp-elicit-1', {
+ action: 'accept',
+ content: { selected: true },
+ _meta: null,
+})
+
+await adapter.respondToServerRequest?.('thread-1', {
+ requestId: 'permissions-1',
+ kind: 'permissions_approval',
+ permissions: grantedPermissionFixture,
+ scope: 'session',
+})
+expect(runtime.respondToServerRequest).toHaveBeenCalledWith('permissions-1', {
+ permissions: grantedPermissionFixture,
+ scope: 'session',
+})
+
+await adapter.respondToServerRequest?.('thread-1', {
+ requestId: 'dynamic-tool-1',
+ kind: 'dynamic_tool',
+ contentItems: [{ type: 'inputText', text: 'Dynamic tool calls are not supported by Freshell yet.' }],
+ success: false,
+})
+expect(runtime.respondToServerRequest).toHaveBeenCalledWith('dynamic-tool-1', {
+ contentItems: [{ type: 'inputText', text: 'Dynamic tool calls are not supported by Freshell yet.' }],
+ success: false,
+})
+
+emitSchemaValidServerRequest({
+ id: 'legacy-patch-request-1',
+ method: 'applyPatchApproval',
+ params: {
+ conversationId: 'thread-1',
+ callId: 'patch-1',
+ fileChanges: {},
+ },
+})
+expect(await adapter.getSnapshot?.({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-1' }))
+ .toMatchObject({ pendingApprovals: [expect.objectContaining({ requestId: expect.anything() })] })
+
+await adapter.respondToServerRequest?.('thread-1', {
+ requestId: 'legacy-patch-request-1',
+ kind: 'legacy_patch_approval',
+ decision: 'approved',
+})
+expect(runtime.respondToServerRequest).toHaveBeenCalledWith('legacy-patch-request-1', {
+ decision: 'approved',
+})
+
+emitSchemaValidServerRequest({
+ id: 'legacy-exec-request-1',
+ method: 'execCommandApproval',
+ params: {
+ conversationId: 'thread-1',
+ callId: 'exec-1',
+ approvalId: null,
+ command: ['npm', 'test'],
+ cwd: '/repo',
+ parsedCmd: [],
+ },
+})
+await adapter.respondToServerRequest?.('thread-1', {
+ requestId: 'legacy-exec-request-1',
+ kind: 'legacy_exec_approval',
+ decision: 'denied',
+})
+expect(runtime.respondToServerRequest).toHaveBeenCalledWith('legacy-exec-request-1', {
+ decision: 'denied',
+})
+```
+
+Add table-driven notification coverage for every local generated `ServerNotification` method that can change visible Freshcodex state:
+
+```ts
+function emitSchemaValidNotification(method: string, overrides: Record = {}) {
+ const params = schemaValidNotificationParams(method, overrides)
+ const notification = CodexServerNotificationSchema.parse({ method, params })
+ emitNotification(notification.method, notification.params)
+}
+
+it.each([
+ ['thread/started'],
+ ['turn/started'],
+ ['turn/completed'],
+ ['item/started'],
+ ['item/completed'],
+ ['thread/status/changed'],
+ ['thread/tokenUsage/updated'],
+ ['turn/diff/updated'],
+ ['turn/plan/updated'],
+ ['thread/compacted'],
+ ['thread/name/updated'],
+ ['thread/closed'],
+ ['thread/archived'],
+ ['thread/realtime/error'],
+])('invalidates the Freshcodex snapshot for %s notifications', async (method) => {
+ const listener = vi.fn()
+ await adapter.subscribe?.('thread-1', listener)
+ emitSchemaValidNotification(method, { threadId: 'thread-1' })
+ expect(listener).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'freshAgent.snapshot.invalidate',
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ threadId: 'thread-1',
+ reason: method,
+ }))
+})
+```
+
+If the generated schema uses different method names or params, use the generated names and generated params in the test table. The executor must add every visible-state notification method present in `ServerNotification.json`; do not shrink the table to the example above. Do not emit `{ threadId }`-only fake notifications for methods whose generated params require a `Turn`, `ThreadItem`, token-usage object, realtime payload, or another structured body; every notification fixture must parse through `CodexServerNotificationSchema` before it reaches the adapter.
+
+Also add negative routing tests for generated notifications with no Freshcodex thread locator:
+
+```ts
+it.each([
+ ['command/exec/outputDelta', { processId: 'process-1', stream: 'stdout', deltaBase64: '', capReached: false }],
+ ['fs/changed', { watchId: 'watch-1', changedPaths: ['/repo/file.ts'] }],
+ ['warning', { threadId: null, message: 'Runtime warning' }],
+])('does not invalidate the subscribed thread for %s without a thread locator', async (method, params) => {
+ const listener = vi.fn()
+ await adapter.subscribe?.('thread-1', listener)
+ emitNotification(method, CodexServerNotificationSchema.parse({ method, params }).params)
+ expect(listener).not.toHaveBeenCalledWith(expect.objectContaining({
+ type: 'freshAgent.snapshot.invalidate',
+ threadId: 'thread-1',
+ reason: method,
+ }))
+})
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts \
+ test/unit/server/fresh-agent/codex-normalize.test.ts \
+ test/unit/server/fresh-agent/codex-adapter.test.ts \
+ test/unit/server/fresh-agent/runtime-manager.test.ts \
+ test/unit/server/ws-handler-fresh-agent.test.ts \
+ test/unit/client/lib/fresh-agent-ws.test.ts
+```
+
+Expected: FAIL because Codex items are still raw-ish and actions are incomplete.
+
+- [ ] **Step 3: Implement normalization and action adapter**
+
+In `normalize.ts`, expose focused pure helpers:
+
+```ts
+export function normalizeCodexThreadStatus(raw: unknown): FreshAgentThreadStatus
+export function normalizeCodexItem(raw: unknown): FreshAgentTranscriptItem[]
+export function normalizeCodexTurnBody(input: { sessionType: 'freshcodex'; provider: 'codex'; threadId: string; revision: number; rawTurn: CodexTurn }): FreshAgentTurnBody
+export function normalizeCodexTurnPage(input: { threadId: string; revision: number; page: CodexThreadTurnsListResult }): FreshAgentTurnPage
+export function normalizeCodexThreadSnapshot(input: ...): FreshAgentThreadSnapshot
+```
+
+Map generated Codex status objects explicitly. The current app-server schema represents thread status as `{ type: 'notLoaded' | 'idle' | 'systemError' | 'active', activeFlags: [...] }`, not as a bare string. Preserve active flags such as `waitingOnApproval` and `waitingOnUserInput` under the Codex extension while mapping them to a shared running status:
+
+```ts
+export function normalizeCodexThreadStatus(raw: unknown): FreshAgentThreadStatus {
+ const parsed = CodexThreadStatusSchema.parse(raw)
+ switch (parsed.type) {
+ case 'notLoaded':
+ case 'idle':
+ return 'idle'
+ case 'systemError':
+ return 'error'
+ case 'active':
+ return 'running'
+ }
+}
+```
+
+Throw a clear `UnsupportedCodexItemError` for item types not intentionally modeled. Normalize actual app-server shapes from the generated `Thread` / `Turn` / `ThreadItem` schemas:
+
+Map generated item statuses through one small helper and use it for every transcript item kind that carries a status:
+
+```ts
+function normalizeCodexItemStatus(status: 'inProgress' | 'completed' | 'failed' | 'declined'): 'running' | 'completed' | 'failed' | 'declined' {
+ return status === 'inProgress' ? 'running' : status
+}
+```
+
+Do not reuse `normalizeCodexThreadStatus` or `TurnStatus` handling for item statuses; those are different generated leaf types. Tests must cover the active `"inProgress"` case for command executions, file changes, MCP tool calls, dynamic tool calls, and collab-agent tool calls because active live updates are the path most likely to hit the mismatch.
+
+Map generated patch-change kinds through another focused helper:
+
+```ts
+function normalizeCodexPatchChangeKind(kind: CodexPatchChangeKind): { changeKind: 'add' | 'modify' | 'delete' | 'rename'; movePath?: string } {
+ switch (kind.type) {
+ case 'add':
+ return { changeKind: 'add' }
+ case 'delete':
+ return { changeKind: 'delete' }
+ case 'update':
+ return kind.move_path
+ ? { changeKind: 'rename', movePath: kind.move_path }
+ : { changeKind: 'modify' }
+ }
+}
+```
+
+Use this helper for every `fileChange.changes[]` entry before parsing the normalized item through `FreshAgentFileChangeItemSchema`; do not drop `move_path`.
+
+```ts
+export function normalizeCodexThreadSnapshot(input: {
+ thread: CodexThread
+ normalizedRevision: number
+ pendingApprovals: PendingCodexApproval[]
+ pendingQuestions: PendingCodexQuestion[]
+ tokenUsage?: FreshAgentThreadSnapshot['tokenUsage']
+}): FreshAgentThreadSnapshot
+
+export function normalizeCodexTurnPage(input: {
+ sessionType: 'freshcodex'
+ provider: 'codex'
+ threadId: string
+ revision: number
+ page: CodexThreadTurnsListResult // { data, nextCursor, backwardsCursor }
+}): FreshAgentTurnPage
+```
+
+Do not read `rawSnapshot.revision`, `rawSnapshot.turns`, or `page.turns`; those are stale assumptions from Freshell's provisional protocol. Use `raw.thread` from `thread/read { includeTurns: false }` for snapshot metadata and `page.data` from `thread/turns/list` for turn bodies. `normalizeCodexTurnPage` should place each page turn's normalized body on the matching `FreshAgentTurnSummary.body` and should also populate the adapter's bounded turn-body cache.
+Do not require or synthesize a turn-level role for Codex. `normalizeCodexTurnBody` must set `role` only when a legacy provider supplies one, and must preserve Codex user/assistant roles on `message` transcript items. `normalizeCodexItem` must return an array and `normalizeCodexTurnBody` must flatten those arrays:
+
+```ts
+const items = rawTurn.items.flatMap((item) => normalizeCodexItem(item))
+const codexUnixSecondsToIso = (value: number | null | undefined) => (
+ typeof value === 'number' ? new Date(value * 1000).toISOString() : undefined
+)
+return FreshAgentTurnBodySchema.parse({
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ threadId,
+ turnId: rawTurn.id,
+ revision,
+ source: 'durable',
+ startedAt: codexUnixSecondsToIso(rawTurn.startedAt),
+ completedAt: codexUnixSecondsToIso(rawTurn.completedAt),
+ items,
+})
+```
+
+The `startedAt` and `completedAt` conversion is required because the local Codex app-server schema emits Unix seconds, while the fresh-agent UI contract uses ISO strings. Tests should include non-null numeric timestamps and assert the ISO conversion so the raw Codex schema is not accidentally modeled as strings.
+
+In `adapter.ts`, track per-thread ephemeral live state:
+
+```ts
+type CodexLiveThreadState = {
+ pendingApprovals: Map
+ pendingQuestions: Map
+ activeTurnId?: string
+ latestRevision?: number
+ tokenUsage?: FreshAgentThreadSnapshot['tokenUsage']
+}
+```
+
+Implement `adapter.subscribe(sessionId, listener)` for Codex by subscribing to the rich runtime notification stream and translating visible app-server events into fresh-agent events:
+
+```ts
+return await runtime.subscribe(sessionId, (event) => {
+ if (isCodexServerRequest(event)) {
+ const routedThreadId = getServerRequestThreadId(event) // params.threadId for v2, params.conversationId for legacy root approvals
+ if (!routedThreadId) {
+ respondToUnsupportedRuntimeGlobalRequest(event)
+ listener({ type: 'freshAgent.error', sessionId, sessionType: 'freshcodex', provider: 'codex', code: 'FRESH_AGENT_UNSUPPORTED_AUTH_REFRESH', message: 'Freshell cannot refresh Codex ChatGPT auth tokens from this runtime.', retryable: false })
+ return
+ }
+ updatePendingRequestState(routedThreadId, event)
+ listener({ type: 'freshAgent.snapshot.invalidate', sessionType: 'freshcodex', provider: 'codex', threadId: routedThreadId, reason: event.method })
+ return
+ }
+ if (isCodexServerNotification(event)) {
+ const route = getCodexNotificationRoute(event)
+ if (route.kind === 'thread') {
+ if (route.threadId !== sessionId) return
+ updateLiveThreadStateFromNotification(route.threadId, event)
+ listener({ type: 'freshAgent.snapshot.invalidate', sessionType: 'freshcodex', provider: 'codex', threadId: route.threadId, reason: event.method })
+ return
+ }
+ if (route.kind === 'runtimeGlobal') {
+ listener({ type: 'freshAgent.runtimeEvent', sessionType: 'freshcodex', provider: 'codex', event })
+ return
+ }
+ if (route.kind === 'connectionScoped') {
+ logIgnoredConnectionScopedNotification(event.method, route.owner)
+ return
+ }
+ }
+})
+```
+
+`getCodexNotificationRoute` must be a generated-method-specific table, not a generic `params.threadId ?? sessionId` fallback. It must cover `thread/started` via `params.thread.id`, nullable-thread `warning`, and no-locator runtime/global or connection-scoped notifications. `turn/started` and `turn/completed` must update `activeTurnId`; `thread/tokenUsage/updated` must update `tokenUsage` in live state so the next snapshot rebuild includes current token counts; status, diff, review, compaction, item, metadata/name, close/archive, realtime error/close, and child-agent/collaboration notifications must invalidate the snapshot so every subscribed browser refreshes from the normalized app-server source. Non-visible notifications may be ignored only through an explicit allowlist with a comment naming why they do not affect the Freshcodex UI.
+`getSnapshot` and `resume` must also recover `activeTurnId` without loading the full transcript. First read metadata with `thread/read { includeTurns: false }`, then fetch a bounded newest-first page with `thread/turns/list { limit: 10, sortDirection: 'desc' }` and select the newest `status: 'inProgress'` turn if present. This is required for interrupt to work after a browser reconnect, server restart, or adapter resubscription that missed the original `turn/started` notification while preserving long-transcript scalability.
+
+Implement `send`, `interrupt`, `fork`, and `respondToServerRequest` using the Freshcodex stdio rich runtime from Task 4, not the websocket launch planner runtime. `send` must store the active turn id from `turn/start -> { turn }`; `turn/started`, `turn/completed`, and runtime close/error notifications must keep `activeTurnId` current. `interrupt(locator)` remains the Fresh-agent API because the UI interrupts the active turn, but the Codex adapter must translate that to `turn/interrupt { threadId, turnId: activeTurnId }` and return a clear `FRESH_AGENT_NO_ACTIVE_TURN` action error if there is no active turn. `respondToServerRequest` must look up the pending request by generated request id, validate that the response `kind` matches the original generated server request method, serialize the generated response shape, and respond on the original JSON-RPC server request id. Do not keep separate `resolveApproval` / `answerQuestion` action paths for Codex; those names encourage collapsing permissions approvals, request-user-input prompts, and MCP elicitations into the wrong Claude-shaped payload.
+
+Add a single routing helper for generated server requests:
+
+```ts
+function getServerRequestThreadId(request: CodexServerRequest): string | null {
+ if ('threadId' in request.params && typeof request.params.threadId === 'string') return request.params.threadId
+ if ('conversationId' in request.params && typeof request.params.conversationId === 'string') return request.params.conversationId
+ return null
+}
+```
+
+Only `account/chatgptAuthTokens/refresh` should currently return `null`. Legacy `applyPatchApproval` and `execCommandApproval` must use `conversationId` to update the correct thread's pending approval state and must respond with `CodexLegacyApplyPatchApprovalResponseSchema` / `CodexLegacyExecCommandApprovalResponseSchema` payloads.
+
+Add a separate routing helper for generated server notifications:
+
+```ts
+type CodexNotificationRoute =
+ | { kind: 'thread'; threadId: string }
+ | { kind: 'runtimeGlobal' }
+ | { kind: 'connectionScoped'; owner: 'unsupported-command-exec' | 'unsupported-fs-watch' | 'runtime-capability' }
+ | { kind: 'nonVisible' }
+
+function getCodexNotificationRoute(notification: CodexServerNotification): CodexNotificationRoute {
+ switch (notification.method) {
+ case 'thread/started':
+ return { kind: 'thread', threadId: notification.params.thread.id }
+ case 'warning':
+ return notification.params.threadId
+ ? { kind: 'thread', threadId: notification.params.threadId }
+ : { kind: 'runtimeGlobal' }
+ case 'command/exec/outputDelta':
+ return { kind: 'connectionScoped', owner: 'unsupported-command-exec' }
+ case 'fs/changed':
+ return { kind: 'connectionScoped', owner: 'unsupported-fs-watch' }
+ case 'configWarning':
+ case 'mcpServer/oauthLogin/completed':
+ case 'mcpServer/startupStatus/updated':
+ case 'windows/worldWritableWarning':
+ case 'windowsSandbox/setupCompleted':
+ return { kind: 'runtimeGlobal' }
+ default:
+ return hasGeneratedThreadId(notification.params)
+ ? { kind: 'thread', threadId: notification.params.threadId }
+ : { kind: 'nonVisible' }
+ }
+}
+```
+
+The actual implementation should use an exhaustive table keyed by generated method name rather than the loose `default` above if that is clearer for TypeScript exhaustiveness. The important invariant is that no-locator notifications never use the subscriber's session id as a fake target thread.
+
+Carry runtime settings into both create/resume and turn start. Add `sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access'` to `FreshAgentCreateRequest`, `FreshAgentPaneContent`, and the fresh-agent create WS payload. Replace the old create-message effort enum with the shared runtime-settings field schemas so Freshcodex create can carry generated Codex effort values such as `xhigh` and granular approval policy objects. The create parser must also resolve the effective provider from the session-type registry before accepting runtime settings; otherwise the broad persisted/UI settings schema would accept `bypassPermissions` and `max` for Freshcodex:
+
+```ts
+const FreshAgentCreateBaseSchema = z.object({
+ type: z.literal('freshAgent.create'),
+ requestId: z.string().min(1),
+ sessionType: FreshAgentSessionTypeSchema,
+ provider: FreshAgentRuntimeProviderSchema.optional(), // omitted only when resolved from the session-type registry
+ cwd: z.string().optional(),
+ resumeSessionId: z.string().optional(),
+ model: FreshAgentRuntimeSettingsSchema.shape.model,
+ sandbox: FreshAgentRuntimeSettingsSchema.shape.sandbox,
+ permissionMode: FreshAgentRuntimeSettingsSchema.shape.permissionMode,
+ effort: FreshAgentRuntimeSettingsSchema.shape.effort,
+ plugins: z.array(z.string()).optional(),
+})
+
+export const FreshAgentCreateSchema = FreshAgentCreateBaseSchema.superRefine((value, ctx) => {
+ const provider = resolveRuntimeProviderForCreate(value.sessionType, value.provider)
+ validateFreshAgentRuntimeSettingFieldsForProvider(provider, value, ctx)
+})
+```
+
+Add explicit create-parser tests for both branches:
+
+```ts
+expect(() => FreshAgentCreateSchema.parse({
+ type: 'freshAgent.create',
+ requestId: 'create-codex-invalid',
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ permissionMode: 'bypassPermissions',
+ effort: 'max',
+})).toThrow(/FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING|permissionMode|effort/i)
+
+expect(FreshAgentCreateSchema.parse({
+ type: 'freshAgent.create',
+ requestId: 'create-claude-valid',
+ sessionType: 'freshclaude',
+ provider: 'claude',
+ permissionMode: 'bypassPermissions',
+ effort: 'max',
+})).toMatchObject({ provider: 'claude' })
+```
+
+Implement `resolveRuntimeProviderForCreate` from the shared session descriptor source (`shared/fresh-agent.ts` or the split shared descriptor registry from Task 3), not from the client-only `src/lib/fresh-agent-registry.ts`. Implement `validateFreshAgentRuntimeSettingFieldsForProvider` by extracting `{ model, sandbox, permissionMode, effort }` from the parsed action and calling `FreshAgentCodexRuntimeSettingsSchema` or `FreshAgentClaudeRuntimeSettingsSchema` from `shared/fresh-agent-contract.ts`. Convert Zod failures into `FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING` action errors so the UI reports invalid migrated Freshcodex settings before a Codex app-server request is sent.
+
+Also update the server-to-client create response so the newly created session immediately carries the same locator shape:
+
+```ts
+| {
+ type: 'freshAgent.created'
+ requestId: string
+ sessionId: string
+ sessionType: string
+ provider: string
+ runtimeProvider?: string
+ }
+| { type: 'freshAgent.create.failed'; requestId: string; sessionType?: string; provider?: string; code: string; message: string; retryable?: boolean }
+```
+
+Resolve Freshcodex defaults from provider settings when the pane is created, then include `model`, `sandbox`, Codex-shaped `permissionMode` as generated `approvalPolicy`, and Codex-shaped `effort` in `thread/start`, `thread/resume`, and `turn/start` where the generated schema supports them. Tests must prove a pane with model/sandbox/permission/effort settings creates the Codex thread with those values and sends a later turn with the same values unless the user changes them. Tests must also prove Freshcodex rejects legacy Claude-only values (`permissionMode: 'bypassPermissions'`, `effort: 'max'`) before calling Codex app-server, including through `freshAgent.create` parsing rather than only through later send actions.
+
+Implement explicit runtime-setting mappers:
+
+```ts
+import path from 'node:path'
+
+export function mapFreshcodexApprovalPolicy(value: FreshAgentRuntimeSettings['permissionMode']): CodexApprovalPolicy | undefined {
+ if (value === undefined) return undefined
+ return CodexApprovalPolicySchema.parse(value)
+}
+
+export function mapFreshcodexReasoningEffort(value: FreshAgentRuntimeSettings['effort']): CodexReasoningEffort | undefined {
+ if (value === undefined) return undefined
+ return CodexReasoningEffortSchema.parse(value)
+}
+
+export function mapFreshcodexSandboxModeToTurnPolicy(
+ sandbox: FreshAgentRuntimeSettings['sandbox'],
+ cwd: string | undefined,
+): CodexSandboxPolicy | undefined {
+ switch (sandbox) {
+ case undefined:
+ return undefined
+ case 'danger-full-access':
+ return { type: 'dangerFullAccess' }
+ case 'read-only':
+ return { type: 'readOnly', networkAccess: false }
+ case 'workspace-write':
+ if (!cwd || !path.isAbsolute(cwd)) throw new FreshAgentUnsupportedRuntimeSettingError('workspace-write turn sandbox requires an absolute cwd')
+ return {
+ type: 'workspaceWrite',
+ writableRoots: [cwd],
+ networkAccess: false,
+ excludeTmpdirEnvVar: false,
+ excludeSlashTmp: false,
+ }
+ }
+}
+```
+
+Use `sandbox` only for `thread/start`, `thread/resume`, and `thread/fork`; use `mapFreshcodexSandboxModeToTurnPolicy()` for `turn/start`. Do not pass the string `sandbox` field to `turn/start`.
+
+Extend `FreshAgentSendSchema`, `FreshAgentRuntimeAdapter.send`, `FreshAgentRuntimeManager.send`, and `server/ws-handler.ts` so turn-time runtime settings and typed image inputs cross the browser/server boundary. Import `FreshAgentInputImageSchema` and `FreshAgentRuntimeSettingsSchema` from `shared/fresh-agent-contract.ts`; do not duplicate those schemas in WebSocket protocol code:
+
+```ts
+export const FreshAgentSendSchema = z.object({
+ type: z.literal('freshAgent.send'),
+ sessionId: z.string().min(1),
+ sessionType: FreshAgentSessionTypeSchema,
+ provider: FreshAgentRuntimeProviderSchema,
+ text: z.string().optional(),
+ images: z.array(FreshAgentInputImageSchema).optional(),
+ runtimeSettings: FreshAgentRuntimeSettingsSchema.optional(),
+}).refine((value) => Boolean(value.text?.trim() || value.images?.length), {
+ message: 'Fresh-agent send requires text or an image',
+}).superRefine((value, ctx) => {
+ validateFreshAgentRuntimeSettingsForProvider(value.provider, value.runtimeSettings, ctx)
+})
+```
+
+Apply the same locator rule to `freshAgent.interrupt`, `freshAgent.fork`, approval/request response, review start, and any future fresh-agent action message: each action schema must include `sessionType` and `provider` alongside `sessionId`, and `server/ws-handler.ts` must pass the full locator to the runtime manager. Tests should send two attached records with the same `sessionId` and different providers, then prove each action reaches only the intended adapter.
+
+Map input content explicitly. The Freshcodex composer/controller should pass image attachments as typed `FreshAgentInputImage` values; the adapter should convert remote URLs to Codex `{ type: 'image', url }`, convert `{ kind: 'data', mediaType, data }` into a valid `data:${mediaType};base64,${data}` URL before sending `{ type: 'image', url }`, and convert local file paths to `{ type: 'localImage', path }`. Existing Codex transcripts may contain `{ type: 'skill' }` and `{ type: 'mention' }` content parts; preserve them in normalized message content. If a new outbound input part cannot be represented by the generated schema, return a typed unsupported-capability error before starting the turn.
+
+Extend `FreshAgentAttachSchema`, `FreshAgentRuntimeManager.attach`, and `FreshAgentRuntimeAdapter.attach?` so a saved Freshcodex pane can provide `cwd`, `model`, `sandbox`, `permissionMode`, and `effort` when it reattaches:
+
+```ts
+export const FreshAgentAttachSchema = z.object({
+ type: z.literal('freshAgent.attach'),
+ sessionId: z.string().min(1),
+ sessionType: FreshAgentSessionTypeSchema,
+ provider: FreshAgentRuntimeProviderSchema,
+ resumeSessionId: z.string().optional(),
+ cwd: z.string().optional(),
+ runtimeSettings: FreshAgentRuntimeSettingsSchema.optional(),
+}).superRefine((value, ctx) => {
+ validateFreshAgentRuntimeSettingsForProvider(value.provider, value.runtimeSettings, ctx)
+})
+```
+
+The Codex adapter must implement `ensureThreadLoaded(sessionId, context)` and call it before snapshot, subscribe, send, interrupt, fork, and start-review work. It should first try `thread/read { includeTurns: false }`; if the returned status is `{ type: 'notLoaded' }` or the app-server reports a lost/unloaded thread, call `thread/resume` with the attach/create context and `excludeTurns: true`, then re-read metadata. If the thread still cannot be loaded, surface `FRESH_AGENT_LOST_SESSION` or `FRESH_AGENT_RUNTIME_UNAVAILABLE` with a clear pane error. This is required because a fresh stdio app-server process does not necessarily have browser-restored thread ids loaded in memory, and omitting `excludeTurns: true` would let normal restore paths load an entire long transcript before the page-first `thread/turns/list` request.
+
+Convert `thread/fork -> { thread, ...metadata }` to the fresh-agent fork result at the adapter boundary:
+
+```ts
+const forked = await runtime.forkThread({ threadId: sessionId, excludeTurns: true })
+return {
+ sessionId: forked.thread.id,
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ runtimeProvider: 'codex',
+ parentThreadId: sessionId,
+ extensions: { codex: { fork: { parentThreadId: sessionId } } },
+}
+```
+
+Implement `getTurnBody` as a fresh-agent compatibility facade, not a Codex RPC method:
+
+```ts
+async getTurnBody(thread, revision) {
+ const currentRevision = getNormalizedRevisionFor(thread.threadId)
+ if (revision !== currentRevision) throw new FreshAgentStaleThreadRevisionError(currentRevision)
+ const cached = turnBodyCache.get(`${thread.threadId}:${thread.turnId}`)
+ if (!cached) throw new FreshAgentTurnBodyNotLoadedError(thread.threadId, thread.turnId)
+ return cached
+}
+```
+
+If a later generated schema adds a direct turn-read method, replace this cache facade in a focused follow-up. Do not add a nonexistent `thread/turn/read` call, and do not use `thread/read { includeTurns: true }` as a body-fetch fallback for long transcripts.
+
+Extend WS protocol with `freshAgent.forked`:
+
+```ts
+| {
+ type: 'freshAgent.forked'
+ sourceSessionId: string
+ sourceSessionType: string
+ sourceProvider: string
+ sessionId: string
+ sessionType: string
+ provider: string
+ runtimeProvider?: string
+ parentThreadId?: string
+ }
+```
+
+Send it from `server/ws-handler.ts` after `freshAgent.fork`.
+
+Extend client-to-server WS protocol with `freshAgent.review.start` and route it through `FreshAgentRuntimeManager.startReview(locator, { target, delivery })`:
+
+```ts
+export const FreshAgentReviewStartSchema = z.object({
+ type: z.literal('freshAgent.review.start'),
+ sessionId: z.string().min(1),
+ sessionType: FreshAgentSessionTypeSchema,
+ provider: FreshAgentRuntimeProviderSchema,
+ target: FreshAgentReviewTargetSchema.default({ type: 'uncommittedChanges' }),
+ delivery: z.enum(['inline', 'detached']).default('inline'),
+})
+
+export const FreshAgentServerRequestRespondSchema = z.object({
+ type: z.literal('freshAgent.serverRequest.respond'),
+ sessionId: z.string().min(1),
+ sessionType: FreshAgentSessionTypeSchema,
+ provider: FreshAgentRuntimeProviderSchema,
+ response: FreshAgentServerRequestResponseSchema,
+})
+
+export type FreshAgentServerMessage =
+ | { type: 'freshAgent.event'; sessionId: string; sessionType: string; provider: string; event: unknown }
+ | { type: 'freshAgent.review.started'; sessionId: string; sessionType: string; provider: string; turnId: string; reviewThreadId: string; target: FreshAgentReviewTarget; delivery: 'inline' | 'detached' }
+ | { type: 'freshAgent.killed'; sessionId: string; sessionType: string; provider: string; success: boolean }
+ | { type: 'freshAgent.error'; sessionId?: string; sessionType?: string; provider?: string; requestId?: string | number; code: string; message: string; retryable?: boolean }
+```
+
+On success, emit `freshAgent.review.started` with the returned `reviewThreadId` and full `{ sessionType, provider, sessionId }` locator, then emit a `freshAgent.event` invalidation for the same session so the workspace panel refreshes review output. On failure, emit `freshAgent.error` with a typed code and locator fields whenever the failure is session-specific. Preserve `reviewThreadId` in the Codex extension or review metadata when the snapshot refresh observes review items; do not collapse detached and inline reviews into only the source thread id.
+
+Extend the fresh-agent adapter/runtime contract with implemented Codex methods that Task 4 classified as supported:
+
+```ts
+startReview?(sessionId: string, input?: { target?: FreshAgentReviewTarget; delivery?: 'inline' | 'detached' }): Promise<{ turnId: string; reviewThreadId: string; target: FreshAgentReviewTarget; delivery: 'inline' | 'detached' }>
+respondToServerRequest?(sessionId: string, response: FreshAgentServerRequestResponse): Promise
+listThreads?(query: { limit?: number; cursor?: string; sortDirection?: 'asc' | 'desc'; sourceKinds?: string[] }): Promise
+listLoadedThreadIds?(query?: { limit?: number; cursor?: string }): Promise<{ ids: string[]; nextCursor: string | null }>
+listModels?(query?: FreshAgentModelListQuery): Promise
+readModelProviderCapabilities?(): Promise
+```
+
+Do not leave these as raw `CodexAppServerClient` helpers only. If the shared UI does not expose a method in this plan, move it from the implemented set to the explicit unsupported set in Task 4.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/server/coding-cli/codex-app-server/schema-traceability.test.ts \
+ test/unit/server/fresh-agent/codex-normalize.test.ts \
+ test/unit/server/fresh-agent/codex-adapter.test.ts \
+ test/unit/server/fresh-agent/runtime-manager.test.ts \
+ test/unit/server/ws-handler-fresh-agent.test.ts \
+ test/unit/client/lib/fresh-agent-ws.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Refactor and verify**
+
+Ensure `server/fresh-agent/adapters/codex/normalize.ts` contains no `Array>` transcript items and no unchecked `any` payload crossing into contract output.
+
+Run:
+
+```bash
+rg -n "Array>|Promise>|turns: input\\.transcript\\.turns|extensions = .*\\?\\? \\{\\}" server/fresh-agent/adapters/codex server/coding-cli/codex-app-server
+npm run test:vitest -- \
+ test/unit/server/fresh-agent/codex-normalize.test.ts \
+ test/unit/server/fresh-agent/codex-adapter.test.ts \
+ test/unit/shared/fresh-agent-contract.test.ts
+npm run typecheck:server
+```
+
+Expected: `rg` finds no stale raw transcript patterns; tests and typecheck pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add \
+ server/coding-cli/codex-app-server/rich-runtime.ts \
+ server/fresh-agent/adapters/codex/normalize.ts \
+ server/fresh-agent/adapters/codex/adapter.ts \
+ server/fresh-agent/runtime-adapter.ts server/fresh-agent/runtime-manager.ts \
+ shared/ws-protocol.ts server/ws-handler.ts src/lib/fresh-agent-ws.ts \
+ src/store/paneTypes.ts \
+ test/fixtures/fresh-agent/codex/contract-fixtures.ts \
+ test/fixtures/coding-cli/codex-app-server/schema-traceability.ts \
+ test/unit/server/fresh-agent/codex-normalize.test.ts \
+ test/unit/server/fresh-agent/codex-adapter.test.ts \
+ test/unit/server/fresh-agent/runtime-manager.test.ts \
+ test/unit/server/ws-handler-fresh-agent.test.ts \
+ test/unit/client/lib/fresh-agent-ws.test.ts
+git commit -m "Normalize Codex fresh-agent turns and actions"
+```
+
+### Task 6: Split FreshAgentView Into Controller And Pure Shell
+
+**Files:**
+- Create: `src/components/fresh-agent/useFreshAgentThreadController.ts`
+- Create: `src/components/fresh-agent/FreshAgentShell.tsx`
+- Create: `src/components/fresh-agent/fresh-agent-policy.ts`
+- Modify: `src/components/fresh-agent/FreshAgentView.tsx`
+- Modify: `src/components/fresh-agent/FreshAgentComposer.tsx`
+- Modify: `src/components/fresh-agent/FreshAgentApprovalBanner.tsx`
+- Modify: `src/components/fresh-agent/FreshAgentQuestionBanner.tsx`
+- Modify: `src/store/paneTypes.ts`
+- Modify: `src/store/panesSlice.ts`
+- Test: `test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx`
+- Test: `test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx`
+- Test: `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx`
+- Test: `test/unit/client/store/panesSlice.test.ts`
+
+- [ ] **Step 1: Write failing controller and shell tests**
+
+Tests must prove:
+
+```ts
+it('renders freshcodex without agentChat state', async () => {
+ const store = configureStore({ reducer: { panes, settings, freshAgent } })
+ render()
+ expect(await screen.findByText('Codex summary')).toBeInTheDocument()
+})
+
+it('does not clobber newer pane fields when freshAgent.created arrives late', async () => {
+ // Start with model/initialCwd/user title mutated after create was sent.
+ // Deliver freshAgent.created.
+ // Assert only sessionId, resumeSessionId, status, and createError changed.
+})
+
+it('opens a forked freshcodex thread in a sibling pane', async () => {
+ emitWs({
+ type: 'freshAgent.forked',
+ sourceSessionId: 'thread-1',
+ sourceSessionType: 'freshcodex',
+ sourceProvider: 'codex',
+ sessionId: 'thread-fork-1',
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ runtimeProvider: 'codex',
+ parentThreadId: 'thread-1',
+ })
+ expect(selectLayoutLeaves(store.getState(), 'tab-1')).toContainEqual(expect.objectContaining({
+ content: expect.objectContaining({ kind: 'fresh-agent', sessionType: 'freshcodex', provider: 'codex', sessionId: 'thread-fork-1' }),
+ }))
+})
+
+it('sends Freshcodex text, images, and runtime settings without reading Claude state', async () => {
+ renderFreshcodexPane({
+ paneContent: {
+ model: 'configured-model',
+ sandbox: 'workspace-write',
+ permissionMode: 'on-request',
+ effort: 'xhigh',
+ },
+ })
+ await user.type(screen.getByRole('textbox', { name: /chat message input/i }), 'Use this mockup')
+ await attachImageUrl('https://example.test/mockup.png')
+ await user.click(screen.getByRole('button', { name: 'Send' }))
+ expect(ws.send).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'freshAgent.send',
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ text: 'Use this mockup',
+ images: [{ kind: 'url', url: 'https://example.test/mockup.png', mediaType: 'image/png' }],
+ runtimeSettings: {
+ model: 'configured-model',
+ sandbox: 'workspace-write',
+ permissionMode: 'on-request',
+ effort: 'xhigh',
+ },
+ }))
+})
+
+it('attaches a restored Freshcodex pane with runtime context so the server can load the thread', async () => {
+ renderFreshcodexPane({
+ paneContent: {
+ sessionId: 'thread-restored-1',
+ resumeSessionId: 'thread-restored-1',
+ initialCwd: '/repo',
+ model: 'configured-model',
+ sandbox: 'workspace-write',
+ permissionMode: 'on-request',
+ effort: 'xhigh',
+ },
+ })
+ expect(ws.send).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'freshAgent.attach',
+ sessionId: 'thread-restored-1',
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ cwd: '/repo',
+ runtimeSettings: {
+ model: 'configured-model',
+ sandbox: 'workspace-write',
+ permissionMode: 'on-request',
+ effort: 'xhigh',
+ },
+ }))
+})
+
+it('accepts pasted or uploaded browser images as data image inputs', async () => {
+ renderFreshcodexPane()
+ await uploadImageFile(new File([new Uint8Array([1, 2, 3])], 'mockup.png', { type: 'image/png' }))
+ await user.type(screen.getByRole('textbox', { name: /chat message input/i }), 'Use this uploaded image')
+ await user.click(screen.getByRole('button', { name: 'Send' }))
+ expect(ws.send).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'freshAgent.send',
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ text: 'Use this uploaded image',
+ images: [expect.objectContaining({ kind: 'data', mediaType: 'image/png', data: expect.any(String) })],
+ }))
+})
+
+it('responds to Codex request-user-input prompts with generated answer arrays', async () => {
+ renderFreshcodexPane({ snapshot: snapshotWithToolUserInputRequest })
+ await user.click(screen.getByRole('button', { name: /answer a/i }))
+ expect(ws.send).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'freshAgent.serverRequest.respond',
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ response: {
+ requestId: 'user-input-1',
+ kind: 'tool_user_input',
+ answers: { choice: { answers: ['a'] } },
+ },
+ }))
+})
+
+it('responds to MCP elicitations with generated action/content metadata', async () => {
+ renderFreshcodexPane({ snapshot: snapshotWithMcpElicitationRequest })
+ await user.click(screen.getByRole('button', { name: /accept mcp input/i }))
+ expect(ws.send).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'freshAgent.serverRequest.respond',
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ response: {
+ requestId: 'mcp-elicit-1',
+ kind: 'mcp_elicitation',
+ action: 'accept',
+ content: expect.any(Object),
+ _meta: null,
+ },
+ }))
+})
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentView.test.tsx
+```
+
+Expected: FAIL because the controller/shell split does not exist and FreshAgentView imports Claude state directly.
+
+- [ ] **Step 3: Implement controller and shell**
+
+`FreshAgentView.tsx` becomes a small wrapper:
+
+```tsx
+export function FreshAgentView(props: FreshAgentViewProps) {
+ const controller = useFreshAgentThreadController(props)
+ return
+}
+```
+
+`useFreshAgentThreadController.ts` owns:
+
+- create/attach WS sends
+- snapshot loading
+- turn page/body loading hooks needed by Task 7
+- image attachment state passed from the composer to `freshAgent.send`
+- retry/recovery state
+- action dispatchers
+- forked-pane creation through `splitPane`
+- controlled load/create/action errors
+- attach context for restored Freshcodex panes, including cwd and Codex runtime settings, so the server-side adapter can call `thread/resume` before snapshot/action work when a new app-server process has not loaded the thread
+
+`FreshAgentShell.tsx` is pure and receives typed props:
+
+```ts
+type FreshAgentShellProps = {
+ descriptorLabel: string
+ statusLabel: string
+ summaryText: string
+ snapshot: FreshAgentThreadSnapshot | null
+ loadError: string | null
+ createError: FreshAgentCreateError | null
+ actions: {
+ send(text: string, images?: FreshAgentInputImage[], runtimeSettings?: FreshAgentRuntimeSettings): void
+ interrupt(): void
+ fork(): void
+ startReview(target?: FreshAgentReviewTarget, delivery?: 'inline' | 'detached'): void
+ retryCreate(): void
+ respondToServerRequest(response: FreshAgentServerRequestResponse): void
+ }
+}
+```
+
+`FreshAgentRuntimeSettings` is a shared client/server shape for turn-time overrides:
+
+```ts
+type FreshAgentRuntimeSettings = {
+ model?: string
+ sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access'
+ permissionMode?: CodexApprovalPolicy | ClaudePermissionMode
+ effort?: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'
+}
+```
+
+Provider policy helpers must validate these union fields with the provider-specific schemas before action dispatch. Freshcodex may dispatch only generated Codex `approvalPolicy` and `effort` values; Freshclaude may keep its existing Claude-specific permission/effort values. The shell should show a controlled settings error if a migrated Freshcodex pane still contains Claude-only values, and the WebSocket/client action parser must reject those values before the runtime manager calls the Codex adapter.
+
+`FreshAgentComposer.tsx` must support browser-representable image input directly, not only a test helper. Add an accessible image URL attachment control and file/paste handling that converts selected browser files to `{ kind: 'data', mediaType, data }` before dispatch. Keep `{ kind: 'local', path }` in the shared contract for server-side or restored Codex content, but do not pretend the browser can produce arbitrary local filesystem paths without an explicit server-side file picker.
+
+`fresh-agent-policy.ts` owns small pure helpers:
+
+```ts
+export function getFreshAgentStatusLabel(status: FreshAgentPaneStatus, readModelStatus?: FreshAgentThreadStatus, restoring?: boolean): string
+export function getFreshAgentQuestionLabel(sessionType: FreshAgentSessionType, provider: FreshAgentRuntimeProvider): string
+export function canUseFreshAgentAction(snapshot: FreshAgentThreadSnapshot | null, action: keyof FreshAgentCapabilities): boolean
+export function usesClaudeRestoreState(sessionType: FreshAgentSessionType, provider: FreshAgentRuntimeProvider): boolean
+```
+
+Use `mergePaneContent` for async field updates:
+
+```ts
+dispatch(mergePaneContent({
+ tabId,
+ paneId,
+ updates: {
+ sessionId: message.sessionId,
+ resumeSessionId: paneContentRef.current.resumeSessionId ?? message.sessionId,
+ status: 'connected',
+ createError: undefined,
+ },
+}))
+```
+
+When attaching a pane that already has `sessionId`, send:
+
+```ts
+ws.send({
+ type: 'freshAgent.attach',
+ sessionId,
+ sessionType,
+ provider,
+ resumeSessionId: paneContent.resumeSessionId,
+ cwd: paneContent.initialCwd,
+ runtimeSettings: {
+ model: paneContent.model,
+ sandbox: paneContent.sandbox,
+ permissionMode: paneContent.permissionMode,
+ effort: paneContent.effort,
+ },
+})
+```
+
+Do not reduce attach to `{ sessionId, sessionType }` for Freshcodex; that loses the context needed to load the thread into a fresh stdio app-server runtime.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \
+ test/unit/client/store/panesSlice.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Refactor and verify**
+
+Remove local duplicated snapshot/item types from `FreshAgentView.tsx`; import all fresh-agent read-model types from `shared/fresh-agent-contract`.
+
+Run:
+
+```bash
+rg -n "type FreshAgentSnapshot|state\\.agentChat|\\.\\.\\.paneContent" src/components/fresh-agent
+npm run test:vitest -- \
+ test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \
+ test/unit/client/components/panes/PaneContainer.test.tsx
+npm run typecheck:client
+```
+
+Expected: `rg` finds no stale local snapshot type and no Claude state read outside policy/controller; tests and typecheck pass. A controlled `paneContentRef.current` spread for explicit retry replacement is acceptable, but async message handlers should not spread captured `paneContent`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add \
+ src/components/fresh-agent/useFreshAgentThreadController.ts \
+ src/components/fresh-agent/FreshAgentShell.tsx \
+ src/components/fresh-agent/fresh-agent-policy.ts \
+ src/components/fresh-agent/FreshAgentView.tsx \
+ src/components/fresh-agent/FreshAgentComposer.tsx \
+ src/components/fresh-agent/FreshAgentApprovalBanner.tsx \
+ src/components/fresh-agent/FreshAgentQuestionBanner.tsx \
+ src/store/paneTypes.ts \
+ src/store/panesSlice.ts \
+ test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \
+ test/unit/client/store/panesSlice.test.ts
+git commit -m "Split fresh-agent controller from shell"
+```
+
+### Task 7: Add Turn Paging, Body Hydration, And Transcript Virtualization
+
+**Files:**
+- Create: `src/components/fresh-agent/FreshAgentTranscriptVirtualList.tsx`
+- Modify: `src/components/fresh-agent/FreshAgentTranscript.tsx`
+- Modify: `src/components/fresh-agent/useFreshAgentThreadController.ts`
+- Modify: `src/components/fresh-agent/FreshAgentShell.tsx`
+- Modify: `src/lib/api.ts`
+- Test: `test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx`
+- Test: `test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx`
+- Test: `test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx`
+- Test: `test/unit/client/lib/api.fresh-agent-contract.test.ts`
+
+- [ ] **Step 1: Write failing paging and virtualization tests**
+
+Add tests:
+
+```ts
+it('loads a visible turn page and hydrates missing bodies on demand', async () => {
+ api.getFreshAgentTurnPage.mockResolvedValue(contractPageWithTwoSummaries)
+ api.getFreshAgentTurnBody.mockResolvedValue(contractBodyForTurn2)
+ renderFreshcodex()
+ expect(await screen.findByText('Preview for turn 1')).toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: /load full turn 2/i }))
+ expect(await screen.findByText('Full body for turn 2')).toBeInTheDocument()
+})
+
+it('does not render every row in a 1000-turn transcript', () => {
+ render()
+ expect(screen.queryByText('turn 999')).not.toBeInTheDocument()
+})
+
+it('shows a stale revision error instead of mixing page and body revisions', async () => {
+ api.getFreshAgentTurnBody.mockRejectedValue({ code: 'STALE_THREAD_REVISION', currentRevision: 9 })
+ expect(await screen.findByText(/session changed while loading/i)).toBeInTheDocument()
+})
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx \
+ test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx
+```
+
+Expected: FAIL because transcript paging and virtualization are not implemented.
+
+- [ ] **Step 3: Implement virtualized transcript state**
+
+Use `react-window` already present in the repo. This repo has `react-window@2.x`, which exports `List`, not the old v1 `FixedSizeList`. The controller should:
+
+- Use `snapshot.initialTurnPage` as the initial visible page when present. Snapshots should not expose or require a full `turns` array.
+- If `snapshot.capabilities.turnPaging`, call `getFreshAgentTurnPage(sessionType, provider, threadId, { revision, priority: 'visible', limit, sortDirection })`; the server adapter maps this to Codex `thread/turns/list` without sending unsupported `revision` or `includeBodies` fields to app-server.
+- Store turn summaries keyed by `turnId`; when a page summary includes `body`, render that body directly and cache it client-side.
+- Hydrate body through `getFreshAgentTurnBody` only for providers or summaries that advertise an uncached body endpoint. For Codex, page results already contain the app-server `Turn` items, so the normal hydration path is loading the containing page, not calling a direct body endpoint and not a nonexistent Codex `thread/turn/read` method.
+- Refresh the snapshot and first page on stale revision errors.
+
+`FreshAgentTranscriptVirtualList.tsx` should render:
+
+```tsx
+import { List } from 'react-window'
+
+function Row({
+ ariaAttributes,
+ index,
+ style,
+ turns,
+ hydrateTurn,
+}: {
+ ariaAttributes: { 'aria-posinset': number; 'aria-setsize': number; role: 'listitem' }
+ index: number
+ style: React.CSSProperties
+ turns: FreshAgentTurnSummary[]
+ hydrateTurn: (turnId: string) => void
+}) {
+ const turn = turns[index]
+ return (
+
+
+
+ )
+}
+
+
+```
+
+Keep accessible markup inside each row: role/heading labels must remain visible to browser-use automation.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx \
+ test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Refactor and verify**
+
+Ensure the empty transcript state still renders when snapshot and page are empty.
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \
+ test/unit/client/components/HistoryView.mobile.test.tsx
+npm run typecheck:client
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add \
+ src/components/fresh-agent/FreshAgentTranscriptVirtualList.tsx \
+ src/components/fresh-agent/FreshAgentTranscript.tsx \
+ src/components/fresh-agent/useFreshAgentThreadController.ts \
+ src/components/fresh-agent/FreshAgentShell.tsx \
+ src/lib/api.ts \
+ test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx \
+ test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentTranscript.test.tsx \
+ test/unit/client/lib/api.fresh-agent-contract.test.ts
+git commit -m "Page and virtualize fresh-agent transcripts"
+```
+
+### Task 8: Build Freshcodex Item, Diff, Review, Worktree, And Fork UX
+
+**Files:**
+- Create: `src/components/fresh-agent/FreshAgentItemCard.tsx`
+- Create: `src/components/fresh-agent/FreshAgentWorkspacePanel.tsx`
+- Modify: `src/components/fresh-agent/FreshAgentTranscript.tsx`
+- Modify: `src/components/fresh-agent/FreshAgentDiffPanel.tsx`
+- Modify: `src/components/fresh-agent/FreshAgentSidebar.tsx`
+- Modify: `src/components/fresh-agent/FreshAgentShell.tsx`
+- Modify: `src/components/agent-chat/DiffView.tsx` only if a shared prop is needed
+- Test: `test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx`
+- Test: `test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx`
+- Test: `test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx`
+- Test: `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx`
+- Test: `test/e2e-browser/specs/fresh-agent.spec.ts`
+
+- [ ] **Step 1: Write failing UX tests**
+
+Use typed fixtures to assert every important Codex surface:
+
+```ts
+expect(screen.getByRole('article', { name: /command npm test/i })).toHaveTextContent('completed')
+expect(screen.getByRole('button', { name: /view diff src\/app.ts/i })).toBeInTheDocument()
+expect(screen.getByRole('article', { name: /renamed file src\/new-name.ts/i })).toHaveTextContent('src/old-name.ts')
+expect(screen.getByRole('region', { name: /review current changes/i })).toHaveTextContent('No blocking findings')
+await user.click(screen.getByRole('button', { name: /start codex review/i }))
+expect(ws.send).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'freshAgent.review.start',
+ sessionId: 'thread-1',
+}))
+expect(screen.getByRole('region', { name: /worktree/i })).toHaveTextContent('feature/freshcodex')
+expect(screen.getByRole('region', { name: /fork lineage/i })).toHaveTextContent('thread-parent-1')
+expect(screen.getByRole('region', { name: /child threads/i })).toHaveTextContent('Review shell')
+expect(screen.getByRole('article', { name: /child-agent action spawnAgent/i })).toHaveTextContent('thread-child-1')
+expect(screen.getByRole('article', { name: /child-agent action spawnAgent/i })).toHaveTextContent('thread-child-2')
+expect(screen.getByRole('button', { name: /mentioned file README.md/i })).toHaveTextContent('/repo/README.md')
+expect(screen.getByRole('button', { name: /skill reviewer/i })).toHaveTextContent('reviewer')
+```
+
+Browser e2e should seed a Freshcodex snapshot with a file-change diff and verify the diff is expandable without relying on selectors.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentView.test.tsx
+```
+
+Expected: FAIL because normalized Codex item rendering and workspace panel are incomplete.
+
+- [ ] **Step 3: Implement item and workspace UI**
+
+`FreshAgentItemCard.tsx` renders one contract item with semantic labels:
+
+- `message`: role-labelled user/assistant/system message with ordered text/image/mention/skill parts, preserving generated text elements, assistant message phase, and memory citations.
+- `message` mention/skill parts: render preserved Codex `mention` and `skill` content parts as accessible inline chips with their names and paths.
+- `text`: markdown/plain text with wrapping.
+- `hook_prompt`: hook/context prompt fragments with generated `hookRunId` available for accessible details without exposing raw JSON.
+- `reasoning`: collapsed by default, with generated summary visible, full generated content available behind an accessible toggle, and no loss of either array.
+- `plan`: plan card.
+- `command`: command, cwd, source, status, command-action metadata, output, exit code, process id, and duration.
+- `file_change`: list changed files, distinguish add/modify/delete/rename, show `movePath` for renamed/moved files, and provide expandable diffs using shared `DiffView`.
+- `tool`: MCP/tool card with server, arbitrary JSON input, MCP app resource URI, structured result metadata, error, and duration.
+- `dynamic_tool`: unsupported or completed dynamic tool call state with namespace, arbitrary JSON input, generated output content items, success state, duration, and the user-visible response.
+- `collaboration`: child-agent action card with all `receiverThreadIds`, sender id, prompt, model/effort metadata, and generated agent-state summaries when available.
+- `review`: entered/exited review cards.
+- `web_search`: query, structured generated action (`search`, `openPage`, `findInPage`, or `other`), and status.
+- `image`: path/url card.
+- `image_generation`: prompt, raw generated status string, raw generated `result`, optional `savedPath`, optional derived display status, and generated image metadata when available.
+- `context_compaction`: compaction status and token before/after summary when available.
+- `request_prompt`: pending/resolved approval/question/tool prompt state.
+- `error`: alert card.
+
+`FreshAgentWorkspacePanel.tsx` replaces sidebar-only listing for:
+
+- worktrees
+- child threads
+- diffs
+- review metadata and review output
+- start-review action when `snapshot.capabilities.review` or the Codex extension says review is supported; disabled with a clear label otherwise
+- fork lineage
+- token/context details
+
+Keep a compact sidebar on narrow panes and full details in the main panel. Use semantic `button`, `section`, `article`, headings, and `aria-label`.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentView.test.tsx
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Refactor and verify**
+
+Run the browser spec after unit tests:
+
+```bash
+npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent.spec.ts
+npm run typecheck:client
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add \
+ src/components/fresh-agent/FreshAgentItemCard.tsx \
+ src/components/fresh-agent/FreshAgentWorkspacePanel.tsx \
+ src/components/fresh-agent/FreshAgentTranscript.tsx \
+ src/components/fresh-agent/FreshAgentDiffPanel.tsx \
+ src/components/fresh-agent/FreshAgentSidebar.tsx \
+ src/components/fresh-agent/FreshAgentShell.tsx \
+ src/components/agent-chat/DiffView.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentItemCard.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentDiffPanel.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \
+ test/e2e-browser/specs/fresh-agent.spec.ts
+git commit -m "Render rich Freshcodex transcript and workspace items"
+```
+
+### Task 9: Port Mobile Keyboard, Touch, And Performance Fixes Into Fresh-Agent
+
+**Files:**
+- Modify: `src/components/fresh-agent/FreshAgentShell.tsx`
+- Modify: `src/components/fresh-agent/FreshAgentComposer.tsx`
+- Modify: `src/components/fresh-agent/FreshAgentApprovalBanner.tsx`
+- Modify: `src/components/fresh-agent/FreshAgentQuestionBanner.tsx`
+- Modify: `src/components/fresh-agent/FreshAgentTranscriptVirtualList.tsx`
+- Modify: `src/hooks/useKeyboardInset.ts`
+- Modify: `test/e2e-browser/perf/scenarios.ts`
+- Test: `test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx`
+- Test: `test/unit/client/components/fresh-agent/FreshAgentComposer.test.tsx` if it does not already exist, create it
+- Test: `test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx`
+- Test: `test/e2e-browser/specs/fresh-agent-mobile.spec.ts`
+
+- [ ] **Step 1: Write failing mobile tests**
+
+Add tests proving:
+
+```ts
+expect(screen.getByRole('textbox', { name: /chat message input/i })).toHaveAttribute('enterkeyhint', 'send')
+expect(screen.getByRole('button', { name: 'Send' })).toHaveClass(expect.stringMatching(/min-h|h-/))
+expect(screen.getByTestId('fresh-agent-root')).toHaveStyle({ paddingBottom: 'var(--keyboard-inset-bottom)' })
+```
+
+Browser mobile spec should verify the composer remains visible while typing and approval/question buttons have accessible labels and usable touch size.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx
+```
+
+Expected: FAIL because FreshAgent shell has not yet ported the main-branch mobile keyboard behavior.
+
+- [ ] **Step 3: Implement mobile behavior**
+
+Port the main `agent-chat` keyboard/touch behavior into fresh-agent components without importing `agent-chat` view state:
+
+- apply `useKeyboardInset` to the fresh-agent root/composer region
+- keep composer sticky in mobile panes
+- preserve virtualization container height when keyboard inset changes
+- ensure approval/question/action buttons have accessible names and mobile touch targets
+- keep transcript scroll stable on send and on snapshot refresh
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx \
+ test/unit/client/hooks/useKeyboardInset.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Refactor and verify**
+
+Run:
+
+```bash
+npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent-mobile.spec.ts
+npm run test:visible-first:contract
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add \
+ src/components/fresh-agent/FreshAgentShell.tsx \
+ src/components/fresh-agent/FreshAgentComposer.tsx \
+ src/components/fresh-agent/FreshAgentApprovalBanner.tsx \
+ src/components/fresh-agent/FreshAgentQuestionBanner.tsx \
+ src/components/fresh-agent/FreshAgentTranscriptVirtualList.tsx \
+ src/hooks/useKeyboardInset.ts test/e2e-browser/perf/scenarios.ts \
+ test/unit/client/components/fresh-agent/FreshAgentShell.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentComposer.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentTranscriptVirtualList.test.tsx \
+ test/e2e-browser/specs/fresh-agent-mobile.spec.ts
+git commit -m "Port mobile ergonomics to fresh-agent shell"
+```
+
+### Task 10: Finish Freshcodex Session Identity, Titles, Sidebar, And Settings
+
+**Files:**
+- Modify: `src/lib/fresh-agent-registry.ts`
+- Modify: `src/lib/derivePaneTitle.ts`
+- Modify: `src/lib/session-utils.ts`
+- Modify: `src/lib/session-type-utils.ts`
+- Modify: `src/lib/tab-registry-snapshot.ts`
+- Modify: `src/lib/api.ts`
+- Modify: `src/lib/pane-activity.ts`
+- Modify: `src/store/tabsSlice.ts`
+- Modify: `src/store/paneTreeValidation.ts`
+- Modify: `src/store/panesSlice.ts`
+- Modify: `src/store/persistedState.ts` if runtime-setting persistence schemas need versioned validation changes
+- Modify: `src/store/managed-items.ts`
+- Modify: `src/store/settingsThunks.ts`
+- Modify: `src/store/selectors/sidebarSelectors.ts`
+- Modify: `src/components/ExtensionsView.tsx`
+- Modify: `src/components/HistoryView.tsx`
+- Modify: `src/components/TabsView.tsx`
+- Modify: `src/components/panes/PaneContainer.tsx`
+- Modify: `src/components/panes/PanePicker.tsx`
+- Modify: `src/components/SettingsView.tsx`
+- Modify: `src/store/paneTypes.ts`
+- Modify: `server/fresh-agent/runtime-manager.ts`
+- Modify: `server/fresh-agent/router.ts`
+- Modify: `server/session-directory/projection.ts`
+- Modify: `server/coding-cli/session-indexer.ts`
+- Modify: `shared/settings.ts`
+- Test: `test/unit/shared/fresh-agent-registry.test.ts`
+- Test: `test/unit/client/lib/derivePaneTitle.test.ts`
+- Test: `test/unit/client/lib/session-utils.test.ts`
+- Test: `test/unit/client/lib/session-type-utils.test.ts`
+- Test: `test/unit/client/lib/tab-registry-snapshot.test.ts`
+- Test: `test/unit/client/lib/pane-activity.test.ts`
+- Test: `test/unit/client/store/selectors/sidebarSelectors.test.ts`
+- Test: `test/unit/client/store/panesPersistence.test.ts`
+- Test: `test/unit/client/store/storage-migration.fresh-agent.test.ts`
+- Test: `test/unit/client/store/persisted-state.fresh-agent.test.ts`
+- Test: `test/unit/client/components/TabsView.fresh-agent.test.tsx`
+- Test: `test/unit/client/components/ExtensionsView.test.tsx`
+- Test: `test/unit/client/components/HistoryView.mobile.test.tsx`
+- Test: `test/unit/client/components/panes/PaneContainer.test.tsx`
+- Test: `test/unit/client/components/panes/PanePicker.test.tsx`
+- Test: `test/unit/server/fresh-agent/router.test.ts`
+- Test: `test/unit/server/session-directory/fresh-agent-projection.test.ts`
+- Test: `test/unit/server/coding-cli/session-indexer.test.ts`
+
+- [ ] **Step 1: Write failing identity tests**
+
+Add tests:
+
+```ts
+expect(derivePaneTitle({ kind: 'fresh-agent', sessionType: 'freshcodex', provider: 'codex', createRequestId: 'r', status: 'idle' }))
+ .toBe('Freshcodex')
+
+expect(collectSessionRefsFromNode(freshcodexLayout)).toContainEqual(expect.objectContaining({
+ provider: 'codex',
+ sessionType: 'freshcodex',
+}))
+
+expect(projectFreshAgentSession(codexThread)).toMatchObject({
+ provider: 'codex',
+ sessionType: 'freshcodex',
+ title: expect.any(String),
+})
+
+expect(projectFreshAgentSession(schemaValidThread({
+ id: 'thread-child-1',
+ source: { subAgent: { thread_spawn: {
+ parent_thread_id: 'thread-parent-1',
+ depth: 1,
+ agent_path: null,
+ agent_nickname: 'reviewer',
+ agent_role: 'review',
+ } } },
+ turns: [],
+}))).toMatchObject({
+ provider: 'codex',
+ sessionType: 'freshcodex',
+ sessionId: 'thread-child-1',
+ parentThreadId: 'thread-parent-1',
+ source: expect.objectContaining({
+ subAgent: expect.objectContaining({
+ thread_spawn: expect.objectContaining({ parent_thread_id: 'thread-parent-1' }),
+ }),
+ }),
+})
+
+runtime.listThreads.mockResolvedValue({ data: [codexThread], nextCursor: null, backwardsCursor: null })
+await expect(loadFreshcodexHistoryPage({ limit: 25 })).resolves.toMatchObject({
+ items: [expect.objectContaining({ provider: 'codex', sessionType: 'freshcodex', sessionId: codexThread.id })],
+ nextCursor: null,
+ backwardsCursor: null,
+})
+expect(runtime.listThreads).toHaveBeenCalledWith(expect.objectContaining({
+ limit: 25,
+ sourceKinds: ['cli', 'vscode', 'exec', 'appServer', 'subAgent', 'subAgentReview', 'subAgentCompact', 'subAgentThreadSpawn', 'subAgentOther'],
+}))
+
+runtime.listModels.mockResolvedValue({
+ data: [{
+ id: 'fixture-model',
+ model: 'fixture-model',
+ upgrade: null,
+ upgradeInfo: null,
+ availabilityNux: null,
+ displayName: 'Fixture Model',
+ description: 'Fixture model for tests',
+ hidden: false,
+ supportedReasoningEfforts: [],
+ defaultReasoningEffort: 'medium',
+ inputModalities: [],
+ supportsPersonality: false,
+ additionalSpeedTiers: [],
+ isDefault: true,
+ }],
+ nextCursor: null,
+})
+runtime.readModelProviderCapabilities.mockResolvedValue({ namespaceTools: true, imageGeneration: false, webSearch: true })
+await expect(loadFreshcodexModelPage({ limit: 25 })).resolves.toMatchObject({
+ items: [expect.objectContaining({ id: 'fixture-model' })],
+ nextCursor: null,
+ providerCapabilities: { namespaceTools: true, imageGeneration: false, webSearch: true },
+})
+await expect(loadFreshcodexModelOptions()).resolves.toEqual(expect.arrayContaining([
+ expect.objectContaining({ id: 'fixture-model' }),
+]))
+await expect(loadFreshcodexModelProviderCapabilities()).resolves.toEqual({
+ namespaceTools: true,
+ imageGeneration: false,
+ webSearch: true,
+})
+
+expect(createFreshcodexPaneFromSettings({
+ codingCli: {
+ providers: {
+ codex: {
+ model: 'configured-model',
+ sandbox: 'workspace-write',
+ permissionMode: 'on-request',
+ },
+ },
+ },
+ freshAgent: {
+ providers: {
+ freshcodex: {
+ defaultEffort: 'xhigh',
+ },
+ },
+ },
+})).toMatchObject({
+ kind: 'fresh-agent',
+ provider: 'codex',
+ sessionType: 'freshcodex',
+ model: 'configured-model',
+ sandbox: 'workspace-write',
+ permissionMode: 'on-request',
+ effort: 'xhigh',
+})
+
+expect(createFreshcodexPaneFromSettings({
+ codingCli: {
+ providers: {
+ codex: {
+ model: 'configured-model',
+ sandbox: 'workspace-write',
+ permissionMode: 'bypassPermissions',
+ },
+ },
+ },
+ freshAgent: {
+ providers: {
+ freshcodex: {
+ defaultEffort: 'max',
+ },
+ },
+ },
+})).toMatchObject({
+ kind: 'fresh-agent',
+ provider: 'codex',
+ sessionType: 'freshcodex',
+ createError: expect.objectContaining({ code: 'FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING' }),
+})
+```
+
+Also test that `buildResumeContent`, `TabsView` remote snapshot hydration, `collectPaneSnapshots`, `paneTreeValidation`, and pane persistence preserve Freshcodex `sandbox`, generated Codex effort values, and generated Codex approval policies without narrowing them to Claude strings:
+
+```ts
+expect(buildResumeContent({
+ sessionType: 'freshcodex',
+ sessionId: 'thread-1',
+ cwd: '/repo',
+ freshAgentProviderSettings: {
+ defaultModel: 'configured-model',
+ defaultSandbox: 'workspace-write',
+ defaultPermissionMode: 'on-request',
+ defaultEffort: 'xhigh',
+ },
+})).toMatchObject({
+ kind: 'fresh-agent',
+ provider: 'codex',
+ sessionType: 'freshcodex',
+ sandbox: 'workspace-write',
+ permissionMode: 'on-request',
+ effort: 'xhigh',
+})
+
+expect(collectPaneSnapshots({
+ type: 'leaf',
+ id: 'pane-1',
+ content: {
+ kind: 'fresh-agent',
+ provider: 'codex',
+ sessionType: 'freshcodex',
+ resumeSessionId: 'thread-1',
+ createRequestId: 'req-1',
+ status: 'idle',
+ sandbox: 'workspace-write',
+ permissionMode: { granular: { sandbox_approval: true, rules: true, skill_approval: true, request_permissions: true, mcp_elicitations: true } },
+ effort: 'xhigh',
+ },
+}, 'server-1')).toContainEqual(expect.objectContaining({
+ payload: expect.objectContaining({
+ sandbox: 'workspace-write',
+ permissionMode: expect.objectContaining({ granular: expect.any(Object) }),
+ effort: 'xhigh',
+ }),
+}))
+```
+
+Add persistence tests for browser-reloaded Freshcodex panes with valid Codex runtime settings and for legacy Freshcodex panes with Claude-only values. Valid panes must rehydrate unchanged and attach; legacy invalid panes must remain visible with a controlled `createError`, not be dropped, silently coerced, or replaced with a picker pane. Also test that `freshcodex` settings appear independently from Freshclaude where the UI exposes runtime settings, and `freshopencode` remains disabled/hidden.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/shared/fresh-agent-registry.test.ts \
+ test/unit/client/lib/derivePaneTitle.test.ts \
+ test/unit/client/lib/session-utils.test.ts \
+ test/unit/client/lib/session-type-utils.test.ts \
+ test/unit/client/lib/tab-registry-snapshot.test.ts \
+ test/unit/client/lib/pane-activity.test.ts \
+ test/unit/client/store/selectors/sidebarSelectors.test.ts \
+ test/unit/client/store/panesPersistence.test.ts \
+ test/unit/client/store/storage-migration.fresh-agent.test.ts \
+ test/unit/client/store/persisted-state.fresh-agent.test.ts \
+ test/unit/client/components/TabsView.fresh-agent.test.tsx \
+ test/unit/client/components/ExtensionsView.test.tsx \
+ test/unit/server/fresh-agent/router.test.ts \
+ test/unit/server/session-directory/fresh-agent-projection.test.ts
+```
+
+Expected: FAIL for any identity/title/sidebar/settings/history/snapshot gaps still coupled to `agent-chat` or Claude-shaped runtime assumptions.
+
+- [ ] **Step 3: Implement identity fixes**
+
+Rules:
+
+- `freshcodex` title defaults to `Freshcodex`, then updates from the first user message or thread name when available.
+- `provider: 'codex'` plus `sessionType: 'freshcodex'` is the session ref identity.
+- `sandbox` is stored on fresh-agent pane content and comes from Codex provider settings, not from Claude/Freshclaude settings.
+- `freshcodex` default permission/effort settings must be Codex-shaped. Replace any Freshcodex registry/default value that still uses Claude-specific permission modes such as `bypassPermissions` with a generated Codex approval policy such as `on-request`; replace any Freshcodex default effort that still uses Claude-only `max` with a generated Codex effort value. Do not mutate Freshclaude or Kilroy defaults.
+- Split settings types so Codex and Claude defaults cannot be accidentally interchanged. In `shared/settings.ts`, stop typing `codingCli.providers.codex.permissionMode` and `freshAgent.providers.freshcodex.defaultEffort` with Claude-only aliases. Introduce Codex-specific approval/effort/sandbox schemas based on the generated app-server values, keep Claude-specific settings for Freshclaude/Kilroy, and migrate invalid legacy Freshcodex values into a visible `createError` rather than silently coercing them.
+- Update `src/lib/session-type-utils.ts`, `src/store/tabsSlice.ts`, and `src/components/panes/PaneContainer.tsx` so Freshcodex creation and resume use Freshcodex settings from `freshAgent.providers.freshcodex` plus Codex CLI defaults from `codingCli.providers.codex`. Do not route Freshcodex through `getAgentChatProviderConfig()` or an `agentChatProviderSettings` parameter; those are Claude/Kilroy compatibility paths only.
+- Update `src/lib/tab-registry-snapshot.ts`, `src/components/TabsView.tsx`, `src/store/paneTreeValidation.ts`, and pane persistence schemas/tests so Freshcodex `sandbox`, generated Codex effort values, and structured/generated approval policies are preserved in local and remote tab snapshots. Remote snapshots must not cast Freshcodex effort back to `'low' | 'medium' | 'high' | 'max'`, must not cast structured approval policy objects to strings, and must not omit `sandbox`.
+- Update `src/store/managed-items.ts`, `src/components/ExtensionsView.tsx`, and `src/store/settingsThunks.ts` where provider settings are exposed or sanitized so Codex provider settings do not offer or accept Claude permission modes for Freshcodex defaults. If raw Codex terminal settings still need a narrower CLI-specific representation, model that separately from Freshcodex rich runtime settings.
+- Add fresh-agent REST/API surfaces for the adapter methods classified as implemented in Task 5: list Freshcodex threads, list loaded Freshcodex thread ids, list models, and read model-provider capabilities. These should be typed in `server/fresh-agent/runtime-manager.ts`, exposed by `server/fresh-agent/router.ts`, parsed in `src/lib/api.ts`, and consumed by history/settings UI. Do not leave `thread/list`, `thread/loaded/list`, `model/list`, or `modelProvider/capabilities/read` as uncalled low-level app-server helpers after classifying them as implemented. The thread-list surface must reflect the generated app-server shape after fresh-agent normalization (`{ items, nextCursor, backwardsCursor }`) rather than returning a bare array; the loaded-list surface must reflect the generated app-server shape (`{ ids, nextCursor }` after fresh-agent normalization), or explicitly hydrate those ids with `thread/read`; it must not return fake `FreshAgentSessionSummary` rows from `thread/loaded/list` alone. The model-list surface must likewise reflect the generated paginated shape after fresh-agent normalization (`{ items, nextCursor }`) rather than returning a bare first-page array.
+- Feed Freshcodex history/session rows from the Codex rich adapter's `thread/list` results where available, projected through `session-directory` with `sessionType: 'freshcodex'` and `provider: 'codex'`. Existing file/indexer-derived Codex terminal history may remain for raw Codex terminal panes, but it must not be the only source for Freshcodex rich threads. The Freshcodex history query must pass explicit generated `sourceKinds` for CLI-created sessions, rich app-server sessions, command/exec sessions, locally created app-server threads reported as `vscode`, and child-agent sessions, at least `['cli', 'vscode', 'exec', 'appServer', 'subAgent', 'subAgentReview', 'subAgentCompact', 'subAgentThreadSpawn', 'subAgentOther']`, rather than relying on the app-server default source filter. This keeps CLI-created Codex threads, locally created Freshcodex threads, app-server-created threads, exec sessions, review threads, compaction subagent threads, and spawned child-agent threads visible even if Codex changes the default "interactive" source set.
+- Preserve generated `Thread.source` separately from the `thread/list` `sourceKinds` filter. For subagent threads, parse and store nested `SessionSource` metadata such as `{ subAgent: { thread_spawn: ... } }`; derive `parentThreadId`, child-thread labels, and fork/child UX from that nested metadata where available. Do not flatten returned `Thread.source` into the source-kind filter enum because that loses spawned-agent parent ids, depth, nickname, and role.
+- Feed Freshcodex model/settings options from `model/list` plus `modelProvider/capabilities/read` and cache them behind the fresh-agent adapter boundary. `loadFreshcodexModelPage` should preserve the page cursor for settings UIs that can page model options; any `loadFreshcodexModelOptions` convenience helper that returns an array must explicitly iterate pages until `nextCursor` is null and fail on cursor cycles or an excessive page count instead of silently truncating. If the runtime is unavailable, show a typed runtime-unavailable settings error rather than falling back to stale Claude model defaults.
+- Hidden `kilroy` resolves to Claude runtime metadata but does not appear as a public picker entry.
+- `freshopencode` remains disabled and cannot be created.
+- Settings and history labels use `sessionType`; runtime behavior uses `provider`.
+
+Port main's agent-chat auto-title behavior into fresh-agent by using shared title utilities, not by importing `AgentChatView`.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/shared/fresh-agent-registry.test.ts \
+ test/unit/client/lib/derivePaneTitle.test.ts \
+ test/unit/client/lib/session-utils.test.ts \
+ test/unit/client/lib/session-type-utils.test.ts \
+ test/unit/client/lib/tab-registry-snapshot.test.ts \
+ test/unit/client/lib/pane-activity.test.ts \
+ test/unit/client/store/selectors/sidebarSelectors.test.ts \
+ test/unit/client/store/panesPersistence.test.ts \
+ test/unit/client/store/storage-migration.fresh-agent.test.ts \
+ test/unit/client/store/persisted-state.fresh-agent.test.ts \
+ test/unit/client/components/TabsView.fresh-agent.test.tsx \
+ test/unit/client/components/ExtensionsView.test.tsx \
+ test/unit/client/components/HistoryView.mobile.test.tsx \
+ test/unit/client/components/panes/PaneContainer.test.tsx \
+ test/unit/client/components/panes/PanePicker.test.tsx \
+ test/unit/server/fresh-agent/router.test.ts \
+ test/unit/server/session-directory/fresh-agent-projection.test.ts \
+ test/unit/server/coding-cli/session-indexer.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Refactor and verify**
+
+Run:
+
+```bash
+rg -n "freshcodex.*agentChat|agentChat.*freshcodex|state\\.agentChat.*freshcodex|kind: 'agent-chat'.*freshcodex" src server test
+rg -n "agentChatSlice|agentChatTypes|agentChatThunks" src/store/freshAgentSlice.ts src/store/freshAgentTypes.ts src/store/freshAgentThunks.ts src/lib/pane-activity.ts
+rg -n "effort.*'max'|as 'low' \\| 'medium' \\| 'high' \\| 'max'|permissionMode as string" src/lib/fresh-agent-registry.ts src/lib/session-type-utils.ts src/lib/tab-registry-snapshot.ts src/components/TabsView.tsx src/store/paneTreeValidation.ts
+npm run typecheck
+```
+
+Expected: `rg` commands find no Freshcodex dependence on agent-chat state, no fresh-agent slice/type/thunk alias back to agent-chat modules, and no Freshcodex runtime-setting narrowing to Claude-only effort or string-only permission values; typecheck passes.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add \
+ src/lib/fresh-agent-registry.ts src/lib/derivePaneTitle.ts src/lib/session-utils.ts \
+ src/lib/session-type-utils.ts src/lib/tab-registry-snapshot.ts src/lib/api.ts \
+ src/lib/pane-activity.ts \
+ src/store/selectors/sidebarSelectors.ts src/components/HistoryView.tsx \
+ src/components/ExtensionsView.tsx src/components/TabsView.tsx \
+ src/components/panes/PaneContainer.tsx src/components/panes/PanePicker.tsx \
+ src/components/SettingsView.tsx src/store/paneTypes.ts src/store/panesSlice.ts \
+ src/store/paneTreeValidation.ts src/store/persistedState.ts src/store/tabsSlice.ts \
+ src/store/managed-items.ts src/store/settingsThunks.ts \
+ server/fresh-agent/runtime-manager.ts server/fresh-agent/router.ts \
+ server/session-directory/projection.ts \
+ server/coding-cli/session-indexer.ts shared/settings.ts \
+ test/unit/shared/fresh-agent-registry.test.ts \
+ test/unit/client/lib/derivePaneTitle.test.ts \
+ test/unit/client/lib/session-utils.test.ts \
+ test/unit/client/lib/session-type-utils.test.ts \
+ test/unit/client/lib/tab-registry-snapshot.test.ts \
+ test/unit/client/lib/pane-activity.test.ts \
+ test/unit/client/store/selectors/sidebarSelectors.test.ts \
+ test/unit/client/store/panesPersistence.test.ts \
+ test/unit/client/store/storage-migration.fresh-agent.test.ts \
+ test/unit/client/store/persisted-state.fresh-agent.test.ts \
+ test/unit/client/components/TabsView.fresh-agent.test.tsx \
+ test/unit/client/components/ExtensionsView.test.tsx \
+ test/unit/client/components/HistoryView.mobile.test.tsx \
+ test/unit/client/components/panes/PaneContainer.test.tsx \
+ test/unit/client/components/panes/PanePicker.test.tsx \
+ test/unit/server/fresh-agent/router.test.ts \
+ test/unit/server/session-directory/fresh-agent-projection.test.ts \
+ test/unit/server/coding-cli/session-indexer.test.ts
+git commit -m "Finalize Freshcodex identity and session projections"
+```
+
+### Task 11: Harden Error Handling, Reconnect, And Multi-Client Freshcodex Behavior
+
+**Files:**
+- Modify: `server/ws-handler.ts`
+- Modify: `server/fresh-agent/runtime-manager.ts`
+- Modify: `server/fresh-agent/adapters/codex/adapter.ts`
+- Modify: `src/lib/fresh-agent-ws.ts`
+- Modify: `src/components/fresh-agent/useFreshAgentThreadController.ts`
+- Modify: `src/components/fresh-agent/FreshAgentShell.tsx`
+- Test: `test/unit/server/ws-handler-fresh-agent.test.ts`
+- Test: `test/unit/server/fresh-agent/runtime-manager.test.ts`
+- Test: `test/unit/server/fresh-agent/codex-adapter.test.ts`
+- Test: `test/unit/client/lib/fresh-agent-ws.test.ts`
+- Test: `test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx`
+- Test: `test/e2e-browser/specs/fresh-agent.spec.ts`
+
+- [ ] **Step 1: Write failing resilience tests**
+
+Add tests for:
+
+```ts
+it('keeps two clients subscribed to the same Freshcodex thread without dropping either on event refresh', ...)
+it('refreshes both clients when a Codex turn/item/token/diff notification invalidates the Freshcodex snapshot', ...)
+it('emits a freshAgent.error message instead of generic sdk error for freshcodex action failures', ...)
+it('recovers a stopped Codex app-server by surfacing runtime unavailable and enabling retry, not by clearing pane state', ...)
+it('does not create duplicate turn starts when the browser reconnects and reattaches a pane', ...)
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/server/ws-handler-fresh-agent.test.ts \
+ test/unit/server/fresh-agent/runtime-manager.test.ts \
+ test/unit/server/fresh-agent/codex-adapter.test.ts \
+ test/unit/client/lib/fresh-agent-ws.test.ts \
+ test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx
+```
+
+Expected: FAIL for missing typed fresh-agent action errors and duplicate/reconnect guards.
+
+- [ ] **Step 3: Implement resilience behavior**
+
+Use the fresh-agent specific error message added in Task 5 consistently in `shared/ws-protocol.ts` and handlers:
+
+```ts
+| { type: 'freshAgent.error'; sessionId?: string; sessionType?: string; provider?: string; requestId?: string | number; code: string; message: string; retryable?: boolean }
+```
+
+Use this message for fresh-agent action errors instead of generic `sendError`. Include `sessionType` and `provider` whenever `sessionId` is present so client-side reconnect/error handling never has to guess which runtime owns an opaque id.
+
+In the controller:
+
+- store last create request id sent per pane
+- do not re-send create for a pane with an in-flight request unless retry explicitly changes `createRequestId`
+- attach on reconnect if `sessionId` exists
+- refresh snapshot on `freshAgent.event`, `freshAgent.error` when recoverable, and `freshAgent.forked`
+- debounce notification-driven snapshot refresh per session so a burst of Codex item/token/diff events causes one near-term refresh, not one REST request per raw app-server notification
+- keep action errors in shell state until dismissed or superseded
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run:
+
+```bash
+npm run test:vitest -- \
+ test/unit/server/ws-handler-fresh-agent.test.ts \
+ test/unit/server/fresh-agent/runtime-manager.test.ts \
+ test/unit/server/fresh-agent/codex-adapter.test.ts \
+ test/unit/client/lib/fresh-agent-ws.test.ts \
+ test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Refactor and verify**
+
+Run:
+
+```bash
+npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent.spec.ts
+npm run typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add \
+ shared/ws-protocol.ts server/ws-handler.ts \
+ server/fresh-agent/runtime-manager.ts \
+ server/fresh-agent/adapters/codex/adapter.ts \
+ src/lib/fresh-agent-ws.ts \
+ src/components/fresh-agent/useFreshAgentThreadController.ts \
+ src/components/fresh-agent/FreshAgentShell.tsx \
+ test/unit/server/ws-handler-fresh-agent.test.ts \
+ test/unit/server/fresh-agent/runtime-manager.test.ts \
+ test/unit/server/fresh-agent/codex-adapter.test.ts \
+ test/unit/client/lib/fresh-agent-ws.test.ts \
+ test/unit/client/components/fresh-agent/useFreshAgentThreadController.test.tsx \
+ test/e2e-browser/specs/fresh-agent.spec.ts
+git commit -m "Harden Freshcodex reconnect and action errors"
+```
+
+### Task 12: Documentation, Cleanup, And Final Cutover Verification
+
+**Files:**
+- Modify: `docs/index.html`
+- Modify: `docs/plans/2026-05-03-freshcodex-contract-foundation-test-plan.md` only if implementation changes alter the living Freshcodex acceptance-test inventory; otherwise leave the test plan untouched
+- Modify: `test/e2e-browser/specs/fresh-agent.spec.ts`
+- Modify: `test/e2e-browser/specs/fresh-agent-mobile.spec.ts`
+- Modify: any tests renamed from legacy `agent-chat` specs only if they now cover fresh-agent behavior
+
+- [ ] **Step 1: Write or update final acceptance checks**
+
+Ensure browser specs cover:
+
+```ts
+test('freshcodex create, send, interrupt, approval, question, fork, diff, and reconnect', ...)
+test('freshcodex mobile composer remains usable with long virtualized transcript', ...)
+```
+
+Do not delete legacy `agent-chat` browser specs unless equivalent Freshclaude or Freshcodex coverage exists and the old UI path is truly gone.
+
+- [ ] **Step 2: Run targeted acceptance checks**
+
+Run:
+
+```bash
+npm run test:e2e:chromium -- \
+ test/e2e-browser/specs/fresh-agent.spec.ts \
+ test/e2e-browser/specs/fresh-agent-mobile.spec.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 3: Update docs and clean stale names**
+
+Update `docs/index.html` to show Freshcodex as a rich fresh-agent pane with transcript, diff/review, fork, and worktree surfaces.
+
+Run:
+
+```bash
+rg -n "freshcodex.*agent-chat|Freshcodex.*agent-chat|sdk\\.send.*freshcodex|kind: 'agent-chat'.*freshcodex|provider: 'freshcodex'" src server shared test docs/index.html
+```
+
+Expected: no stale Freshcodex-on-agent-chat references in product code, tests, shared contracts, or the public docs mock. Do not include `docs/plans/**` in this grep; the historical implementation plan itself intentionally describes stale references that the implementation is removing. Legacy Freshclaude/agent-chat references may remain where they are still intentional.
+
+- [ ] **Step 4: Run full verification**
+
+Use the coordinator gate for broad tests:
+
+```bash
+npm run lint
+npm run audit:codex-app-server-schema
+npm run build
+FRESHELL_TEST_SUMMARY="freshcodex contract foundation final verification" npm test
+npm run test:e2e:chromium -- \
+ test/e2e-browser/specs/fresh-agent.spec.ts \
+ test/e2e-browser/specs/fresh-agent-mobile.spec.ts
+```
+
+Expected: all PASS. If a broad run fails, treat it as a real defect until proven unrelated and fixed or documented with evidence.
+
+- [ ] **Step 5: Final main integration safety check**
+
+If `origin/main` moved since Task 1, merge it into the feature branch in this worktree and re-run the final verification. Never merge directly on main.
+
+Run:
+
+```bash
+git fetch origin
+git rev-list --left-right --count HEAD...origin/main
+```
+
+If the right-side count is nonzero:
+
+```bash
+git merge origin/main
+npm run lint
+npm run audit:codex-app-server-schema
+npm run build
+FRESHELL_TEST_SUMMARY="freshcodex contract foundation post-main-merge" npm test
+npm run test:e2e:chromium -- \
+ test/e2e-browser/specs/fresh-agent.spec.ts \
+ test/e2e-browser/specs/fresh-agent-mobile.spec.ts
+```
+
+Expected: clean merge or resolved conflicts in the worktree, all final gates pass after the merge. If the right-side count is zero, do not create a no-op merge commit.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add \
+ docs/index.html \
+ test/e2e-browser/specs/fresh-agent.spec.ts \
+ test/e2e-browser/specs/fresh-agent-mobile.spec.ts \
+ docs/plans/2026-05-03-freshcodex-contract-foundation-test-plan.md
+git commit -m "Document and verify Freshcodex contract foundation"
+```
+
+If `docs/plans/2026-05-03-freshcodex-contract-foundation-test-plan.md` was not modified, omit it from `git add`.
+
+## Final Acceptance Checklist
+
+- `shared/fresh-agent-contract.ts` owns typed schemas for snapshots, turn pages, turn bodies, items, provider extensions, and action results.
+- `test/fixtures/fresh-agent/contract-traceability.ts` covers every shared fresh-agent durable schema with producer, parser, state, UI, fixture, and test owners.
+- `test/fixtures/coding-cli/codex-app-server/schema-traceability.ts` covers every generated Codex client request, response, server request, server notification, item variant, runtime leaf type, and explicit omission; the traceability tests pass without unclassified generated surfaces.
+- Server adapters and runtime manager parse every fresh-agent payload before returning it.
+- Client API parses fresh-agent payloads and surfaces controlled errors.
+- Session-type registry and runtime-provider adapter registry are separate; `freshclaude` and `kilroy` can share the Claude adapter without overwriting provider lookup.
+- `src/store/freshAgentSlice.ts` and `src/store/freshAgentTypes.ts` are real fresh-agent state modules, not aliases/re-exports of agent-chat modules.
+- `src/store/freshAgentThunks.ts` and `src/lib/pane-activity.ts` are fresh-agent-aware, key Freshcodex state by full `{ sessionType, provider, sessionId }` locators, and do not require Freshcodex sessions to exist in legacy agent-chat state.
+- Codex app-server client supports thread fork, turn start, turn interrupt, notifications, and server-request responses according to generated local app-server schemas.
+- Codex notification routing is generated-method-specific: `thread/started` routes by `params.thread.id`, ordinary thread events route by `params.threadId`, nullable/global warnings do not fake a thread target, and connection-scoped unsupported notifications such as `command/exec/outputDelta` and `fs/changed` never invalidate an arbitrary subscribed Freshcodex thread.
+- Codex server-initiated request ids round-trip unchanged through pending approval/question state, WebSocket response actions, adapter response serialization, and error events whether the generated JSON-RPC id is a string or an integer number.
+- Codex protocol schemas and fixtures reject impossible partial app-server entities, while accepting and normalizing generated-optional JSON wire fields. Generated-required fields such as `Thread.turns`, `Thread.cwd`, and `Thread.updatedAt` are required in tests and runtime parsing; generated-optional fields such as response cursors, optional thread metadata, turn timing/error fields, and lifecycle response defaults normalize to explicit `null`/default values before fresh-agent contracts depend on them.
+- Codex implemented client-request parameter schemas are strict generated-shape schemas. They preserve all supported generated runtime override fields and reject stale Freshell-only or misspelled outbound fields before a request reaches app-server.
+- Codex model pages and provider capability reads cross REST, adapter, and settings UI through typed fresh-agent schemas; provider-level `namespaceTools`, `imageGeneration`, and `webSearch` booleans are not lost as unknown model-item fields.
+- Codex generated leaf types for runtime settings, user input, statuses, and session/subagent source metadata are checked into the reduced schema fixture snapshot and covered by inventory tests; `Thread.source` preserves generated nested `SessionSource` / `SubAgentSource` metadata while `thread/list` filters use generated `ThreadSourceKind` values.
+- Codex transcript items are fully normalized; no raw transcript item arrays cross the fresh-agent boundary.
+- Codex reasoning items preserve both generated `summary` and generated `content` arrays.
+- Codex item-level `"inProgress"` statuses for command executions, file changes, MCP tool calls, dynamic tool calls, and collab-agent calls normalize to fresh-agent `"running"` statuses and are covered by schema-valid fixtures.
+- Codex file-change items preserve generated `update.move_path` as normalized `movePath` and render rename/move details in diff UI.
+- Codex collaboration transcript items preserve all generated `receiverThreadIds` and child-agent state metadata, and the shared UI renders every receiver id instead of collapsing child-agent calls to one thread.
+- Codex image-generation items preserve raw generated status strings, including statuses outside Freshell's small display-state buckets.
+- Codex generated item detail fields remain contract-visible: text elements, hook run ids, agent-message phase/memory citations, command source/action metadata, MCP resource/result metadata, dynamic-tool output content, web-search actions, and image-generation `result`/`savedPath` are not flattened away.
+- Codex MCP and dynamic tool-call `arguments` preserve arbitrary generated `JsonValue` and are not narrowed to object records in shared contracts, normalization, Redux state, or renderers.
+- Codex app-server notifications and server requests flow through the rich stdio runtime into fresh-agent subscriptions; live turns, items, token usage, status, diffs, review, compaction, child-thread/collaboration, and thread metadata updates refresh subscribed browsers.
+- Codex runtime-global server requests without a generated thread locator, currently auth-token refresh, are answered with valid JSON-RPC error envelopes and surfaced as typed Freshcodex runtime errors instead of hanging or attaching to an arbitrary thread. Legacy `applyPatchApproval` and `execCommandApproval` use generated `conversationId` as their Freshcodex thread locator and answer with root `ReviewDecision` response shapes.
+- Freshcodex renders without `agentChat` session state.
+- Freshcodex normal snapshot and transcript paths are page-first; they do not load the full Codex thread body list for every snapshot or visible-row hydration.
+- Freshcodex supports create, resume, send text/images with runtime settings, interrupt, fork, approvals, questions, diff/review/worktree/child-thread display, reconnect, retry, and stale revision recovery.
+- Freshcodex starts Codex review through `review/start`, preserves `reviewThreadId`/target/delivery metadata, lists/resumes rich Codex threads through paginated `thread/list`, exposes loaded thread ids according to `thread/loaded/list`, and populates model/capability UI from paginated `model/list` plus `modelProvider/capabilities/read`.
+- Freshcodex history APIs preserve `thread/list` `nextCursor` and `backwardsCursor`, and history queries explicitly include Codex `cli`, `vscode`, `exec`, rich `appServer`, and all generated child-agent source kinds rather than relying on app-server defaults.
+- Freshcodex settings/model APIs preserve `model/list` `nextCursor`; any dropdown convenience helper that returns a full option array explicitly drains pages and guards against cursor loops instead of truncating at the first page.
+- Freshcodex create/resume settings are Codex-shaped across picker creation, history open, pane persistence, remote tab snapshots, and attach; `sandbox`, generated Codex approval policies, and generated Codex effort values are not dropped or narrowed to Claude-only types.
+- Restored Freshcodex panes send attach context and load/resume the Codex app-server thread before snapshot or action work after a browser reload, server restart, or app-server process restart. Every restore/resume path uses `thread/resume { excludeTurns: true }` and then `thread/turns/list` for the visible page so restore cannot accidentally load a full transcript.
+- Existing raw Codex terminal panes still launch through the websocket app-server planner and receive a valid loopback `wsUrl`.
+- Long Freshcodex transcripts use paging and virtualization.
+- Mobile Freshcodex composer, banners, and transcript remain usable with keyboard inset changes.
+- Existing Freshclaude and hidden Kilroy paths still pass their targeted tests.
+- No storage-clearing migration is introduced.
+- `npm run lint`, `npm run audit:codex-app-server-schema`, `npm run build`, coordinated `npm test`, and targeted Freshcodex browser specs pass.
diff --git a/docs/plans/2026-05-03-freshcodex-contract-foundation-test-plan.md b/docs/plans/2026-05-03-freshcodex-contract-foundation-test-plan.md
new file mode 100644
index 000000000..6b2fd903d
--- /dev/null
+++ b/docs/plans/2026-05-03-freshcodex-contract-foundation-test-plan.md
@@ -0,0 +1,474 @@
+# Freshcodex Contract Foundation Test Plan
+
+> Reconciles the testing strategy against `docs/plans/2026-04-30-freshcodex-contract-foundation.md`. Strategy is internally consistent; no cost or scope changes required.
+
+## Strategy Reconciliation
+
+The implementation plan's Strategy Gate calls for (1) shared Zod contracts as the architectural center, (2) separating session-type identity from runtime-provider adapter lookup, (3) a real fresh-agent slice independent of agent-chat, (4) a Codex stdio rich runtime, (5) full normalization of every generated Codex item/request/notification, and (6) page-first turn body hydration. Every architectural prescription maps to concrete implementation tasks with matching test requirements.
+
+**No strategy changes requiring user approval.**
+
+External dependency: `codex app-server` (Codex CLI 0.128.0). The plan handles this by checking in reduced generated-schema snapshots to `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/` with a `schema-inventory.ts` helper so normal test runs have zero external `codex` dependency. A developer-only `scripts/audit-codex-app-server-schema.ts` reconciles the snapshot against the live CLI.
+
+---
+
+## Harness Requirements
+
+The existing test harnesses are sufficient. No new harnesses need to be built before tests can proceed.
+
+### Harness: Vitest unit (`test:vitest`)
+
+What it does: Runs unit and integration tests through Vitest with jsdom or node environment.
+What it exposes: Fixture-based component render (Testing Library + Redux store), mocked WS/API, `vi.hoisted()` for module-level mocks.
+Tests depending on it: All unit and server tests in sections 1-5 below.
+
+### Harness: Playwright E2E browser (`test:e2e:chromium`)
+
+What it does: Launches a full Freshell server in Chromium, exercises the real browser UI with `@playwright/test` and `freshellPage`, `harness`, `terminal` fixtures.
+What it exposes: `window.__FRESHELL_TEST_HARNESS__` dispatch, route interception, real WebSocket, screenshot comparison.
+Tests depending on it: Section 6 (E2E browser tests) and mobile E2E tests.
+
+---
+
+## Test Plan
+
+### Priority 0 — E2E Browser Tests: Full UI Drive-Through With Screenshots
+
+**This is the most important section.** These Playwright E2E tests run against a real Freshell server, use Redux state injection to seed every visual state, exercise the full UI through orchestration where applicable, and capture screenshots at key checkpoints. All tests that exercise a real Codex runtime use the cheapest available model (`gpt-5.4-nano`, $0.20/$1.25 per MTok input/output, as of May 2026) with reasoning effort `none` or `minimal` for cost control; one dedicated test toggles thinking from low to high and back to validate the effort toggle end-to-end.
+
+**Harness:** Playwright E2E (`page`, `harness`, `testServer`, `api` fixtures from `test/e2e-browser/helpers/fixtures.ts`). Server is an isolated `TestServer` per worker. Redux state injected via `window.__FRESHELL_TEST_HARNESS__`. API responses for fresh-agent endpoints mocked with `page.route()` using contract-valid fixtures from `test/fixtures/fresh-agent/`. Orchestration actions use the MCP REST API at `POST /api/orchestrate`.
+
+**Cost control for real Codex tests (E2E-13):** All tests use `model: 'gpt-5.4-nano'` (the cheapest current OpenAI model at $0.20/$1.25 per MTok input/output, sourced live from platform.openai.com/docs/models May 2026), `effort: 'none'` or `'minimal'`. The effort toggle test (E2E-13.6) is the sole exception — it switches to `effort: 'high'` for one turn, then back to `'minimal'`. Total estimated cost per full E2E-13 run: ~$0.10-$0.30.
+
+#### E2E-1: Pane Picker — Freshcodex Entry Creation (4 tests)
+
+| # | Name | What It Drives | Screenshot States |
+|---|------|----------------|-------------------|
+| E2E-1.1 | Picker shows Freshcodex when codex CLI enabled, hides kilroy and freshopencode | Enable `codex` CLI via Redux. Open PanePicker via context menu on terminal. Assert "Freshcodex" button with icon, label, shortcut hint. Assert `freshopencode` absent. Assert `kilroy` absent. Assert "Freshclaude" present if Claude enabled. Screenshot the picker open. | Picker open with Freshcodex entry |
+| E2E-1.2 | Picker supports keyboard nav to Freshcodex, Enter creates pane | Open PanePicker. Press ArrowDown until Freshcodex focused (check aria focus ring). Press Enter. Assert fresh-agent pane appears in layout with `kind: 'fresh-agent'`, `sessionType: 'freshcodex'`. Press Escape on another picker open — assert picker closes, no pane created. | Focus ring on entry; pane created |
+| E2E-1.3 | Picker hides Freshcodex when codex CLI disabled | Disable `codex` CLI in settings. Assert "Freshcodex" entry gone from picker. Re-enable. Assert it returns. Screenshot before/after. | Before/after toggle |
+| E2E-1.4 | Picker creates pane with correct defaults from Codex provider settings | Seed settings with `codingCli.providers.codex: { model: 'gpt-5.4-nano', sandbox: 'workspace-write', permissionMode: 'on-request' }` and `freshAgent.providers.freshcodex: { defaultEffort: 'low' }`. Click Freshcodex. Inspect Redux state: assert new pane has `model: 'gpt-5.4-nano'`, `sandbox: 'workspace-write'`, `permissionMode: 'on-request'`, `effort: 'low'`, `status: 'creating'`. Assert pane header shows "Freshcodex" title. | Pane header with Freshcodex badge |
+
+#### E2E-2: Freshcodex Shell — All Lifecycle States (9 tests)
+
+| # | Name | What It Drives | Screenshot States |
+|---|------|----------------|-------------------|
+| E2E-2.1 | Creating state shows spinner and disables composer | Inject `updatePaneContent` with `status: 'creating'`. Assert "Creating session..." text visible. Assert composer input disabled or hidden. Assert no transcript content. Screenshot creating state. | Shell creating state |
+| E2E-2.2 | Create failed with runtime unavailable shows error + retry | Inject `status: 'create-failed'`, `createError: { code: 'FRESH_AGENT_RUNTIME_UNAVAILABLE', message: 'Codex app-server not found', retryable: true }`. Assert red error banner with message. Assert "Retry" button. Click Retry — assert new `freshAgent.create` WS message sent with incremented `createRequestId`. Assert error banner cleared and status returns to `creating`. | Error banner with retry |
+| E2E-2.3 | Create failed with unsupported settings shows non-retryable error | Inject `createError: { code: 'FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING', message: '...', retryable: false }`. Assert error message mentions the invalid setting. Assert NO Retry button. Screenshot. | Settings error (no retry) |
+| E2E-2.4 | Idle state: composer enabled, empty transcript welcome | Inject `freshAgent.created` then `freshAgentSnapshotReceived` with `status: 'idle'`, `summary: 'Ready'`, `capabilities: { send: true, interrupt: false, ... }`, `initialTurnPage.turns: []`. Assert composer enabled with placeholder "Send a message...". Assert empty transcript shows welcome text. Assert Interrupt button not visible. Assert Send button enabled only when text present. | Idle shell with empty transcript |
+| E2E-2.5 | Running state: interrupt button active, composer disabled, items streaming | Inject snapshot with `status: 'running'`, `capabilities.interrupt: true`, active turn with `status: 'inProgress'` items. Assert composer shows Interrupt button (not Send). Assert "Running..." status badge. Assert transcript items visible (agent message with streaming indicator). Assert Send button not shown. | Shell running with items |
+| E2E-2.6 | Compacting state shows compaction indicator | Inject snapshot with `status: 'compacting'`, `tokenUsage.compactPercent: 45`. Assert "Compacting..." status visible. Assert composer disabled. Assert transcript still shows prior items. | Compacting indicator |
+| E2E-2.7 | Exited state shows ended status, composer offers new session | Inject snapshot with `status: 'exited'`, completed turns. Assert "Session ended" status. Assert composer area shows "Session ended — create a new one" or equivalent end-state message. Assert all transcript items still scrollable and readable. | Exited state |
+| E2E-2.8 | Lost session error with retry | Emit WS `freshAgent.error` with `code: 'FRESH_AGENT_LOST_SESSION'`, `retryable: true`. Assert error banner visible. Assert compose area disabled. Click Retry — assert attach WS message sent. | Lost session error |
+| E2E-2.9 | Runtime unavailable error with retry | Emit WS `freshAgent.error` with `code: 'FRESH_AGENT_RUNTIME_UNAVAILABLE'`. Assert "runtime unavailable" message. Assert retry restarts connection. | Runtime unavailable |
+
+#### E2E-3: Composer — Text, Images, Interrupt, Keyboard (9 tests)
+
+| # | Name | What It Drives | Screenshot States |
+|---|------|----------------|-------------------|
+| E2E-3.1 | Text compose and send dispatches correct WS message | Inject idle snapshot. Find composer textbox by accessible label. Type "Build a React component". Click "Send" button. Assert WS message captured by harness contains `type: 'freshAgent.send'`, `sessionType: 'freshcodex'`, `provider: 'codex'`, `text: 'Build a React component'`. Assert composer is empty after send. | Before send (text in composer); after (empty) |
+| E2E-3.2 | Send carries runtime settings from pane content | Create pane with `model: 'gpt-5.4-nano'`, `effort: 'minimal'`. Type "hi" and send. Assert WS message has `runtimeSettings: { model: 'gpt-5.4-nano', effort: 'minimal' }`. | — |
+| E2E-3.3 | Image attachment via URL input | Click "Attach image" (accessible button). Enter URL `https://example.test/screenshot.png`. Assert image preview thumbnail visible in composer. Assert media type inferred as `image/png`. Type "Review this" and send. Assert WS message `images: [{ kind: 'url', url: 'https://example.test/screenshot.png', mediaType: 'image/png' }]`. | Image preview in composer |
+| E2E-3.4 | Image attachment via file upload converts to data URI | Use `page.setInputFiles` on file input to upload a 1x1 PNG. Assert image preview rendered. Assert WS message `images: [{ kind: 'data', mediaType: 'image/png', data: }]`. | Uploaded image preview |
+| E2E-3.5 | Remove attached image before send | Attach image via URL. Assert preview shown. Click remove/delete button on preview. Assert preview gone. Assert "Send" button now disabled (no text either). Type text without image. Send. Assert NO `images` in WS message. | Before/after remove |
+| E2E-3.6 | Send button disabled when empty, enabled with text or image | Assert Send button is `disabled` when composer empty. Type text — assert enabled. Clear text — assert disabled. Attach image (no text) — assert enabled. Remove image — assert disabled again. | Disabled/enabled states |
+| E2E-3.7 | Interrupt active turn dispatches interrupt WS message | Inject snapshot with `status: 'running'`, `capabilities.interrupt: true`. Assert "Interrupt" button visible (replaces Send button). Click Interrupt. Assert WS `freshAgent.interrupt` sent with `sessionType: 'freshcodex'`. Assert button shows spinner while awaiting response. | Interrupt button active |
+| E2E-3.8 | Enter sends, Shift+Enter inserts newline | Focus composer. Type "line1", press Shift+Enter, type "line2". Assert composer shows two lines. Press Enter. Assert WS send dispatched with multiline text. | Newline in composer |
+| E2E-3.9 | Composer disabled during create/attach | Inject `status: 'creating'` — assert composer input is disabled. Inject `freshAgent.created` with `status: 'idle'` — assert composer enables. | Disabled→enabled |
+
+#### E2E-4: Transcript — All Item Kinds, Paging, Virtualization (19 tests)
+
+| # | Name | What It Drives | Screenshot States |
+|---|------|----------------|-------------------|
+| E2E-4.1 | All item kinds render with correct semantic labels | Seed snapshot with one body turn containing all 17 normalized item kinds. Assert each renders: message with role, command with output, file_change with path, reasoning with summary, plan, review, web_search, hook_prompt, image, image_generation, collaboration, context_compaction, dynamic_tool, request_prompt, error, tool. Screenshot full transcript. | Full transcript with all items |
+| E2E-4.2 | User message multi-part: text + image + mention + skill | Inject message with content parts: text "Use this", image URL, mention "README.md" at "/repo/README.md", skill "reviewer" at "/repo/.codex/skills/reviewer/SKILL.md". Assert text, image, mention chip with path, skill chip with path all rendered. | Multi-part user message |
+| E2E-4.3 | Agent message markdown rendering | Inject agent message with markdown: headers, code blocks, bold, lists. Assert rendered with proper formatting (code blocks monospace, bold text bold). | Formatted agent response |
+| E2E-4.4 | Command item: all 5 status variants | Inject command items in each status: pending (spinner), running (spinner + partial output), completed (checkmark + output + exitCode 0), failed (X + output + exitCode 1), declined (declined badge). Assert each shows correct status badge and content. | Command status variants |
+| E2E-4.5 | File change with expandable diff | Inject file_change with changes: `{ path: 'src/app.ts', diff: '@@ -1 +1 @@ ...' }`. Assert path visible. Assert diff collapsed by default. Click "View diff" toggle — assert diff content rendered. Click again — assert collapsed. | Collapsed/expanded diff |
+| E2E-4.6 | Reasoning: collapsed by default, expand to reveal full text | Inject reasoning with `summary: ['Let me think...']`, `text: 'Detailed reasoning...'`. Assert summary visible, not full text. Click expand — assert full text visible. Click collapse — assert hidden. | Collapsed/expanded reasoning |
+| E2E-4.7 | Context compaction shows token deltas | Inject `context_compaction` with `beforeTokens: 50000`, `afterTokens: 20000`. Assert "Compacted context" badge with token reduction visible. | Compaction item |
+| E2E-4.8 | Review entered/exited items | Inject review items for `entered` and `exited` phases. Assert "Entered review mode" and "Exited review mode" badges. | Review transitions |
+| E2E-4.9 | Error item renders message and code | Inject error item with `message: 'Connection lost'`, `code: 'TIMEOUT'`. Assert error message and code badge visible. | Error transcript item |
+| E2E-4.10 | Request prompt item — pending shows prompt, no action in transcript | Inject `request_prompt` with `requestKind: 'approval'`, `status: 'pending'`. Assert prompt text, pending badge. Assert action is in banner (not card). | Pending request in transcript |
+| E2E-4.11 | Request prompt item — resolved shows no actions | Same item with `status: 'resolved'`. Assert "Resolved" badge. Assert no action buttons. | Resolved request |
+| E2E-4.12 | Turn page: load more turns via cursor pagination | Mock API to return page with `nextCursor: 'c2'`. Seed 3 turn summaries. Assert 3 rows visible. Click "Load more" or scroll to bottom. Assert API called with `cursor: 'c2'`. Assert 6 rows after load. | Before/after page load |
+| E2E-4.13 | Virtualized list: 1000 turns but only visible rows in DOM | Seed 1000 turn summaries. Render at 600px container. Query DOM for turn rows — assert <25 rendered. Scroll to middle — assert only visible rows in DOM. Assert `aria-setsize: 1000` on list. | Virtualized scroll position |
+| E2E-4.14 | Body hydration from page-provided items (no separate request) | Seed page where turn-2 has `body` popuplated. Assert turn-2 items render directly. Turn-1 has no body — assert "Load body" button. Click it — assert API `getFreshAgentTurnBody` called. Assert body renders. | Page body; loaded body |
+| E2E-4.15 | Stale revision error during body load | Mock body API rejects with `{ code: 'FRESH_AGENT_STALE_THREAD_REVISION' }`. Click "Load body" on turn-1. Assert "session changed" error toast/message. Assert snapshot refresh triggered. | Stale revision error |
+| E2E-4.16 | Dynamic tool item — declined with reason | Inject `dynamic_tool` with `status: 'declined'`, `name: 'unsupported-tool'`, `reason: 'Not supported'`. Assert decline reason visible. | Declined dynamic tool |
+| E2E-4.17 | Collaboration item shows cross-thread metadata | Inject `collaboration` with `tool: 'code-reviewer'`, `senderThreadId: 't1'`, `receiverThreadId: 't2'`, `newThreadId: 't3'`. Assert thread IDs and tool name visible. | Collaboration item |
+| E2E-4.18 | Image generation item shows result | Inject `image_generation` with `prompt: 'diagram'`, `status: 'completed'`, `imageUrl: 'https://example.test/gen.png'`. Assert generated image renders. | Generated image |
+| E2E-4.19 | Web search item shows query | Inject `web_search` with `query: 'React hooks'`, `status: 'completed'`. Assert query and status visible. | Web search result |
+
+#### E2E-5: Workspace Panel — Worktrees, Child Threads, Diffs, Review, Fork, Tokens (11 tests)
+
+| # | Name | What It Drives | Screenshot States |
+|---|------|----------------|-------------------|
+| E2E-5.1 | Panel toggle: open sidebar button, click panel content | Click workspace panel toggle (icon in shell header). Assert panel slides open. Assert panel has accessible `region` role with label. Click overlay or toggle again — assert panel closes. | Panel open; closed |
+| E2E-5.2 | Worktree list rendered from snapshot | Inject snapshot with `worktrees: [{ name: 'feature/freshcodex', path: '/repo', branch: 'feature/freshcodex' }]`. Open panel. Assert worktree section with branch name and path. | Worktree section |
+| E2E-5.3 | Child threads section with clickable entries | Inject `childThreads: [{ threadId: 'thread-child-1', title: 'Review shell', source: 'review' }]`. Assert child thread entry with title and source badge. Click — assert navigates to that pane if in layout. | Child thread entry |
+| E2E-5.4 | Fork lineage shows parent thread | Inject extensions codex fork: `{ parentThreadId: 'thread-parent-1' }`. Open panel. Assert fork lineage section shows "Forked from" with parent thread ID. | Fork lineage |
+| E2E-5.5 | Diff list shows file paths with change kind badges | Inject `diffs: [{ path: 'src/app.ts', changeKind: 'modify', summary: 'Updated logic' }]`. Assert diff entry with file path and "modified" badge. | Diff list |
+| E2E-5.6 | Review status section with output | Inject review metadata with status "complete" and output text. Assert review section shows status and output rendered. | Review output |
+| E2E-5.7 | Start review button: enabled dispatches WS | Inject `capabilities.review: true`, `status: 'idle'`. Click "Start review" in panel. Assert WS `freshAgent.review.start` with `target: { type: 'uncommittedChanges' }`, `delivery: 'inline'`. Assert button shows loading. | Review start → loading |
+| E2E-5.8 | Start review button: disabled with reason label | Inject `capabilities.review: false`. Assert "Start review" button disabled. Assert tooltip/label "Review not supported by this provider." | Disabled review |
+| E2E-5.9 | Token usage display | Inject `tokenUsage: { inputTokens: 5000, outputTokens: 1200, totalTokens: 6200, contextTokens: 45000, compactPercent: 30 }`. Assert token counts in panel with labels. | Token section |
+| E2E-5.10 | Fork action: button → WS → new pane | Inject `capabilities.fork: true`. Click "Fork session" in panel. Assert WS `freshAgent.fork` sent. Simulate WS response `freshAgent.forked` with `sessionId: 'thread-fork-1'`, `parentThreadId: 'thread-1'`. Assert new sibling pane appears with `sessionId: 'thread-fork-1'`. Screenshot layout with two panes. | Fork → sibling pane |
+| E2E-5.11 | Panel collapses to compact sidebar on narrow viewport | Resize pane to 350px wide. Assert panel collapses to icon-only buttons (worktree, diff, child, fork, review icons). Resize to 800px. Assert full panel returns. | Compact sidebar; full panel |
+
+#### E2E-6: Approval Banners — All Request Types (6 tests)
+
+| # | Name | What It Drives | Screenshot States |
+|---|------|----------------|-------------------|
+| E2E-6.1 | Command approval: accept/decline/acceptForSession | Inject `pendingApprovals: [{ requestId: 'cmd-1', kind: 'command_approval', title: 'Run: npm test', command: 'npm test' }]`. Assert banner with command text. Click "Accept" — assert WS response `kind: 'command_approval'`, `decision: 'accept'`. Banner dismissed. | Banner; after accept |
+| E2E-6.2 | File change approval: three decision options | Inject file_change_approval with file path. Assert "Accept", "Accept for session", "Decline" buttons. Test each dispatches correct WS response. | File change approval |
+| E2E-6.3 | Permissions approval: turn vs session scope | Inject permission approval banner. Click "Grant for turn" — assert WS `scope: 'turn'`. Repeat with "Grant for session" — assert `scope: 'session'`. Assert `strictAutoReview` toggle if present. | Permissions with scope |
+| E2E-6.4 | Multiple stacked banners render in order | Inject 2 pending approvals. Assert both rendered in order. Resolve first — assert first removed, second remains. | Stacked banners |
+| E2E-6.5 | Banner dismisses on snapshot refresh | Inject pending approval. Assert banner visible. Refresh snapshot via WS event with empty `pendingApprovals`. Assert banner removed. | Banner dismissed |
+| E2E-6.6 | Banner handles cancel response | Inject approval. Click "Cancel" — assert WS response `decision: 'cancel'`. Assert banner dismissed. | Cancel action |
+
+#### E2E-7: Question Banners — User Input and MCP Elicitation (5 tests)
+
+| # | Name | What It Drives | Screenshot States |
+|---|------|----------------|-------------------|
+| E2E-7.1 | Tool user input: select option, submit with array answers | Inject `pendingQuestions: [{ requestId: 'ui-1', kind: 'tool_user_input', title: 'Choose', prompt: 'Select:', fields: [{ name: 'choice', options: ['a', 'b', 'c'], multi: true }] }]`. Select 'a' and 'b'. Click Submit. Assert WS response `kind: 'tool_user_input'`, `answers: { choice: { answers: ['a', 'b'] } }`. | Question with selection |
+| E2E-7.2 | Tool user input: multiple fields | Inject question with fields `name` (text input) and `description` (text area). Fill both. Assert response has answers for both keys. | Multi-field question |
+| E2E-7.3 | MCP elicitation: accept with content | Inject `kind: 'mcp_elicitation'`, `title: 'Confirm', prompt: 'Approve?'`. Click "Accept". Assert WS `kind: 'mcp_elicitation'`, `action: 'accept'`, `content: expect.any(Object)`, `_meta: null`. | MCP accept |
+| E2E-7.4 | MCP elicitation: decline | Click "Decline". Assert WS `action: 'decline'`. Assert banner dismissed. | Decline |
+| E2E-7.5 | Banners have accessible labels and keyboard operable | Tab through all interactive elements in question banner. Assert each field/button receives focus with visible ring. Assert each action button has `aria-label`. | Keyboard focus |
+
+#### E2E-8: Fork, Review, and Cross-Pane Lifecycle via Orchestration (6 tests)
+
+| # | Name | What It Drives | Screenshot States |
+|---|------|----------------|-------------------|
+| E2E-8.1 | Full fork flow: source pane → sibling pane appears | Seed Freshcodex pane with `sessionId: 'thread-1'`, `capabilities.fork: true`. Open workspace panel, click Fork. Assert WS `freshAgent.fork` sent. Inject WS `freshAgent.forked` with `sessionId: 'thread-fork-1'`, `parentThreadId: 'thread-1'`. Assert new sibling pane in layout. Assert new pane `sessionId: 'thread-fork-1'`, `sessionType: 'freshcodex'`. Inspect Redux for correct pane tree. Screenshot layout. | Layout with two Freshcodex panes |
+| E2E-8.2 | Forked pane loads independent snapshot | After fork, inject `freshAgent.created` + `freshAgentSnapshotReceived` for forked pane with different turn content than parent. Assert forked pane shows its own turns, not parent's. Assert forked workspace panel shows `parentThreadId` in fork lineage. | Forked pane with own content |
+| E2E-8.3 | Review flow: start → started → panel shows results | Inject `capabilities.review: true`. Click "Start review". Assert WS sent. Inject `freshAgent.review.started`. Assert "Review started" indicator. Inject snapshot update with review items in transcript. Assert workspace panel shows review output. | Review started; results in panel |
+| E2E-8.4 | Review with commit target | Select commit SHA target in review options (if UI exposes it). Assert WS includes `target: { type: 'commit', sha: ... }`. | Custom review target |
+| E2E-8.5 | Fork via orchestration MCP API | POST `/api/orchestrate { action: 'fork', params: { target: 'thread-1' } }`. Assert new pane created. Assert `freshAgent.forked` emitted. | MCP-orchestrated fork |
+| E2E-8.6 | Send input via orchestration MCP | POST `/api/orchestrate { action: 'send-keys', params: { target: 'thread-1', keys: 'Implement login', literal: true } }`. Assert text appears in composer and send is dispatched. | Orchestration input |
+
+#### E2E-9: Mobile Viewport — Keyboard, Touch, Layout (5 tests)
+
+| # | Name | What It Drives | Screenshot States |
+|---|------|----------------|-------------------|
+| E2E-9.1 | Composer sticky above keyboard | Set iPhone 14 viewport (390x844). Inject idle snapshot. Simulate software keyboard via CSS variable `--keyboard-inset-bottom: 300px`. Assert composer is sticky at viewport bottom, not hidden behind keyboard. Assert `enterkeyhint='send'`. | Mobile with keyboard |
+| E2E-9.2 | Transcript scrollable with keyboard open | Seed 10 items. Simulate keyboard open. Assert transcript container adjusts height. Assert last item scrollable into view. | Mobile transcript with keyboard |
+| E2E-9.3 | Approval buttons meet 44px minimum touch target | Inject approval at mobile viewport. Measure button heights — assert ≥44px or equivalent padding. Assert accessible labels on all buttons. | Mobile approval buttons |
+| E2E-9.4 | Workspace panel as bottom sheet on mobile | Open workspace panel on mobile. Assert it appears as bottom sheet (not side panel). Assert swipe-down handle or close button. | Mobile bottom sheet |
+| E2E-9.5 | PanePicker full-width on mobile | Open PanePicker on mobile viewport. Assert near-full width. Assert scrollable if many entries. | Mobile picker |
+
+#### E2E-10: Multi-Client and Reconnect Resilience (4 tests)
+
+| # | Name | What It Drives | Screenshot States |
+|---|------|----------------|-------------------|
+| E2E-10.1 | Two clients subscribed to same thread both refresh on notification | Open two browser contexts connected to same TestServer. Both create panes for same thread. Inject `turn/completed` notification via server WS. Assert both clients receive snapshot invalidation and refresh. Assert both show updated content. | Two clients side-by-side |
+| E2E-10.2 | Reconnect after WS drop preserves state, sends attach not create | Seed pane with `sessionId: 'thread-1'`. Force-disconnect WS via `harness.forceDisconnect()`. Wait for reconnect. Assert pane sends `freshAgent.attach` with existing `sessionId`, NOT `freshAgent.create`. Assert snapshot reloads. Assert turn history still visible. | After reconnect |
+| E2E-10.3 | Notification burst debounces to single refresh | Inject 5 `turn/started` notifications in rapid succession (<100ms). Poll harness for sent REST requests — assert only 1 snapshot fetch. Assert final state reflects latest notification. | Debounced refresh |
+| E2E-10.4 | Server restart recovery: error state → retry → reloaded | Stop TestServer. Assert all panes show disconnected/error. Restart server. Click Retry on Freshcodex pane. Assert pane reattaches and reloads snapshot with same turns. | Before/after recovery |
+
+#### E2E-11: Settings — Model, Effort, Sandbox, Capabilities (6 tests)
+
+| # | Name | What It Drives | Screenshot States |
+|---|------|----------------|-------------------|
+| E2E-11.1 | Settings dialog shows Codex model list from mocked app-server | Mock `/api/fresh-agent/models/codex` with model list. Open Settings → Freshcodex section. Assert model dropdown populated with model names. Assert currently selected model highlighted. | Settings dialog |
+| E2E-11.2 | Effort dropdown shows only Codex values (no 'max') | Open effort dropdown. Assert options: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. Assert `max` NOT present. Assert current selection matches persisted value. | Effort dropdown |
+| E2E-11.3 | Sandbox selector: read-only, workspace-write, danger-full-access | Cycle through sandbox options. Assert each value reflected in Redux after save. Reload page — assert sandbox persisted. | Sandbox selector |
+| E2E-11.4 | Freshcodex settings independent from Claude settings | Open Freshcodex settings, set model to "gpt-5.4-nano". Switch to Freshclaude settings — assert different model visible. Switch back — assert Freshcodex values preserved. | Independent settings |
+| E2E-11.5 | Unavailable saved model shows "(Unavailable)" | Mock capabilities where saved model is absent. Assert "(Unavailable)" label next to model. Assert info text about fallback. | Unavailable model |
+| E2E-11.6 | Settings on mobile as bottom sheet | Open settings on iPhone 14 viewport. Assert full-height bottom sheet. Assert scrollable. Assert Save/Cancel sticky at bottom. | Mobile settings sheet |
+
+#### E2E-12: Session Persistence and Restore (4 tests)
+
+| # | Name | What It Drives | Screenshot States |
+|---|------|----------------|-------------------|
+| E2E-12.1 | Full settings survive page reload | Create full Freshcodex pane: `model: 'gpt-5.4-nano'`, `sandbox: 'workspace-write'`, `permissionMode: 'on-request'`, `effort: 'low'`, `initialCwd: '/repo'`. Reload page. Assert pane restored with ALL fields intact. Assert attach WS sent with `cwd` and `runtimeSettings`. | After reload |
+| E2E-12.2 | Legacy pane with Claude-only values shows controlled createError, not dropped | Pre-populate localStorage with a pane having `permissionMode: 'bypassPermissions'`, `effort: 'max'`. Load page. Assert pane renders (not dropped). Assert createError banner with `FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING`. Assert pane header still shows "Freshcodex". | Legacy pane error |
+| E2E-12.3 | Granular approval policy survives persistence round-trip | Set `permissionMode: { granular: { sandbox_approval: true, rules: false, skill_approval: true, request_permissions: true, mcp_elicitations: false } }`. Reload. Assert full object preserved (not cast to string, not narrowed to enum). | Granular policy preserved |
+| E2E-12.4 | Tab snapshot captures Freshcodex metadata for remote sessions | Create Freshcodex pane with settings. Trigger tab snapshot via Redux (`collectPaneSnapshots`). Assert snapshot payload includes `sandbox`, `permissionMode`, `effort`, `sessionType`, `provider`. Assert effort is `'xhigh'` not `'max'` if xhigh was set. | Tab snapshot content |
+
+#### E2E-13: Real Codex Runtime Integration — Cost-Controlled (10 tests)
+
+These tests connect to a real `codex` app-server via the rich stdio runtime. Each uses `model: 'gpt-5.4-nano'` (OpenAI's cheapest current model, $0.20/$1.25 per MTok input/output) with `effort: 'none'` or `'minimal'` except E2E-13.6 which toggles effort to `'high'` once. All cost ~$0.005-$0.02 per turn. Skip gracefully (`test.skip(!codexAvailable)`) in CI without Codex.
+
+| # | Name | What It Drives | Screenshot States |
+|---|------|----------------|-------------------|
+| E2E-13.1 | Real: create pane → real thread created, snapshot loads | Create Freshcodex pane with `model: 'gpt-5.4-nano'`, `effort: 'minimal'`. Wait for `freshAgent.created` + snapshot load. Assert snapshot `status: 'idle'`. Assert `threadId` is a real UUID. Assert pane header shows "Freshcodex". Screenshot the connected pane. | Real connected pane |
+| E2E-13.2 | Real: send message → turn runs → items appear → idle again | Type "Say hello in exactly one short sentence." Send. Assert composer disabled, status → "Running". Wait for turn items: user message and agent message. Assert agent message content visible. Wait for status → "idle". Assert composer re-enabled. Screenshot the completed turn. | Real turn complete |
+| E2E-13.3 | Real: interrupt mid-execution | Send: "List recursively all files in the current directory and describe each one." Wait for turn to start (status → "Running"). Click Interrupt. Assert WS `freshAgent.interrupt` sent. Wait for status → "idle" with turn showing interrupted status. Assert last turn has items from before interrupt. | Interrupted turn |
+| E2E-13.4 | Real: fork creates new pane with real forked thread | After E2E-13.2 turn completes, click Fork. Wait for `freshAgent.forked`. Assert new sibling pane appears with new thread ID. Assert forked pane loads independently. Assert workspace panel shows `parentThreadId`. | Forked panes |
+| E2E-13.5 | Real: send with image input | Attach a small test PNG. Type "Describe this image very briefly." Send. Wait for turn. Assert user message content includes image part. Assert agent responds. | Image input turn complete |
+| E2E-13.6 | Real: effort toggle low → high → low | Create pane with `effort: 'minimal'`, `model: 'gpt-5.4-nano'`. Send "What is 2+2?" — completes with minimal effort. Change settings to `effort: 'high'`. Send "What is 3+3?" — completes with high effort (verify via metadata). Change back to `effort: 'minimal'`. Send "What is 4+4?" — back to minimal. Screenshot each state. | Effort toggle: low, high, low |
+| E2E-13.7 | Real: approval flow — command approval banner and respond | Set `permissionMode: 'on-request'`. Send "Run: ls -la" or a command that triggers approval. Wait for `pendingApprovals` in snapshot. Assert command approval banner with command text. Click "Accept". Assert command executes and output appears in turn. | Approval banner → executed |
+| E2E-13.8 | Real: runtime unavailable → restart → recovery | Kill the Codex app-server child process. Assert pane shows `FRESH_AGENT_RUNTIME_UNAVAILABLE` error. Restart app-server (or click Retry). Assert pane reconnects, attaches to same thread, reloads snapshot with prior turns visible. | Error → recovered |
+| E2E-13.9 | Real: token usage updates after turn | After E2E-13.2 completes, check snapshot or WS for `tokenUsage` update with non-zero tokens. Assert `totalTokens > 0`. Assert workspace panel shows token counts. | Token counts |
+| E2E-13.10 | Real: model list fetches from live app-server | Open settings. Fetch model list from real app-server. Assert list includes entries with `id`, `displayName`, `defaultReasoningEffort`. Screenshot settings with real models. | Real model list |
+
+---
+
+### Priority 1 — Existing red checks that must go green
+
+The current suite has no known CI-blocking red tests in this worktree, but the following characterization checks must be verified green after Task 1 (main merge) before proceeding.
+
+| # | Name | Type | Disposition | Harness | Details |
+|---|------|------|-------------|---------|---------|
+| 1 | **Main-origin Codex app-server client tests remain green after merge** | regression | existing | Vitest unit | **Preconditions:** `origin/main` merged into worktree branch. **Actions:** Run `npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts`. **Expected outcome:** All pass. Merge conflicts resolved without deleting fresh-agent tests or reverting main's app-server fixes. **Source of truth:** `npm test` on `origin/main`. **Interactions:** Codex launch-planner, raw terminal attach path. |
+| 2 | **Main-origin panesSlice and SDK WS handler tests remain green after merge** | regression | existing | Vitest unit | **Preconditions:** `origin/main` merged. **Actions:** Run `test/unit/client/store/panesSlice.test.ts test/unit/server/ws-handler-sdk.test.ts`. **Expected outcome:** All pass. Stale-hydration protection and reconnect recovery preserved. **Source of truth:** Main-origin test expectations. **Interactions:** Pane hydration, WebSocket handshake. |
+| 3 | **FreshAgentView renders without crashing after merge** | regression | existing | Vitest unit | **Preconditions:** Merge complete. **Actions:** Run `test/unit/client/components/fresh-agent/FreshAgentView.test.tsx`. **Expected outcome:** All pass. **Source of truth:** Existing test expectations on this branch. **Interactions:** WS mock, API mock. |
+
+### Priority 2 — High-value existing integration and scenario tests
+
+These existing tests exercise the real product surface (Redux store, React components, Express routes, WebSocket handlers) and must continue passing through the entire refactor.
+
+| # | Name | Type | Disposition | Harness | Details |
+|---|------|------|-------------|---------|---------|
+| 4 | **Freshclaude panes render, send, interrupt, and answer questions through fresh-agent surface** | scenario | existing | Vitest unit | **Preconditions:** Store seeded with fresh-agent pane. **Actions:** Render `FreshAgentView` for freshclaude, verify snapshot renders, send text, click interrupt, click question answer button. **Expected outcome:** Snapshot content visible, WS messages dispatched with correct `type`, sessionId, sessionType, provider. **Source of truth:** Freshclaude existing behavior and `shared/ws-protocol.ts` message schemas. **Interactions:** WebSocket client, API client, freshAgent reducer. |
+| 5 | **Freshcodex panes create and render initial snapshot** | scenario | existing | Vitest unit | **Preconditions:** Store seeded with freshcodex pane. **Actions:** Render `FreshAgentView` for freshcodex, assert create WS message sent, deliver `freshAgent.created`, assert snapshot loaded. **Expected outcome:** Create WS message includes sessionType and provider; snapshot appears in DOM. **Source of truth:** `shared/fresh-agent.ts` descriptor table. **Interactions:** WebSocket, API. |
+| 6 | **Fresh Agent E2E: picker entries appear when CLIs are enabled** | scenario | existing | Playwright E2E | **Preconditions:** Freshell server running, both `claude` and `codex` CLIs enabled. **Actions:** Open pane picker via context menu on xterm. **Expected outcome:** Freshclaude and Freshcodex entries visible. `freshopencode` not shown (disabled). Kilroy not shown (hidden). **Source of truth:** `shared/fresh-agent.ts` descriptors. **Interactions:** PaneContainer, PanePicker, settings slice. |
+| 7 | **Fresh Agent E2E: freshclaude banners render and answer over WS** | integration | existing | Playwright E2E | **Preconditions:** Freshclaude pane created, API routes mocked with Claude-shaped snapshot including pending approvals/questions. **Actions:** Wait for banner render, click approve/answer buttons. **Expected outcome:** WS messages sent with correct `type`, answers/decisions. Banners dismissed. **Source of truth:** `shared/ws-protocol.ts`. **Interactions:** WebSocket, REST API, banners, composer. |
+
+### Priority 3 — New integration and scenario tests to close gaps
+
+These tests cover behavior gaps where the existing suite does not test the right surface or with enough fidelity.
+
+#### Contract foundation (Tasks 2-3)
+
+| # | Name | Type | Disposition | Harness | Details |
+|---|------|------|-------------|---------|---------|
+| 8 | **Shared contract schemas validate valid Codex snapshots, turn pages, turn bodies, items, and action responses** | invariant | new | Vitest unit | **Preconditions:** `shared/fresh-agent-contract.ts` implemented. **Actions:** Parse valid fixtures through every schema (`FreshAgentThreadSnapshotSchema`, `FreshAgentTurnPageSchema`, `FreshAgentTurnBodySchema`, `FreshAgentTranscriptItemSchema`, `FreshAgentServerRequestResponseSchema`, `FreshAgentThreadListPageSchema`, `FreshAgentRuntimeSettingsSchema`). **Expected outcome:** All parse successfully. Thread-list fixture preserves `items`, `nextCursor`, `backwardsCursor` (not collapsed to array). **Source of truth:** Generated Codex 0.128.0 schemas + implementation plan contract section. **Interactions:** Client API boundary, server adapter boundary. |
+| 9 | **Shared contract schemas reject invalid statuses, kinds, and missing required fields** | boundary | new | Vitest unit | **Preconditions:** Schemas implemented. **Actions:** Pass `status: 'creating'` (invalid), `kind: 'raw'` (invalid item), snapshot missing `revision`, etc. **Expected outcome:** Each throws ZodError with specific path/message. **Source of truth:** `FreshAgentThreadStatusSchema` enum, `FreshAgentTranscriptItemSchema` discriminator. **Interactions:** Error handling in boundary layers. |
+| 10 | **Shared contract schemas accept generated Codex effort values and reject legacy Claude effort `max` at Codex boundary** | boundary | new | Vitest unit | **Preconditions:** `FreshAgentRuntimeSettingsSchema` implemented. **Actions:** Parse `effort: 'xhigh'` and `effort: 'max'`. **Expected outcome:** `xhigh` passes; `max` is accepted by the shared schema (union with legacy) but `FreshAgentCodexReasoningEffortSchema` rejects `max`. **Source of truth:** Generated `ReasoningEffort.ts` values (`none`, `minimal`, `low`, `medium`, `high`, `xhigh`). **Interactions:** Codex adapter validation, create/resume/send runtime-settings mappers. |
+| 11 | **Shared contract schemas accept Codex approval policies and reject Claude bypassPermissions at Codex boundary** | boundary | new | Vitest unit | **Preconditions:** `FreshAgentCodexApprovalPolicySchema` implemented. **Actions:** Parse `untrusted`, `on-failure`, `on-request`, `never`, and granular `{ granular: { sandbox_approval: true, ... } }` objects; reject `bypassPermissions`. **Expected outcome:** Codex values parse; Claude-only values rejected by Codex-specific schema. **Source of truth:** Generated `AskForApproval.ts`. **Interactions:** Codex adapter runtime-settings mappers. |
+| 12 | **Shared contract schemas preserve multi-part user message content with text, images, mentions, and skills** | invariant | new | Vitest unit | **Preconditions:** `FreshAgentMessageContentPartSchema` implemented. **Actions:** Parse message with all five content part kinds (`text`, `image`, `image` with `path`, `mention`, `skill`). **Expected outcome:** All parts preserved in discriminated union. **Source of truth:** Generated `UserInput.ts` variants (`text`, `image`, `localImage`, `skill`, `mention`). **Interactions:** Transcript item renderer, Codex normalize. |
+| 13 | **Shared contract schemas cover every Codex transcript item kind from generated schema** | invariant | new | Vitest unit | **Preconditions:** `FreshAgentTranscriptItemSchema` implemented. **Actions:** Table-driven parse of all 16 generated `ThreadItem` variants through `FreshAgentTranscriptItemSchema`. **Expected outcome:** Each variant maps to expected normalized kind. **Source of truth:** Checked-in `ThreadItem.ts` snapshot via `schema-inventory.ts`. **Interactions:** Item card renderer, normalize. |
+| 14 | **FreshAgentServerRequestResponseSchema preserves discriminated kind shapes for all server request types** | invariant | new | Vitest unit | **Preconditions:** Schema implemented. **Actions:** Parse `command_approval` (decision), `file_change_approval` (decision), `permissions_approval` (permissions, scope, strictAutoReview), `tool_user_input` (answers with array), `mcp_elicitation` (action, content, _meta). **Expected outcome:** Each kind preserves its generated response shape - not collapsed to string answers/decisions. **Source of truth:** Generated response schemas for each server-request method. **Interactions:** Adapter respondToServerRequest, WebSocket action, controller. |
+| 15 | **FreshAgentInputImageSchema accepts url, local path, and data URL images** | boundary | new | Vitest unit | **Preconditions:** Schema implemented. **Actions:** Parse `{ kind: 'url', url: 'https://...' }`, `{ kind: 'local', path: '/tmp/img.png' }`, `{ kind: 'data', data: 'base64...', mediaType: 'image/png' }`. **Expected outcome:** All three varieties parse. **Source of truth:** Generated `UserInput.ts` image types + Freshell browser upload representation. **Interactions:** Composer image upload, adapter send. |
+
+#### Provider registry and session routing (Task 3)
+
+| # | Name | Type | Disposition | Harness | Details |
+|---|------|------|-------------|---------|---------|
+| 16 | **Provider registry keeps session-type identity separate from runtime-provider adapter lookup** | invariant | new | Vitest unit | **Preconditions:** Registry split into `sessionTypes` and `runtimeAdapters`. **Actions:** Register `freshclaude` and `kilroy` both with `runtimeProvider: 'claude'`; resolve both by session type and by runtime provider. **Expected outcome:** `resolveBySessionType('freshclaude').adapter === resolveBySessionType('kilroy').adapter`. `resolveByRuntimeProvider('claude')` returns the same Claude adapter regardless of registration order. No Map-overwrite side effect. **Source of truth:** Implementation plan invariant "freshclaude and kilroy sharing the Claude adapter must be an intentional many-to-one mapping, not a Map overwrite side effect." **Interactions:** Runtime manager create/attach/resolve. |
+| 17 | **Runtime manager routes actions by full session locator, not bare sessionId** | invariant | new | Vitest unit | **Preconditions:** Two sessions attached with same `sessionId` but different `sessionType`/`provider` (Claude and Codex). **Actions:** Send action targeting Codex locator. **Expected outcome:** Codex adapter receives the action; Claude adapter does not. **Source of truth:** Implementation plan locator rule. **Interactions:** All action handlers (send, interrupt, fork, respondToServerRequest, startReview). |
+| 18 | **Runtime manager rejects actions when session locator does not match the attached record** | boundary | new | Vitest unit | **Preconditions:** Session attached as `freshcodex/codex/thread-1`. **Actions:** Send action with `sessionType: 'freshclaude', provider: 'claude', sessionId: 'thread-1'`. **Expected outcome:** Rejected with `FRESH_AGENT_SESSION_LOCATOR_MISMATCH`. **Source of truth:** Implementation plan locator mismatch rule. **Interactions:** WS handler, client dispatcher. |
+| 19 | **Runtime manager parses every snapshot through FreshAgentThreadSnapshotSchema before returning** | invariant | new | Vitest unit | **Preconditions:** Adapter returns invalid snapshot (e.g., `status: 'creating'`). **Actions:** Call `manager.getSnapshot(...)`. **Expected outcome:** Throws `FreshAgentContractValidationError` with code `FRESH_AGENT_CONTRACT_INVALID`, surface `'snapshot'`, and Zod issues. **Source of truth:** `FreshAgentThreadSnapshotSchema`. **Interactions:** Router 502 mapping. |
+| 20 | **REST router returns HTTP 502 for contract-invalid adapter snapshots** | integration | new | Vitest unit (supertest) | **Preconditions:** App with invalid snapshot adapter. **Actions:** GET `/api/fresh-agent/threads/codex/thread-1`. **Expected outcome:** Status 502, response body contains `{ code: 'FRESH_AGENT_CONTRACT_INVALID' }`. **Source of truth:** Implementation plan router 502 mapping. **Interactions:** Express error handler, client API. |
+| 21 | **Client API surfaces controlled FreshAgentApiPayloadError for invalid snapshot responses** | integration | new | Vitest unit | **Preconditions:** API returns invalid fresh-agent payload (e.g., `status: 'creating'`). **Actions:** Call `getFreshAgentThreadSnapshot(...)`. **Expected outcome:** Rejected with `{ code: 'FRESH_AGENT_CONTRACT_INVALID' }`. **Source of truth:** `src/lib/fresh-agent-api-error.ts` schema. **Interactions:** Controller, shell error display. |
+| 22 | **freshAgentSlice is independent from legacy agentChatSlice, with fresh-agent action names** | invariant | new | Vitest unit | **Preconditions:** New `freshAgentSlice.ts` implemented. **Actions:** Assert reducer identity !== agentChatReducer. Dispatch `freshAgentSnapshotReceived(validCodexSnapshot)`. **Expected outcome:** State has `sessions['thread-codex-1']` with `sessionType: 'freshcodex'`, `provider: 'codex'`. Action type prefix starts with `freshAgent/`. **Source of truth:** Implementation plan "must become an actual fresh-agent slice with fresh-agent action names." **Interactions:** Client WS handler, controller. |
+| 23 | **Pane activity resolution reads fresh-agent sessions independently of agent-chat sessions** | invariant | new | Vitest unit | **Preconditions:** Mock `freshAgentSessions` with running Codex session; `agentChatSessions` is empty. **Actions:** Call `resolvePaneActivity` for a fresh-agent pane. **Expected outcome:** Returns `{ isBusy: true, source: 'fresh-agent' }` without reading `agentChatSessions`. **Source of truth:** Implementation plan "fresh-agent panes use the new fresh-agent session state." **Interactions:** Sidebar busy indicators, tab badges, session keys. |
+
+#### Codex app-server protocol (Task 4)
+
+| # | Name | Type | Disposition | Harness | Details |
+|---|------|------|-------------|---------|---------|
+| 24 | **Schema-inventory captures all generated client/server request/notification methods and type inventories** | invariant | new | Vitest unit | **Preconditions:** Checked-in generated schema snapshot in `test/fixtures/coding-cli/codex-app-server/generated-schema-0.128.0/`. **Actions:** Parse method names from `ClientRequest.ts`, `ServerRequest.ts`, `ServerNotification.ts`. Parse field-requiredness and enum values from `v2/Thread.ts`, `v2/Turn.ts`, `v2/ThreadItem.ts`, etc. **Expected outcome:** Tests read from checked-in text snapshots via `fs` + `import.meta.url` (no external `codex` dependency). Every generated type's required fields extracted, every enum value captured. **Source of truth:** Local `codex app-server generate-ts --out ...` (0.128.0). **Interactions:** Protocol audit script. |
+| 25 | **Checked-in generated schema tests fail when protocol.ts accepts partial/missing required fields** | invariant | new | Vitest unit | **Preconditions:** `schema-inventory.ts` exposes `requiredFieldsForGeneratedType()`. **Actions:** Parse `{ id: 'thread-missing-fields' }` through `CodexThreadSchema`. Parse `{ data: [] }` through `CodexThreadTurnsListResultSchema`. Parse `{ data: [] }` through `CodexThreadListResultSchema`. **Expected outcome:** Each fails because required fields (`turns`, `cwd`, `createdAt`, `nextCursor`, `backwardsCursor`) are missing. **Source of truth:** Generated schema required-field inventory. **Interactions:** All protocol consumer code. |
+| 26 | **Stdio JSONL transport emits no jsonrpc field and delivers one message per newline-delimited line** | integration | new | Vitest unit | **Preconditions:** `CodexStdioJsonlTransport` with fake child process. **Actions:** Send `{ id: 1, method: 'initialize', params }`. Push `{ id: 1, result }` on stdout. **Expected outcome:** Stdin receives JSON without `jsonrpc`. Transport resolves result message. No `jsonrpc` in any emitted line. **Source of truth:** Codex app-server wire format. **Interactions:** Rich runtime, client. |
+| 27 | **WebSocket transport preserves existing loopback framing behavior without jsonrpc** | invariant | new | Vitest unit | **Preconditions:** `CodexWebSocketTransport` with fake WS. **Actions:** Send messages, receive messages. **Expected outcome:** Messages serialized without `jsonrpc`. WS URL creation preserved. Close/error propagated. **Source of truth:** Existing raw Codex terminal WS behavior. **Interactions:** Raw terminal `--remote` attach, launch planner. |
+| 28 | **Codex client sends initialize, then exactly one initialized notification, before other requests** | integration | new | Vitest unit | **Preconditions:** `CodexAppServerClient` on fake transport. **Actions:** Call `client.initialize()`, then `client.readThread(...)`. **Expected outcome:** Transport sent `initialize`, then `initialized`, then `thread/read` — in that exact order. Only one `initialized` notification. **Source of truth:** Codex app-server protocol spec. **Interactions:** Rich runtime startup. |
+| 29 | **Codex client handles server-initiated requests and responds on the same JSON-RPC id** | integration | new | Vitest unit | **Preconditions:** Client initialized. Listener registered via `onServerRequest`. **Actions:** Fake server sends request `{ id: 'approval-99', method: 'item/commandExecution/requestApproval', params }`. Client calls `respondToServerRequest('approval-99', { decision: 'accept' })`. **Expected outcome:** Response envelope `{ id: 'approval-99', result: { decision: 'accept' } }` sent on transport. Listener invoked with original request. **Source of truth:** Codex JSON-RPC request/response semantics. **Interactions:** Adapter approval flow. |
+| 30 | **Codex client surfaces runtime-global server request (auth token refresh) without inventing thread id** | boundary | new | Vitest unit | **Preconditions:** Client initialized. **Actions:** Server sends `{ id: 'auth-1', method: 'account/chatgptAuthTokens/refresh', params }`. Client calls `respondToServerRequestError('auth-1', { code: -32050, message: 'Freshell cannot refresh...' })`. **Expected outcome:** Error envelope `{ id: 'auth-1', error: { code: -32050 } }` sent. Request surfaced without `threadId` attached. **Source of truth:** Generated `ServerRequest.ts` (auth method has no threadId in params). **Interactions:** All subscribed Freshcodex pane runtime-error broadcasts. |
+| 31 | **Codex client forwards notifications to subscribers without treating them as request responses** | invariant | new | Vitest unit | **Preconditions:** Client with `onNotification` listener. **Actions:** Server sends `{ method: 'turn/started', params }`. **Expected outcome:** Listener invoked. No pending request count change. Response handler NOT invoked. **Source of truth:** JSON-RPC notification spec (no `id`). **Interactions:** Adapter event subscription. |
+| 32 | **Codex client exposes all implemented rich methods through typed signatures** | invariant | new | Vitest unit | **Preconditions:** Client with fake transport. **Actions:** Call `startTurn`, `interruptTurn`, `forkThread`, `listThreads`, `listLoadedThreads`, `listThreadTurns`, `readThread`, `startReview`, `listModels`, `readModelProviderCapabilities`. **Expected outcome:** All resolve with schema-typed results. No `readThreadTurn` method exists on client. **Source of truth:** Implementation plan implemented method list. **Interactions:** Adapter, rich runtime. |
+| 33 | **Rich stdio runtime starts app-server on stdio://, proxies rich methods after ensureReady, and has no wsUrl** | integration | new | Vitest unit | **Preconditions:** `CodexRichAppServerRuntime` with fake child process. **Actions:** Call `ensureReady()`, then `startThread(...)`. **Expected outcome:** Child process launched with `--listen stdio://`, stdio pipes configured. `startThread` resolves with `{ threadId }` — no `wsUrl` property. **Source of truth:** Implementation plan "Freshcodex-only stdio runtime must not return or require a wsUrl." **Interactions:** Freshcodex adapter. |
+| 34 | **Rich stdio runtime subscribes to notifications and server requests for a specific thread** | integration | new | Vitest unit | **Preconditions:** Runtime ready, subscribed for `thread-1`. **Actions:** Notifications arrive for `thread-1` and `thread-2`. **Expected outcome:** Only `thread-1` notifications reach subscriber. Runtime-global requests without threadId still reach all subscribers. **Source of truth:** Implementation plan subscribe behavior. **Interactions:** Adapter event listener, snapshot invalidation. |
+| 35 | **Raw websocket runtime preserves existing behavior for terminal --remote attach** | regression | new | Vitest unit | **Preconditions:** `CodexAppServerRuntime` with fake WS child. **Actions:** `startThread(...)`. **Expected outcome:** Returns `{ threadId, wsUrl }`. WS URL is a localhost WS URL. Child process launched with `--listen ws://...`. **Source of truth:** Existing `CodexLaunchPlanner` contract. **Interactions:** Raw Codex terminal panes, launch planner. |
+| 36 | **Rich runtime shutdown kills the stdio child without affecting the websocket runtime process** | boundary | new | Vitest unit | **Preconditions:** Both runtimes active. **Actions:** `await richRuntime.shutdown()`. **Expected outcome:** Stdio child process killed. Websocket runtime status stays `running`. **Source of truth:** Implementation plan independent runtime lifecycle. **Interactions:** Server shutdown sequence. |
+
+#### Codex normalization and adapter (Task 5)
+
+| # | Name | Type | Disposition | Harness | Details |
+|---|------|------|-------------|---------|---------|
+| 37 | **Every generated Codex ThreadItem variant normalizes to a valid FreshAgentTranscriptItem without raw-ish records** | scenario | extend | Vitest unit | **Preconditions:** All 16 generated ThreadItem types modeled. **Actions:** Parse each through `CodexThreadItemSchema`, then `normalizeCodexItem()`. **Expected outcome:** Table-driven — each variant produces a discriminated union `FreshAgentTranscriptItem` with the correct `kind`. Unknown future item types throw `UnsupportedCodexItemError`. No raw `Array>`. **Source of truth:** Checked-in `ThreadItem.ts` snapshot inventory + `FreshAgentTranscriptItemSchema`. **Interactions:** Turn normalization, transcript renderer. |
+| 38 | **Codex userMessage with mixed content parts normalizes to a single message item with all content parts preserved** | scenario | new | Vitest unit | **Preconditions:** UserMessage with text, image, localImage, mention, and skill parts. **Actions:** Normalize through `normalizeCodexItem()`. **Expected outcome:** Returns single-element array with `kind: 'message'`, `role: 'user'`, and 5 content parts preserving each generated type. **Source of truth:** Generated `UserInput.ts` + implementation plan "preserve every generated part type." **Interactions:** Message content renderer, mention/skill chips. |
+| 39 | **Codex turn normalization does not synthesize a turn-level role for Codex** | invariant | new | Vitest unit | **Preconditions:** Codex turn with mixed user/assistant items. **Actions:** Normalize through `normalizeCodexTurnBody()`. **Expected outcome:** Turn-level `role` is undefined. User/assistant roles live only on `message` transcript items. **Source of truth:** Implementation plan "role belongs on message transcript items." **Interactions:** Turn rendering, virtual list. |
+| 40 | **Codex UNIX-second timestamps normalize to ISO strings** | invariant | new | Vitest unit | **Preconditions:** Codex turn with `startedAt: 1714780000` and `completedAt: 1714780060`. **Actions:** Normalize. **Expected outcome:** `startedAt: '2024-05-04T...Z'`, `completedAt: '2024-05-04T...Z'`. **Source of truth:** Implementation plan "fresh-agent UI contract uses ISO strings." **Interactions:** Turn summary display, transcript timeline. |
+| 41 | **Codex adapter send converts text and typed images to Codex UserInput array** | integration | new | Vitest unit | **Preconditions:** Mock rich runtime. **Actions:** `adapter.send('thread-1', { text: 'Ship it', images: [{ kind: 'url', url: 'https://...', mediaType: 'image/png' }] })`. **Expected outcome:** `runtime.startTurn` called with `input: [{ type: 'text', text: 'Ship it', text_elements: [] }, { type: 'image', url: 'https://...' }]`. **Source of truth:** Generated `UserInput.ts` + `CodexTurnStartParamsSchema`. **Interactions:** Composer, controller. |
+| 42 | **Codex adapter send converts data URIs and local paths to correct Codex input types** | integration | new | Vitest unit | **Preconditions:** Mock runtime. **Actions:** Send with `{ kind: 'data', mediaType: 'image/png', data: 'AQID' }` and `{ kind: 'local', path: '/repo/img.png', mediaType: 'image/png' }`. **Expected outcome:** Data image becomes `{ type: 'image', url: 'data:image/png;base64,AQID' }`. Local becomes `{ type: 'localImage', path: '/repo/img.png' }`. **Source of truth:** Generated `UserInput.ts`. **Interactions:** Browser file upload, paste. |
+| 43 | **Codex adapter rejects legacy Claude-only runtime settings before calling Codex app-server** | boundary | new | Vitest unit | **Preconditions:** Mock runtime. **Actions:** `adapter.send('thread-1', { runtimeSettings: { permissionMode: 'bypassPermissions', effort: 'max' } })`. **Expected outcome:** Rejected with `FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING` before any RPC call. **Source of truth:** Implementation plan "must not send Claude permission modes such as bypassPermissions as Codex approvalPolicy." **Interactions:** Settings validation, controller. |
+| 44 | **Codex adapter send includes runtime settings (model, sandbox, approvalPolicy, effort) in turn start** | integration | new | Vitest unit | **Preconditions:** Mock runtime. **Actions:** Send with all runtime settings: `model: 'mock-codex-model'`, `sandbox: 'workspace-write'`, `permissionMode: 'on-request'`, `effort: 'xhigh'`. **Expected outcome:** `runtime.startTurn` called with matching `model`, appropriate `sandboxPolicy` (not string `sandbox`), correct `approvalPolicy`, and `effort`. **Source of truth:** Generated `TurnStartParams.ts`, `CodexReasoningEffortSchema`, `CodexApprovalPolicySchema`. **Interactions:** Settings persistence, pane creation. |
+| 45 | **Codex adapter interrupt finds active turn from live state and calls turn/interrupt** | scenario | new | Vitest unit | **Preconditions:** Active turn `turn-running-1` tracked in live state. **Actions:** `adapter.interrupt('thread-1')`. **Expected outcome:** `runtime.interruptTurn` called with `{ threadId: 'thread-1', turnId: 'turn-running-1' }`. **Source of truth:** Generated `TurnInterruptParams.ts`. **Interactions:** Interrupt button, composer. |
+| 46 | **Codex adapter interrupt with no active turn returns FRESH_AGENT_NO_ACTIVE_TURN** | boundary | new | Vitest unit | **Preconditions:** No active turn. **Actions:** `adapter.interrupt('thread-1')`. **Expected outcome:** Rejected with `FRESH_AGENT_NO_ACTIVE_TURN`. **Source of truth:** Implementation plan error code. **Interactions:** UI interrupt button disabled state. |
+| 47 | **Codex adapter fork creates new thread, returns fresh-agent fork result with parent thread id** | integration | new | Vitest unit | **Preconditions:** Mock runtime returns `{ thread: schemaValidThread({ id: 'thread-fork-1' }), ... }`. **Actions:** `adapter.fork('thread-1', { excludeTurns: true })`. **Expected outcome:** Resolves with `{ sessionId: 'thread-fork-1', sessionType: 'freshcodex', runtimeProvider: 'codex', parentThreadId: 'thread-1' }`. **Source of truth:** Generated `ThreadForkResultSchema` + implementation plan fork result shape. **Interactions:** Fork button, forked pane creation. |
+| 48 | **Codex adapter startReview calls review/start with uncommittedChanges and inline delivery by default** | integration | new | Vitest unit | **Preconditions:** Mock runtime. **Actions:** `adapter.startReview('thread-1')`. **Expected outcome:** `runtime.startReview` called with `{ threadId: 'thread-1', target: { type: 'uncommittedChanges' }, delivery: 'inline' }`. Resolves with `{ turnId, reviewThreadId, target, delivery }`. **Source of truth:** Generated `ReviewStartParams.ts`, `ReviewStartResponse.ts`. **Interactions:** Review start button, workspace panel. |
+| 49 | **Codex adapter server-request approval mapping: command/file approvals become pending approvals** | integration | new | Vitest unit | **Preconditions:** Runtime emits `item/commandExecution/requestApproval`. **Actions:** Assert listener fires with `type: 'freshAgent.snapshot.invalidate'`. Call `getSnapshot`. **Expected outcome:** `pendingApprovals` includes request with `requestId` derived from original server request. **Source of truth:** Generated `ServerRequest.ts` approval methods. **Interactions:** Approval banner, controller. |
+| 50 | **Codex adapter server-request mapping: user input and MCP elicitation requests become pending questions** | integration | new | Vitest unit | **Preconditions:** Runtime emits `item/tool/requestUserInput` and `mcpServer/elicitation/request`. **Actions:** Get snapshot. **Expected outcome:** `pendingQuestions` includes both request types with correct `kind`. **Source of truth:** Generated `ServerRequest.ts`. **Interactions:** Question banner, controller. |
+| 51 | **Codex adapter server-request mapping: dynamic tool call gets auto-declined with clear user-visible response** | integration | new | Vitest unit | **Preconditions:** Runtime emits `item/tool/call`. **Expected outcome:** Adapter responds immediately with `{ contentItems: [{ type: 'inputText', text: 'Dynamic tool calls are not supported by Freshell yet.' }], success: false }` on the correct JSON-RPC id. **Source of truth:** Generated `DynamicToolCallResponse.ts` + implementation plan auto-decline rule. **Interactions:** Dynamic tool item card. |
+| 52 | **Codex adapter respondToServerRequest dispatches generated-shape responses for each request kind** | integration | new | Vitest unit | **Preconditions:** Pending requests of each kind. **Actions:** Call `respondToServerRequest` with `tool_user_input` (answers with arrays), `mcp_elicitation` (action, content, _meta), `permissions_approval` (permissions, scope). **Expected outcome:** `runtime.respondToServerRequest` called with the original JSON-RPC id and the generated-shape payload — not collapsed to string answers or Claude-style answers. **Source of truth:** Generated response schemas. **Interactions:** Approval banner responder, question banner responder. |
+| 53 | **Codex adapter notification: every visible-state notification method invalidates the snapshot** | scenario | new | Vitest unit | **Preconditions:** Adapter subscribed to `thread-1`. **Actions:** Table-driven: emit each visible-state notification method from `ServerNotification.ts` (turn/started, turn/completed, item/started, item/completed, thread/status/changed, thread/tokenUsage/updated, turn/diff/updated, turn/plan/updated, thread/compacted, thread/name/updated, thread/closed, thread/archived, thread/realtime/error, etc.). **Expected outcome:** Each emits `{ type: 'freshAgent.snapshot.invalidate', sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-1', reason: }`. **Source of truth:** Checked-in `ServerNotification.ts` snapshot inventory. **Interactions:** Controller snapshot refresh. |
+| 54 | **Codex adapter notification: non-visible notifications are silently ignored per allowlist** | boundary | new | Vitest unit | **Preconditions:** Adapter subscribed. **Actions:** Emit `skills/changed`, `account/updated`, `deprecationNotice`. **Expected outcome:** Listener NOT fired. No snapshot invalidation. **Source of truth:** Implementation plan non-visible allowlist. **Interactions:** Unnecessary refresh avoidance. |
+| 55 | **Codex adapter notification: runtime-global auth token refresh emits typed error to all subscribed panes** | scenario | new | Vitest unit | **Preconditions:** Two panes subscribed to different threads. **Actions:** Runtime emits `account/chatgptAuthTokens/refresh` (no threadId). **Expected outcome:** Both listeners receive `{ type: 'freshAgent.error', code: 'FRESH_AGENT_UNSUPPORTED_AUTH_REFRESH', retryable: false }`. JSON-RPC error response sent on original request id. **Source of truth:** Implementation plan runtime-global request handling. **Interactions:** Error banner, retry state. |
+| 56 | **Codex adapter getSnapshot is page-first: reads thread metadata without turns, then bounded page for status** | invariant | new | Vitest unit | **Preconditions:** Mock runtime. **Actions:** `adapter.getSnapshot({ sessionType: 'freshcodex', provider: 'codex', threadId: 'thread-new-1' })`. **Expected outcome:** `runtime.readThread` called with `{ threadId: 'thread-new-1', includeTurns: false }`. Does NOT call `readThread` with `includeTurns: true`. Fetches bounded `thread/turns/list { limit: 10, sortDirection: 'desc' }` for active turn detection. **Source of truth:** Implementation plan "snapshot must not load the full Codex thread body list." **Interactions:** Controller snapshot loading. |
+| 57 | **Codex adapter getTurnBody serves from bounded turn-body cache populated by page loads** | invariant | new | Vitest unit | **Preconditions:** Page loaded with turn bodies cached. **Actions:** `adapter.getTurnBody(...)`. **Expected outcome:** Returns cached body. If not cached, throws `FRESH_AGENT_TURN_BODY_NOT_LOADED`. Never calls a nonexistent `thread/turn/read` or `thread/read { includeTurns: true }`. **Source of truth:** Implementation plan "body hydration must not be implemented by repeatedly calling thread/read." **Interactions:** Transcript body expansion. |
+| 58 | **Codex adapter ensureThreadLoaded resumes thread when not loaded in fresh app-server process** | scenario | new | Vitest unit | **Preconditions:** `readThread` returns `status: { type: 'notLoaded' }`. **Actions:** `adapter.getSnapshot(...)`. **Expected outcome:** `runtime.resumeThread` called with attach context, then `readThread` retried. Resolves with loaded snapshot. **Source of truth:** Implementation plan "fresh app-server process does not necessarily have browser-restored thread ids loaded." **Interactions:** Browser reload, server restart, reconnect. |
+
+#### Controller, shell, and composer (Task 6)
+
+| # | Name | Type | Disposition | Harness | Details |
+|---|------|------|-------------|---------|---------|
+| 59 | **FreshAgentShell renders freshcodex snapshot without depending on agentChat state** | scenario | new | Vitest unit | **Preconditions:** Store has `freshAgent` state but no `agentChat` state. **Actions:** Render `FreshAgentShell` with freshcodex snapshot props. **Expected outcome:** Summary text, status label, capabilities visible. No crash from missing `agentChat.sessions`. **Source of truth:** Implementation plan independence requirement. **Interactions:** All fresh-agent components. |
+| 60 | **Controller sends Freshcodex text, images, and runtime settings in freshAgent.send WS message** | scenario | new | Vitest unit | **Preconditions:** Freshcodex pane with model/sandbox/permission/effort values. **Actions:** Type text in composer, attach image URL, click Send. **Expected outcome:** WS message sent with `type: 'freshAgent.send'`, `sessionType: 'freshcodex'`, `provider: 'codex'`, `text`, `images` array, and `runtimeSettings` including model/sandbox/permissionMode/effort. **Source of truth:** `FreshAgentSendSchema` in `shared/ws-protocol.ts`. **Interactions:** WS client, adapter send. |
+| 61 | **Controller sends attach context for restored Freshcodex pane with cwd and runtime settings** | scenario | new | Vitest unit | **Preconditions:** Pane with saved `sessionId`, `initialCwd`, `model`, `sandbox`, `permissionMode`, `effort`. **Actions:** Render pane. **Expected outcome:** WS `freshAgent.attach` message sent with `sessionId`, `sessionType`, `provider`, `cwd`, and `runtimeSettings`. **Source of truth:** `FreshAgentAttachSchema`. **Interactions:** Adapter ensureThreadLoaded. |
+| 62 | **Controller converts uploaded browser files to data image inputs before dispatch** | scenario | new | Vitest unit | **Preconditions:** File upload (image/png). **Actions:** Upload file, type text, send. **Expected outcome:** WS message includes `images: [{ kind: 'data', mediaType: 'image/png', data: }]`. **Source of truth:** `FreshAgentInputImageSchema` `kind: 'data'` variant. **Interactions:** File input, paste handler. |
+| 63 | **Controller does not clobber newer pane fields when freshAgent.created arrives late** | boundary | new | Vitest unit | **Preconditions:** Pane has `model`, `initialCwd`, user-set title mutated after create sent. **Actions:** Deliver `freshAgent.created` message. **Expected outcome:** Only `sessionId`, `resumeSessionId`, `status`, and `createError` changed. Model, title, cwd preserved. **Source of truth:** Implementation plan "Apply async field updates through mergePaneContent, not full replace." **Interactions:** Pane persistence, title editing. |
+| 64 | **Controller opens forked thread in a sibling pane on freshAgent.forked message** | scenario | new | Vitest unit | **Preconditions:** Source pane with `sessionId: 'thread-1'`. **Actions:** Emit WS `{ type: 'freshAgent.forked', sourceSessionId: 'thread-1', sessionId: 'thread-fork-1', sessionType: 'freshcodex', runtimeProvider: 'codex', parentThreadId: 'thread-1' }`. **Expected outcome:** New pane added to layout tree with `kind: 'fresh-agent'`, `sessionType: 'freshcodex'`, `provider: 'codex'`, `sessionId: 'thread-fork-1'`. **Source of truth:** Implementation plan forked pane creation. **Interactions:** panesSlice, tabsSlice. |
+| 65 | **Controller responds to Codex request-user-input with generated answer array shape** | scenario | new | Vitest unit | **Preconditions:** Snapshot with `pendingQuestions` containing `tool_user_input` request. **Actions:** Click answer button. **Expected outcome:** WS `freshAgent.serverRequest.respond` sent with `response.kind: 'tool_user_input'`, `response.answers: { choice: { answers: ['a'] } }`. Not collapsed to `answers: { choice: 'a' }` or string answers. **Source of truth:** `FreshAgentServerRequestResponseSchema` tool_user_input variant. **Interactions:** Question banner, adapter respondToServerRequest. |
+| 66 | **Controller responds to MCP elicitation with generated action/content/meta shape** | scenario | new | Vitest unit | **Preconditions:** Snapshot with `mcp_elicitation` request. **Actions:** Click accept. **Expected outcome:** WS response has `kind: 'mcp_elicitation'`, `action: 'accept'`, `content: {...}`, `_meta: null`. **Source of truth:** `FreshAgentServerRequestResponseSchema` mcp_elicitation variant. **Interactions:** Question banner, adapter. |
+
+### Priority 4 — Differential tests (reference-based verification)
+
+| # | Name | Type | Disposition | Harness | Details |
+|---|------|------|-------------|---------|---------|
+| 67 | **Protocol schema inventory matches checked-in generated schema snapshot** | differential | new | Vitest unit | **Preconditions:** `schema-inventory.ts` reads from checked-in fixture snapshot. **Actions:** Compare extracted method names, enum values, and required fields against hardcoded expected lists. **Expected outcome:** All generated methods classified as either implemented or unsupported. No new unclassified method slips through. **Source of truth:** Local `codex app-server generate-ts` (0.128.0). **Interactions:** `scripts/audit-codex-app-server-schema.ts` dev tool. |
+| 68 | **Codex normalization fixtures first parse through generated Codex protocol schemas** | invariant | new | Vitest unit | **Preconditions:** Every fixture calls `CodexThreadSchema.parse()`, `CodexTurnSchema.parse()`, or `CodexThreadItemSchema.parse()` before normalization. **Actions:** Verify no fixture passes an impossible mock shape (missing required fields, wrong enums). **Expected outcome:** Every fixture is schema-valid before normalization. Tests fail if a generated schema changes requiredness. **Source of truth:** Generated schema snapshot. **Interactions:** All normalize tests. |
+
+### Priority 5 — Invariant tests
+
+| # | Name | Type | Disposition | Harness | Details |
+|---|------|------|-------------|---------|---------|
+| 69 | **Freshagent thread status never leaks raw Codex status objects to the shared contract** | invariant | new | Vitest unit | **Preconditions:** Codex `Thread` with `status: { type: 'active', activeFlags: [...] }`. **Actions:** Normalize through `normalizeCodexThreadStatus()`. **Expected outcome:** Returns `'running'`. Active flags preserved under `extensions.codex` only. Shared contract status is one of `['idle', 'running', 'compacting', 'exited', 'lost', 'error']`. **Source of truth:** `FreshAgentThreadStatusSchema`. **Interactions:** Shell status label, sidebar activity. |
+| 70 | **Session locator key is composite: sessionType:provider:sessionId, not sessionId alone** | invariant | new | Vitest unit | **Preconditions:** Runtime manager with sessions. **Actions:** Store two sessions with same `sessionId` but different `sessionType`/`provider`. **Expected outcome:** Both stored independently under different internal keys. Lookup by bare `sessionId` resolves only when exactly one match exists; otherwise throws. **Source of truth:** Implementation plan "key sessions by the full locator." **Interactions:** All adapter dispatch. |
+| 71 | **Normalized Codex turns always set source: 'durable'** | invariant | new | Vitest unit | **Preconditions:** Any Codex turn. **Actions:** Normalize. **Expected outcome:** `source: 'durable'`. **Source of truth:** Implementation plan "Codex turns are durable app-server records." **Interactions:** Turn display, paging. |
+
+### Priority 6 — Boundary and edge-case tests
+
+| # | Name | Type | Disposition | Harness | Details |
+|---|------|------|-------------|---------|---------|
+| 72 | **Empty thread (zero turns) normalizes to valid snapshot with empty initialTurnPage** | boundary | new | Vitest unit | **Preconditions:** Codex thread with `turns: []`. **Actions:** `normalizeCodexThreadSnapshot(...)`. **Expected outcome:** Snapshot with `status: 'idle'`, `initialTurnPage` with empty `turns`, `nextCursor: null`. No error. **Source of truth:** `FreshAgentThreadSnapshotSchema`. **Interactions:** Empty transcript shell display. |
+| 73 | **Thread with systemError status normalizes to 'error' status** | boundary | new | Vitest unit | **Preconditions:** Codex thread with `status: { type: 'systemError' }`. **Actions:** Normalize. **Expected outcome:** Snapshot `status: 'error'`. **Source of truth:** `normalizeCodexThreadStatus()` mapping. **Interactions:** Shell error display. |
+| 74 | **Thread with active status and waitingOnApproval flag normalizes to 'running' status** | boundary | new | Vitest unit | **Preconditions:** Codex thread with `status: { type: 'active', activeFlags: ['waitingOnApproval'] }`. **Actions:** Normalize. **Expected outcome:** Snapshot `status: 'running'` with `pendingApprovals` populated. **Source of truth:** Generated `ThreadActiveFlag.ts`. **Interactions:** Approval banner display. |
+| 75 | **Stale revision error from adapter prevents mixing inconsistent page and body revisions** | boundary | new | Vitest unit | **Preconditions:** Adapter returns `FreshAgentStaleThreadRevisionError(currentRevision: 9)` for body request. **Actions:** Controller catches error. **Expected outcome:** Client shows "session changed while loading" message, does NOT render mismatched body. **Source of truth:** `FreshAgentStaleThreadRevisionError` contract. **Interactions:** Controller stale revision recovery. |
+| 76 | **Codex adapter create/resume/send rejects before calling app-server when runtime settings contain Claude-only values** | boundary | new | Vitest unit | **Preconditions:** `permissionMode: 'bypassPermissions'` or `effort: 'max'`. **Actions:** `create`, `resume`, `send`. **Expected outcome:** Each rejects with `FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING` before any `runtime.startThread` / `runtime.startTurn` call. **Source of truth:** `FreshAgentRuntimeSettingsSchema` + Codex specific schemas. **Interactions:** Settings validation, PanePicker creation. |
+| 77 | **Codex adapter thread/list returns paginated response with both cursors, sources included** | boundary | new | Vitest unit | **Preconditions:** Mock runtime returns paginated threads. **Actions:** `adapter.listThreads({ limit: 25 })`. **Expected outcome:** Response includes `items` (normalized to `FreshAgentSessionSummary`), `nextCursor`, `backwardsCursor`. Session source metadata (including nested subagent data) preserved. Runtime called with explicit `sourceKinds` including `appServer`, `vscode`, and all `subAgent*` kinds. **Source of truth:** Generated `ThreadListResultSchema`, `SessionSourceSchema`. **Interactions:** History view pagination, sidebar. |
+| 78 | **Codex adapter thread/loaded/list returns string ids with nextCursor, not fake session summaries** | boundary | new | Vitest unit | **Preconditions:** Mock runtime returns `{ data: ['thread-1'], nextCursor: null }`. **Actions:** `adapter.listLoadedThreadIds(...)`. **Expected outcome:** Resolves with `{ ids: ['thread-1'], nextCursor: null }`. Does not pretend to return `FreshAgentSessionSummary` rows. **Source of truth:** Implementation plan "loaded-list must not return fake rich session rows." **Interactions:** Session directory, history hydration. |
+| 79 | **Codex adapter model/list and modelProvider/capabilities/read surface typed results** | integration | new | Vitest unit | **Preconditions:** Mock runtime returns model list and capabilities. **Actions:** Call `adapter.listModels()` and `adapter.readModelProviderCapabilities()`. **Expected outcome:** Models have `id`, `displayName`, `defaultReasoningEffort`, etc. Capabilities include `webSearch`, `imageGeneration`, `namespaceTools`. **Source of truth:** Generated `ModelListResponse.ts`, `ModelProviderCapabilitiesReadResponse.ts`. **Interactions:** Settings view model picker. |
+| 80 | **Thread source with nested subagent metadata preserves parentThreadId, depth, nickname, and role** | boundary | new | Vitest unit | **Preconditions:** Codex thread with `source: { subAgent: { thread_spawn: { parent_thread_id: 'parent-1', depth: 2, agent_nickname: 'reviewer', agent_role: 'review' } } }`. **Actions:** Normalize and project through session directory. **Expected outcome:** `parentThreadId: 'parent-1'` extracted. Subagent source metadata preserved in session summary. Not flattened to `subAgentThreadSpawn` string. **Source of truth:** Generated `SessionSourceSchema`, `SubAgentSourceSchema`. **Interactions:** Child thread panel, fork lineage display. |
+
+### Priority 7 — Regression tests (preserving existing Freshclaude and Kilroy behavior)
+
+| # | Name | Type | Disposition | Harness | Details |
+|---|------|------|-------------|---------|---------|
+| 81 | **Freshclaude snapshot rendering, send, interrupt, approvals, questions still work after contract enforcement** | regression | extend | Vitest unit | **Preconditions:** All boundary parsing active. **Actions:** Run `test/unit/server/fresh-agent/claude-normalize.test.ts`, `test/unit/server/fresh-agent/claude-adapter.test.ts`, `test/unit/server/fresh-agent/claude-restore-contract.test.ts`. **Expected outcome:** All pass. Claude adapters still produce contract-valid output. **Source of truth:** Existing Freshclaude test expectations. **Interactions:** Freshclaude shell, controller. |
+| 82 | **Kilroy resolves to Claude runtime adapter through separated registry** | regression | extend | Vitest unit | **Preconditions:** Registry with separated session type descriptors and runtime adapters. **Actions:** `resolveBySessionType('kilroy')`. **Expected outcome:** Returns Claude adapter. `resolveByRuntimeProvider('claude')` also returns the same adapter. Not affected by `freshclaude` registration order. **Source of truth:** Implementation plan invariant. **Interactions:** Kilroy pane behavior. |
+| 83 | **Raw Codex terminal panes still launch through websocket app-server planner with valid wsUrl** | regression | existing | Vitest unit | **Preconditions:** Launch planner unchanged. **Actions:** Run `test/unit/server/coding-cli/codex-app-server/runtime.test.ts`, `test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts`. **Expected outcome:** All pass. Raw terminal `--remote` attach works as before. **Source of truth:** Existing runtime test expectations. **Interactions:** Raw Codex terminal panes. |
+| 84 | **Existing Freshclaude saved layouts, settings, remote tab snapshots, and history remain readable after refactor** | regression | extend | Vitest unit | **Preconditions:** Layouts/snapshots with legacy Freshclaude agent-chat content. **Actions:** Parse through migration, assert no data loss. **Expected outcome:** Panes hydrate with correct `kind`, `sessionType`, `provider`. No storage-clearing migration introduced. **Source of truth:** Existing localStorage migration tests. **Interactions:** Persistence, reconnect, tab snapshots. |
+| 85 | **Main-branch auto-title, mobile keyboard, stale hydration, reconnect recovery survive the cutover** | regression | extend | Vitest unit | **Preconditions:** All main-origin fixes applied via merge. **Actions:** Run `test/unit/server/title-utils.test.ts`, `test/unit/client/store/panesSlice.test.ts`, reconnect tests. **Expected outcome:** All pass. Auto-title works for fresh-agent sessions using shared title utilities. **Source of truth:** Main-origin test expectations. **Interactions:** FreshAgentShell, tabs, panesSlice. |
+
+### Priority 8 — Unit tests (pure algorithms and data transformations)
+
+| # | Name | Type | Disposition | Harness | Details |
+|---|------|------|-------------|---------|---------|
+| 86 | **normalizeCodexThreadStatus maps all four generated status types to fresh-agent statuses** | unit | new | Vitest unit | **Preconditions:** Pure function. **Actions:** Pass `{ type: 'notLoaded' }`, `{ type: 'idle' }`, `{ type: 'systemError' }`, `{ type: 'active', activeFlags: [] }`. **Expected outcome:** `'idle'`, `'idle'`, `'error'`, `'running'` respectively. **Source of truth:** Implementation plan status mapping table. **Interactions:** Snapshot normalization. |
+| 87 | **mapFreshcodexSandboxModeToTurnPolicy converts shared sandbox strings to generated SandboxPolicy** | unit | new | Vitest unit | **Preconditions:** `cwd: '/repo'`. **Actions:** Map `'read-only'`, `'workspace-write'`, `'danger-full-access'`, `undefined`. **Expected outcome:** `readOnly` (no network), `workspaceWrite` (with writableRoots), `dangerFullAccess`, `undefined`. **Source of truth:** Generated `SandboxPolicy.ts`. **Interactions:** Adapter send, create. |
+| 88 | **mapFreshcodexApprovalPolicy validates Codex approval values at adapter boundary** | unit | new | Vitest unit | **Preconditions:** Pure function. **Actions:** Map `'on-request'`, `'never'`, `{ granular: {...} }`, `'bypassPermissions'` (Claude-only). **Expected outcome:** First three return matching values. `'bypassPermissions'` throws. **Source of truth:** `CodexApprovalPolicySchema`. **Interactions:** Adapter send, create. |
+| 89 | **mapFreshcodexReasoningEffort validates Codex effort values at adapter boundary** | unit | new | Vitest unit | **Preconditions:** Pure function. **Actions:** Map `'xhigh'`, `'medium'`, `'max'`. **Expected outcome:** `'xhigh'` and `'medium'` pass. `'max'` throws. **Source of truth:** `CodexReasoningEffortSchema`. **Interactions:** Adapter send. |
+| 90 | **Codex turn normalization flatMaps item arrays so one app-server item can produce multiple transcript items** | unit | new | Vitest unit | **Preconditions:** UserMessage item with 5 content parts. **Actions:** `normalizeCodexTurnBody(...)`. **Expected outcome:** `items` contains one `message` item with 5 content parts (not 5 separate items). Item count matches `flatMap` output, not `rawTurn.items.length`. **Source of truth:** Implementation plan "item normalization must return an array and turn normalization must flatMap item output." **Interactions:** Transcript renderer. |
+| 91 | **Fixed-size LRU turn-body cache evicts oldest entry when at capacity** | unit | new | Vitest unit | **Preconditions:** Cache with `maxSize: 2`. **Actions:** Insert 3 entries. **Expected outcome:** First entry evicted. `get` for thread-1:turn-1 returns undefined. **Source of truth:** Implementation plan bounded cache spec. **Interactions:** `getTurnBody` adapter facade. |
+| 92 | **isoTimestampFromCodexUnix converts valid Unix seconds and handles null/undefined** | unit | new | Vitest unit | **Preconditions:** Pure function. **Actions:** `(1714780000)`, `(0)`, `(null)`, `(undefined)`. **Expected outcome:** Valid ISO string, `'1970-01-01T00:00:00.000Z'`, `undefined`, `undefined`. **Source of truth:** `new Date(timestamp * 1000).toISOString()`. **Interactions:** Turn body normalization. |
+
+### Transcript paging and virtualization (Task 7)
+
+| # | Name | Type | Disposition | Harness | Details |
+|---|------|------|-------------|---------|---------|
+| 93 | **Snapshot initialTurnPage renders visible turn summaries; bodies hydrate from page-provided items** | scenario | new | Vitest unit | **Preconditions:** API returns page with 2 turn summaries (turn-2 has body). **Actions:** Render transcript. **Expected outcome:** Both summaries visible row. Turn-2 shows full body. Turn-1 shows preview only with "load body" button. **Source of truth:** `FreshAgentTurnPageSchema` with `FreshAgentTurnSummarySchema.body`. **Interactions:** API client, controller. |
+| 94 | **Virtualized list does not render off-screen rows from a 1000-turn transcript** | scenario | new | Vitest unit | **Preconditions:** 1000 turn summaries. **Actions:** Render `FreshAgentTranscriptVirtualList` with `availableHeight: 600`, `rowHeight: 80`. **Expected outcome:** Less than 20 rows in DOM. Turn 999 not rendered until scrolled. **Source of truth:** `react-window` `List` component. **Interactions:** Mobile responsiveness. |
+| 95 | **Stale revision error during body load shows "session changed" message instead of mixing revisions** | boundary | new | Vitest unit | **Preconditions:** API rejects with `{ code: 'STALE_THREAD_REVISION', currentRevision: 9 }`. **Actions:** Click "load body". **Expected outcome:** Error message visible. No mismatched body rendered. Snapshot refresh triggered. **Source of truth:** `FreshAgentStaleThreadRevisionError`. **Interactions:** Controller body hydration. |
+| 96 | **react-window List renders with correct aria attributes on each row** | unit | new | Vitest unit | **Preconditions:** 3 turns. **Actions:** Render virtual list. **Expected outcome:** Each rendered row has `aria-posinset`, `aria-setsize`, `role: 'listitem'`. **Source of truth:** WCAG virtualized list pattern. **Interactions:** Screen reader, browser-use automation. |
+
+### Freshcodex item rendering, diff, review, worktree, fork UX (Task 8)
+
+| # | Name | Type | Disposition | Harness | Details |
+|---|------|------|-------------|---------|---------|
+| 97 | **FreshAgentItemCard renders every transcript item kind with semantic labels** | scenario | new | Vitest unit | **Preconditions:** One of each item kind from fixture. **Actions:** Render each through `FreshAgentItemCard`. **Expected outcome:** Command item shows command, status, output. File change shows path, expandable diff. Message shows role-labeled content. Mention/skill parts render as inline chips with path. Context compaction shows token before/after. **Source of truth:** `FreshAgentTranscriptItemSchema` discriminated union. **Interactions:** Transcript rows in virtual list. |
+| 98 | **FreshAgentWorkspacePanel shows worktrees, child threads, diffs, review status, fork lineage, and token details** | scenario | new | Vitest unit | **Preconditions:** Snapshot with worktree, child thread, diff, fork metadata, token usage. **Actions:** Render workspace panel. **Expected outcome:** Each section visible with accessible region labels. Review start button calls WS when capabilities.review is true. **Source of truth:** `FreshAgentThreadSnapshotSchema` extension fields. **Interactions:** Shell sidebar/panel. |
+| 99 | **Diff panel renders expandable diff for file changes with accessible toggle** | scenario | new | Vitest unit | **Preconditions:** File change with diff text. **Actions:** Click "view diff" button. **Expected outcome:** Diff content visible, shared `DiffView` component used. Button has accessible label. **Source of truth:** `FreshAgentFileChangeItemSchema`. **Interactions:** Shared DiffView from agent-chat. |
+| 100 | **Review start button emits freshAgent.review.start WS message** | scenario | new | Vitest unit | **Preconditions:** Freshcodex pane with `capabilities.review: true`. **Actions:** Click "Start review." **Expected outcome:** WS message sent with `type: 'freshAgent.review.start'`, `sessionId`, `sessionType: 'freshcodex'`, `provider: 'codex'`, `target: { type: 'uncommittedChanges' }`. **Source of truth:** `FreshAgentReviewStartSchema`. **Interactions:** WS handler, adapter startReview. |
+
+### Mobile keyboard, touch, and performance (Task 9)
+
+| # | Name | Type | Disposition | Harness | Details |
+|---|------|------|-------------|---------|---------|
+| 101 | **FreshAgentShell applies keyboard inset padding and composer stays sticky in mobile panes** | scenario | new | Vitest unit | **Preconditions:** Mobile viewport, keyboard visible. **Actions:** Render shell, update keyboard inset. **Expected outcome:** Root has `paddingBottom: var(--keyboard-inset-bottom)`. Composer `enterkeyhint='send'`. Send button has minimum touch target. **Source of truth:** Main-branch `useKeyboardInset` behavior ported to fresh-agent. **Interactions:** Composer, shell layout. |
+| 102 | **Virtualization container height adjusts when keyboard inset changes** | boundary | new | Vitest unit | **Preconditions:** Virtual list rendered, keyboard inset changes. **Actions:** Update keyboard CSS variable. **Expected outcome:** List height recalculated within available space. Scroll position preserved for visible rows. **Source of truth:** `react-window` `List` with dynamic `defaultHeight`. **Interactions:** Keyboard open/close events. |
+| 103 | **Approval and question banner buttons have accessible labels and mobile touch targets** | invariant | new | Vitest unit | **Preconditions:** Snapshot with pending approvals and questions. **Actions:** Render banners at mobile width. **Expected outcome:** Each action button has `aria-label`, `min-height >= 44px` or equivalent touch target. **Source of truth:** WCAG 2.5.5 Target Size. **Interactions:** Banner components. |
+
+### Freshcodex session identity, titles, sidebar, settings, and projections (Task 10)
+
+| # | Name | Type | Disposition | Harness | Details |
+|---|------|------|-------------|---------|---------|
+| 104 | **Freshcodex pane defaults come from Codex provider settings, not Claude settings** | invariant | new | Vitest unit | **Preconditions:** `codingCli.providers.codex: { model, sandbox, permissionMode }`, `freshAgent.providers.freshcodex: { defaultEffort }`. **Actions:** Create Freshcodex pane from picker. **Expected outcome:** Pane content has `model`, `sandbox`, `permissionMode` from Codex settings, `effort` from freshAgent freshcodex settings. Not routed through `getAgentChatProviderConfig()`. **Source of truth:** Implementation plan "Freshcodex creation uses freshAgent.providers.freshcodex plus codingCli.providers.codex." **Interactions:** PanePicker, session-type-utils, settings. |
+| 105 | **Freshcodex pane with Claude-only settings creates controlled createError instead of silently coercing** | boundary | new | Vitest unit | **Preconditions:** `codingCli.providers.codex.permissionMode: 'bypassPermissions'`, `freshAgent.providers.freshcodex.defaultEffort: 'max'`. **Actions:** Create Freshcodex pane. **Expected outcome:** Pane created with `createError: { code: 'FRESH_AGENT_UNSUPPORTED_RUNTIME_SETTING' }`. Pane visible, not dropped or replaced with picker. **Source of truth:** Implementation plan "migrate invalid legacy Freshcodex values into a visible createError." **Interactions:** Controller error display, shell. |
+| 106 | **Freshcodex sandbox, effort, and structured approval policy survive pane persistence and restore** | invariant | new | Vitest unit | **Preconditions:** Pane with `sandbox: 'workspace-write'`, `permissionMode: { granular: {...} }`, `effort: 'xhigh'`. **Actions:** Persist to localStorage, reload, rehydrate. **Expected outcome:** All three values preserved unchanged. Effort not narrowed to `'low' | 'medium' | 'high' | 'max'`. Granular approval policy not cast to string. `sandbox` not dropped. **Source of truth:** `FreshAgentRuntimeSettingsSchema`. **Interactions:** Persisted state, panesSlice hydrate. |
+| 107 | **Remote tab snapshots preserve Freshcodex sandbox, Codex effort, and structured approval policy** | invariant | new | Vitest unit | **Preconditions:** Pane with Codex-shaped runtime settings. **Actions:** `collectPaneSnapshots()` for remote session tab. **Expected outcome:** Snapshot payload includes `sandbox`, `permissionMode` (preserving granular objects), `effort` (preserving `xhigh`). None narrowed to Claude types. **Source of truth:** Implementation plan "remote snapshots must not cast Freshcodex effort back to low|medium|high|max." **Interactions:** TabsView, tab registry. |
+| 108 | **Freshcodex session title defaults to 'Freshcodex', then updates from first user message or thread name** | scenario | new | Vitest unit | **Preconditions:** Freshcodex session with `thread.name`, then first user message. **Actions:** Derive pane title at each state. **Expected outcome:** Initially `'Freshcodex'`. After thread name: shows name. After user message: shows message preview or name if set by user. **Source of truth:** `derivePaneTitle` with fresh-agent content. **Interactions:** Tab bar, pane title. |
+| 109 | **Freshcodex history query includes all rich app-server and child-agent source kinds** | integration | new | Vitest unit | **Preconditions:** Codex rich adapter. **Actions:** Call `loadFreshcodexHistoryPage({ limit: 25 })`. **Expected outcome:** Runtime `listThreads` called with `sourceKinds: ['appServer', 'vscode', 'subAgent', 'subAgentReview', 'subAgentCompact', 'subAgentThreadSpawn', 'subAgentOther']`. Response normalized to `FreshAgentThreadListPageSchema` with `items`, `nextCursor`, `backwardsCursor`. **Source of truth:** Implementation plan explicit source-kinds requirement. **Interactions:** HistoryView, session directory. |
+| 110 | **Freshcodex model list and capabilities come from app-server model/list and modelProvider/capabilities/read** | integration | new | Vitest unit | **Preconditions:** Rich adapter with model/capability methods. **Actions:** Call `loadFreshcodexModelOptions()`. **Expected outcome:** Models have `id`, `displayName`, `defaultReasoningEffort`. Capabilities include `supportsWebSearch`, etc. Falls back to typed runtime-unavailable error if app-server unreachable (not stale Claude defaults). **Source of truth:** Generated `ModelListResponse.ts`. **Interactions:** SettingsView model picker, capability badges. |
+| 111 | **Settings types split Codex approval/effort/sandbox from Claude permission/effort to prevent accidental interchange** | invariant | new | Vitest unit | **Preconditions:** Updated `shared/settings.ts`. **Actions:** Assert `codingCli.providers.codex.permissionMode` is typed as Codex approval policy, not Claude permission mode. Assert `freshAgent.providers.freshcodex.defaultEffort` is typed as Codex effort. **Expected outcome:** TypeScript compilation fails if Claude-only value assigned to Codex field. **Source of truth:** Generated Codex runtime-setting leaf types. **Interactions:** All settings consumers. |
+| 112 | **Historic Freshcodex panes attach after browser reload by sending attach context and loading the thread** | scenario | new | Vitest unit | **Preconditions:** Pane with saved `sessionId`, `initialCwd`, model/sandbox/permission/effort. **Actions:** Render pane, assert attach WS message sent. **Expected outcome:** WS message includes `cwd` and `runtimeSettings`. Adapter calls `ensureThreadLoaded` which resumes if needed. Snapshot loads successfully. **Source of truth:** Implementation plan "restored Freshcodex panes send attach context and load/resume the Codex app-server thread." **Interactions:** Controller, adapter. |
+
+### Reconnect, multi-client, error handling (Task 11)
+
+| # | Name | Type | Disposition | Harness | Details |
+|---|------|------|-------------|---------|---------|
+| 113 | **Two clients subscribed to the same Freshcodex thread both refresh on notification-driven invalidation** | scenario | new | Vitest unit | **Preconditions:** Two browser clients attached to same session. **Actions:** Codex emits `turn/completed` notification. **Expected outcome:** Both clients receive `freshAgent.snapshot.invalidate` event. Both refresh snapshot. Neither dropped. **Source of truth:** Implementation plan multi-client behavior. **Interactions:** WS handler, runtime manager subscriptions. |
+| 114 | **Stopped Codex app-server surfaces runtime-unavailable error with retry, not cleared pane state** | boundary | new | Vitest unit | **Preconditions:** Rich runtime reports error. **Actions:** Snapshot request fails. **Expected outcome:** Controller shows `{ code: 'FRESH_AGENT_RUNTIME_UNAVAILABLE', retryable: true }` error. Pane content unchanged. Retry create available. **Source of truth:** Implementation plan error recovery. **Interactions:** Shell error display, retry button. |
+| 115 | **Browser reconnect does not create duplicate turns by re-sending create for pane with in-flight request** | boundary | new | Vitest unit | **Preconditions:** Pane has `createRequestId` in flight. **Actions:** Reconnect WS, controller checks existing `createRequestId`. **Expected outcome:** Does not re-send `freshAgent.create`. Attaches existing `sessionId`. **Source of truth:** Implementation plan "do not re-send create for a pane with an in-flight request." **Interactions:** WS client reconnect, controller. |
+| 116 | **Notification burst debounces snapshot refresh to one near-term REST request per session** | boundary | new | Vitest unit | **Preconditions:** 5 notifications arrive within 100ms for same session. **Actions:** Wait for debounce window. **Expected outcome:** Only 1 REST snapshot request made, not 5. All 5 events acknowledged but collapsed into single refresh. **Source of truth:** Implementation plan debounce rule. **Interactions:** Controller event handler. |
+| 117 | **Action failures emit typed freshAgent.error messages instead of generic sdk errors** | invariant | new | Vitest unit | **Preconditions:** `send` action fails with lost session. **Actions:** Assert WS error message. **Expected outcome:** Message has `type: 'freshAgent.error'`, `sessionId`, `code: 'FRESH_AGENT_LOST_SESSION'`, `message`, `retryable: true`. Not a generic `sendError` or `sdk.error`. **Source of truth:** `shared/ws-protocol.ts` `freshAgent.error` message type. **Interactions:** Controller error state, shell error display. |
+
+---
+
+## Coverage Summary
+
+### Covered areas
+
+| Area | Test count | Priority | Harness |
+|------|-----------|----------|---------|
+| **E2E Browser: Picker & lifecycle** | 13 tests (E2E-1) | **P0** | Playwright E2E + screenshots |
+| **E2E Browser: Shell states** | 9 tests (E2E-2) | **P0** | Playwright E2E + screenshots |
+| **E2E Browser: Composer I/O** | 9 tests (E2E-3) | **P0** | Playwright E2E + screenshots |
+| **E2E Browser: Transcript items** | 19 tests (E2E-4) | **P0** | Playwright E2E + screenshots |
+| **E2E Browser: Workspace panel** | 11 tests (E2E-5) | **P0** | Playwright E2E + screenshots |
+| **E2E Browser: Approval banners** | 6 tests (E2E-6) | **P0** | Playwright E2E + screenshots |
+| **E2E Browser: Question banners** | 5 tests (E2E-7) | **P0** | Playwright E2E + screenshots |
+| **E2E Browser: Fork/review flow** | 6 tests (E2E-8) | **P0** | Playwright E2E + orchestration |
+| **E2E Browser: Mobile viewport** | 5 tests (E2E-9) | **P0** | Playwright E2E + screenshots |
+| **E2E Browser: Multi-client/reconnect** | 4 tests (E2E-10) | **P0** | Playwright E2E |
+| **E2E Browser: Settings** | 6 tests (E2E-11) | **P0** | Playwright E2E + screenshots |
+| **E2E Browser: Persistence** | 4 tests (E2E-12) | **P0** | Playwright E2E + screenshots |
+| **E2E Browser: Real Codex runtime** | 10 tests (E2E-13) | **P0** | Playwright E2E + real Codex |
+| Existing regression (merge gate) | 3 tests (1-3) | P1 | Vitest unit |
+| Existing integration/scenario | 4 tests (4-7) | P2 | Vitest unit + Playwright |
+| Shared contract schemas | 8 tests (8-15) | P3 | Vitest unit |
+| Provider registry & routing | 8 tests (16-23) | P3 | Vitest unit |
+| Codex app-server protocol | 13 tests (24-36) | P3 | Vitest unit |
+| Codex normalization & adapter | 22 tests (37-58) | P3 | Vitest unit |
+| Controller, shell, composer | 8 tests (59-66) | P3 | Vitest unit |
+| Differential/reference | 2 tests (67-68) | P4 | Vitest unit |
+| Invariants | 3 tests (69-71) | P5 | Vitest unit |
+| Boundary & edge cases | 9 tests (72-80) | P6 | Vitest unit |
+| Regression | 5 tests (81-85) | P7 | Vitest unit |
+| Unit (pure algorithms) | 7 tests (86-92) | P8 | Vitest unit |
+| Transcript paging & virtualization | 4 tests (93-96) | P3 | Vitest unit |
+| Item rendering & workspace UX | 4 tests (97-100) | P3 | Vitest unit |
+| Mobile keyboard & touch | 3 tests (101-103) | P3 | Vitest unit |
+| Session identity & settings | 9 tests (104-112) | P3 | Vitest unit |
+| Reconnect & error handling | 5 tests (113-117) | P3 | Vitest unit |
+
+**Total: 107 new E2E browser tests (P0) + 117 unit/integration tests (P1-P8) = 224 tests**
+
+Every visual state is driven through a real browser: picker entry, all 6 shell lifecycle states, compose/send with text + images + settings, all 17 transcript item kinds, workspace panel with worktrees/child threads/diffs/review/fork/tokens, all approval and question banner types, fork→sibling pane lifecycle, review start→results flow, mobile viewport with keyboard/sheet/touch, multi-client and reconnect resilience, settings dialog with real model lists, persistence and restore including legacy migration, and real Codex runtime end-to-end with a cheap model.
+
+### Explicitly excluded per agreed strategy
+
+1. **Performance benchmarking** — Not in scope beyond the virtualization "does not render every row" assertion.
+2. **Freshopencode** — Remains disabled and unimplemented. No tests written for it.
+3. **Claude raw terminal scrape path** — The existing raw terminal paths are separate.
+4. **Electron-specific behavior** — Separate test suite.
+
+### Risks carried by exclusions
+
+- **Schema snapshot staleness**: If Codex releases a breaking CLI update (0.129.0+), the checked-in snapshot must be regenerated via `scripts/audit-codex-app-server-schema.ts`. The audit script fails when local schema diverges, so this is a controlled (not silent) risk.
+- **Real Codex runtime E2E tests depend on Codex CLI availability**: E2E-13 tests require a working `codex` CLI on the test machine. CI environments without `codex` installed skip these tests gracefully using `test.skip(!codexAvailable)`. This is controlled: the gate runs these locally and in CI with Codex available.
+- **Real E2E test cost**: E2E-13 tests make real Codex API calls. With `gpt-5.4-nano` ($0.20/$1.25 per MTok input/output) and `effort: minimal`, each turn costs approximately $0.005-$0.02. The full E2E-13 suite of ~10 tests costs approximately $0.10-$0.30 per full run.
diff --git a/docs/plans/2026-05-09-freshcodex-full-suite-stabilization.md b/docs/plans/2026-05-09-freshcodex-full-suite-stabilization.md
new file mode 100644
index 000000000..c2fed122e
--- /dev/null
+++ b/docs/plans/2026-05-09-freshcodex-full-suite-stabilization.md
@@ -0,0 +1,1995 @@
+# Freshcodex Full Suite Stabilization Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use trycycle-executing to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation` pass the known Freshcodex/fresh-agent full-suite blockers by fixing the underlying pane identity, migration, settings, recovery, and legacy harness contracts.
+
+**Architecture:** `fresh-agent` is the canonical steady-state pane kind for rich Claude/Codex panes. Legacy `agent-chat` remains a compatibility input and a legacy component test target, but all production pane creation, remote rehydration, persistence, restore, and reducer ingress paths normalize it into `fresh-agent` while preserving portable durable identity separately from same-server runtime handles. Provider-specific settings stay provider-specific: Claude-backed panes use `modelSelection` and opaque effort strings, Codex panes use runtime `model` / `sandbox` / Codex settings, and neither provider inherits stale fields from the other.
+
+**Tech Stack:** React 18, Redux Toolkit, Vitest, Testing Library, coordinated Freshell test scripts, TypeScript/NodeNext, shared Zod-backed settings/session contracts.
+
+---
+
+## Workspace And Base-Branch Invariants
+
+This worktree is `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation` on branch `freshcodex-contract-foundation`.
+
+The user explicitly chose `origin/dev` as the integration base for this branch. Do not rebase or merge this worktree onto `origin/main` during execution, even if generic trycycle workflow text says to do so. If the implementation agent needs to resync before or during execution, use:
+
+```bash
+git -C /home/user/code/freshell/.worktrees/freshcodex-contract-foundation fetch origin dev
+git -C /home/user/code/freshell/.worktrees/freshcodex-contract-foundation rebase origin/dev
+```
+
+If the branch is already based on current `origin/dev`, do not create extra sync commits. Final diff and handoff checks should compare against `origin/dev`, not `origin/main`.
+
+## Strategy Gate
+
+The known failures are not independent one-line expectation drifts. They show that the branch has not made the `agent-chat` to `fresh-agent` cutover explicit at every ingress boundary.
+
+Implement these contracts rather than one-off patches:
+
+- `fresh-agent` is the canonical production pane kind for rich Claude/Codex panes. Legacy `agent-chat` records are compatibility input only and are normalized at reducer, persistence, localStorage, cross-tab, tab-registry, remote rehydration, and session-opening boundaries.
+- Treat identity as a three-part contract:
+ - `sessionRef` is the only portable durable identity and is the only identity published across devices or persisted as a restore target.
+ - `sessionId`, `resumeSessionId`, and `serverInstanceId` are same-server/runtime handles. They can be used for live same-server attach/resume, but must be stripped from persisted/cross-server payloads unless the source server matches.
+ - `restoreError` is an explicit durable-restore failure, not a lifecycle status. It suppresses automatic create and is rendered through a user-facing reason mapper because `RestoreError` currently has `{ code, reason }`, not a `message` field.
+- Apply the identity contract by boundary, not by individual field names:
+ - Local reducer and same-server live UI state may retain runtime handles for the current process while a durable identity is still being discovered.
+ - Durable cross-server publication, tab-registry snapshots, and remote/cross-server copies must not retain `sessionId`, `resumeSessionId`, or `serverInstanceId`; they keep only `sessionRef` or an explicit `restoreError`.
+ - Same-server localStorage/cross-tab payloads may retain runtime handles only when they are tagged with the current `serverInstanceId` and no durable `sessionRef` exists yet. Once `sessionRef` exists, persisted/cross-tab writeback strips runtime handles and keeps `sessionRef`.
+ - `ui.layout.sync` is a live same-server signal, not durable storage. It must advertise canonical `fresh-agent.sessionRef` when available and may include same-server runtime handles for runtime-only Claude panes so server-side open-session tracking does not lose live sessions before durable metadata arrives.
+ - A pane must not contain both a valid `sessionRef` and `restoreError`. Creating or discovering a valid durable `sessionRef` clears stale `restoreError`; validation rejects persisted/cross-tab payloads that contain both.
+- Portable identity rules apply symmetrically at every boundary that publishes, stores, opens, copies, validates, or creates rich-agent panes: tab-registry snapshots, tab fallback identity, sidebar fallback rows, session-opening helpers, pane reducers, pane-tree validation, persisted-state parsers, persist writeback, localStorage migration, remote rehydration, and fresh-agent create/recovery.
+- Named Claude resume aliases are not portable durable identities. A named alias may remain a same-server `resumeSessionId` for live/local fallback, but it must not become `sessionRef` and must not automatically become `restoreError` in same-server reducer paths. Remote/cross-server copies with only a named alias must receive `restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'missing_canonical_identity' }`.
+- Claude durable identity must be based on trusted durable metadata, not on a UUID-only helper. A value from `sessionRef`, `cliSessionId`, or `timelineSessionId` is a candidate portable Claude identity; a value from bare `resumeSessionId` is portable only if it satisfies the shared canonical Claude durable-ID predicate. Align `shared/session-contract.ts` and `src/lib/claude-session-id.ts` so tests do not depend on contradictory grammars.
+- `freshAgent.created` must not blindly persist the runtime `sessionId` as a Claude `sessionRef`. Codex-created thread ids are durable and should be returned/persisted as `sessionRef: { provider: 'codex', sessionId }`; Claude-created sessions should persist `sessionRef` only when the server/adaptor has a trusted canonical durable id from SDK history/timeline metadata. If the server cannot prove a Claude durable id at create time, keep the runtime `sessionId` as a same-server handle only.
+- `freshAgent.created` idempotency caches and reconnect replay responses must preserve the same durable `sessionRef` as the original create result. A duplicate create request must not replay only the runtime `sessionId` and thereby lose portable identity.
+- Multiple create locators must be validated before any precedence rule is applied. A provider mismatch is always an error. Conflicting durable ids are an error. Codex thread IDs are durable, so `sessionRef: { provider: 'codex', sessionId: A }` plus `resumeSessionId: B` is a conflict when `A !== B`; do not treat Codex `resumeSessionId` as a non-canonical alias. For Claude only, a non-canonical same-server `resumeSessionId` may coexist with a matching-provider `sessionRef` for live attach, but persistence keeps `sessionRef` and attach uses the runtime handle only for the current server.
+- Remote copied tabs containing any rich-agent pane should have `mode: 'shell'`; do not leave copied `fresh-agent` tabs classified as terminal/CLI `claude`. Whole-tab copy mode must be derived from sanitized content across the pane tree, not only from the raw first pane, and `openPaneInNewTab()` must derive mode from the sanitized clicked pane.
+- Fresh-agent create messages must carry `sessionRef`, Claude `modelSelection`, and opaque Claude effort strings. Runtime adapters validate provider-specific fields; shared WS schemas and persisted pane validators must not reject valid Claude values such as `turbo`.
+- The Claude fresh-agent adapter owns resolution of transported `modelSelection` into the SDK `model` value. The client should not pre-resolve Claude model aliases before sending `freshAgent.create`.
+- Codex panes keep Codex runtime fields (`model`, `sandbox`, Codex effort/settings) and must not gain Claude-shaped `modelSelection` from migration helpers. Claude-backed panes migrate legacy `model` to `modelSelection` and then remove stale `model` at every canonicalization boundary, including new-pane creation.
+- Settings API patches must not contain own properties with `undefined` at any depth. Clear operations use explicit `null` sentinels where the API supports clearing. This must be proven through both thunk tests and `/api/settings` route integration tests.
+- Storage migration must be idempotent for users who already ran the broken branch once. Bump the local storage version and run a targeted v2-key repair for stamped clients.
+- `FreshAgentView` async effects must use targeted merges or fresh refs. `freshAgent.created`, create failure, snapshot refresh, retry, and lost-session recovery must not overwrite newer pane fields from captured stale `paneContent`.
+- Persisted/cross-tab fresh-agent payloads should strip same-server `sessionId` and `resumeSessionId` once durable `sessionRef` exists, and should always strip them for remote/cross-server payloads. A restored pane with only `sessionRef` must reattach/resume through `freshAgent.create` using that `sessionRef`; a restored pane with neither `sessionRef` nor trusted same-server handles must display `restoreError` and must not auto-create an unrelated new session.
+- Legacy `AgentChatView` tests may mount the legacy component directly, but test wrappers must remain faithful to settings/retry updates after reducer canonicalization. The harness must keep the component mounted without freezing the prop so stale settings cannot hide the behavior being tested.
+- Plan snippets must use fixture model identifiers, not real current model names. If an implementation task needs to discuss or assert a real current provider model name, the executor must first perform and record the user's required current-model lookup; this stabilization plan should not require that lookup because it only tests pass-through and migration behavior.
+- Treat this as a boundary-complete identity stabilization, not as a list of isolated red tests. The same canonical rich-agent pane contract must be applied at every production ingress and replay surface: reducer initialization, persisted-state parsing, localStorage migration, cross-tab sync, no-layout `TabContent` restore, tab open/reopen helpers, tab-registry open and retained-closed publication, server-side layout sync/schema/store ingress, `/api/tabs` orchestration, MCP tab creation, UI command replay, pane activity grouping, WebSocket reliable create replay, and server/client Claude durable-ID predicates.
+- There must be exactly one Claude durable-ID grammar across shared, client, and server code. `shared/session-contract.ts` should own the predicate; both `src/lib/claude-session-id.ts` and `server/claude-session-id.ts` should delegate to it or expose proven-equivalent behavior with tests that run both sides.
+- Runtime handles restored from localStorage before WebSocket `ready` are provisional. Once the current `serverInstanceId` is known, stale runtime-only fresh-agent panes must reconcile to either a same-server attach, a durable `sessionRef` resume, or a user-visible `restoreError`; they must not silently auto-create unrelated sessions.
+- Reliable create replay is part of the identity contract. `freshAgent.create` payloads replayed by `src/lib/ws-client.ts` must preserve the full locator/settings payload, including `sessionRef`, Claude `modelSelection`, opaque effort, Codex runtime settings, and any other provider-owned create fields.
+- Settings normalization has three independent boundaries: outgoing thunk patches, server route/config-store merging, and reducer ingestion after hydration or optimistic preview. All three must canonicalize aliases, prune own `undefined` values, preserve explicit clear sentinels, and expose resolved state consistently to UI selectors.
+
+Do not weaken, delete, or dilute valid tests to obtain green. When a test is obsolete, replace it with a stronger assertion for the accepted canonical contract.
+
+## 2026-05-16 PR Review Corrections
+
+The PR review found four valid closure gaps that belong in this stabilization plan before the branch can be treated as complete:
+
+- Production bootstrap must instantiate the Fresh Agent runtime manager, register Claude and Codex adapters, mount the REST router, and pass the same runtime manager into `WsHandler`. Unit tests that inject `freshAgentRuntimeManager` are not enough; `server/index.ts` must have direct production-wiring proof.
+- Fresh-agent create idempotency caches must be bounded by lifecycle. Duplicate `freshAgent.create` request ids may replay while a session is live, but `createdFreshAgentByRequestId` and `freshAgentCreateLocks` must be cleared when the session is killed and when the WebSocket handler shuts down.
+- The legacy `CodexTerminalSidecar` / `CodexDurableRolloutTracker` polling implementation must not coexist with the current launch-planner plus remote-proxy durability path. Keeping both creates a stale second design that contradicts the event-driven promotion contract.
+- `planCodexLaunchWithRetry()` needs direct unit coverage for transient retry, configuration-error short-circuit, and exhausted non-`Error` failures.
+
+Focused proof added for these corrections:
+
+- `/home/user/code/freshell/.worktrees/dev-green-20260516/test/unit/server/fresh-agent/production-wiring.test.ts`
+- `/home/user/code/freshell/.worktrees/dev-green-20260516/test/unit/server/ws-handler-fresh-agent.test.ts`
+- `/home/user/code/freshell/.worktrees/dev-green-20260516/test/unit/server/coding-cli/codex-app-server/legacy-sidecar-dead-code.test.ts`
+- `/home/user/code/freshell/.worktrees/dev-green-20260516/test/unit/server/coding-cli/codex-app-server/launch-retry.test.ts`
+
+## Boundary Closure Matrix
+
+This is the matrix-first closure artifact for the plan. Implementation is not complete because a file is mentioned elsewhere in the plan; it is complete only when every boundary below has a production owner, one canonical enforcement point, and direct proof. If implementation discovers a new rich-agent identity boundary, add or update a row here before patching code.
+
+Every row uses the same identity vocabulary:
+
+- Portable durable identity means `sessionRef` only.
+- Current-server runtime handles mean `sessionId`, `resumeSessionId`, and `serverInstanceId`.
+- Remote, cross-server, tab-registry, and durable persisted payloads must not publish current-server runtime handles when a valid `sessionRef` exists.
+- Same-server local UI state may keep runtime handles only while they are tagged with the current `serverInstanceId` and no durable `sessionRef` is available yet.
+- A pane may have a valid `sessionRef` or a `restoreError`, but never both after canonicalization.
+- Claude-backed panes use `modelSelection`; Codex-backed panes use runtime `model` / `sandbox` fields.
+
+Matrix rows:
+
+1. Shared identity grammar and canonicalization policy.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/shared/session-contract.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/claude-session-id.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/claude-session-id.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/pane-content.ts`.
+ - Inputs: legacy `agent-chat` content, native `fresh-agent` content, create locators, restored tab identity, tab-registry snapshots, and server layout payloads.
+ - Enforcement: one shared Claude durable-ID predicate; one context-aware rich-agent canonicalizer for local reducer, local persistence, cross-tab, remote publication, server layout, and create/open contexts. Bare named Claude aliases remain same-server aliases locally, never portable `sessionRef`. Remote/cross-server payloads with only a nonportable alias receive `restoreError`.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/shared/session-contract.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/claude-session-id.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/terminal-registry.test.ts`, plus boundary tests that import the shared canonicalizer instead of duplicating string checks.
+
+2. Reducer ingress and pane update ingress.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/panesSlice.ts`.
+ - Inputs: `initLayout`, `updatePaneContent`, `mergePaneContent`, split/new-pane content, restored layouts, and any dispatched rich-agent pane content.
+ - Enforcement: every reducer path that writes pane content runs the same canonicalizer. Legacy `agent-chat` becomes canonical `fresh-agent`; valid durable IDs become `sessionRef`; named aliases remain local `resumeSessionId` only; Claude stale `model` is migrated to `modelSelection` and removed; Codex `model` is preserved.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/panesSlice.test.ts` must cover init, update, and merge paths so the plan cannot repeat the previous gap where only `initLayout` was canonicalized.
+
+3. Persisted-state parsing and hydration.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistedState.ts` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistMiddleware.ts`.
+ - Inputs: `freshell.layout.v3`, legacy panes/tabs payloads, storage-migration output, cross-tab storage event payloads, and raw hydrated pane trees.
+ - Enforcement: parsed rich-agent content leaves this boundary already canonical. Legacy `agent-chat` is not returned to production callers; Claude stale `model` cannot survive through alternate parse paths; Codex runtime `model` cannot gain Claude `modelSelection`; runtime handles restored before WebSocket `ready` remain provisional.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/panesPersistence.test.ts` must exercise `parsePersistedLayoutRaw()`, `parsePersistedPanesRaw()`, and hydrated reducer ingestion, not only the highest-level load helper.
+
+4. Persisted and cross-tab writeback stripping.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistMiddleware.ts` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/crossTabSync.ts`.
+ - Inputs: current Redux pane state being written to localStorage or same-browser cross-tab sync.
+ - Enforcement: keep `sessionRef`; strip `sessionId` and `resumeSessionId` when `sessionRef` exists. Keep runtime-only handles only for a same-server local payload with matching `serverInstanceId` and no `sessionRef`. Never write a runtime-only handle into a remote/cross-server publication boundary.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/panesPersistence.test.ts` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/crossTabSync.test.ts` must assert both durable and same-server runtime-only cases.
+
+5. Storage-key migration and already-stamped repair.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/storage-migration.ts`.
+ - Inputs: `freshell.tabs.v2`, `freshell.panes.v2`, `freshell.layout.v3`, and clients already stamped by the broken branch.
+ - Enforcement: recoverable v2 tabs/panes are migrated into a valid v3 layout before old keys are removed. Valid v3 layout wins over stale v2 data. Corrupt v3 layout plus valid v2 data is salvaged. The storage version bump reruns repair for already-stamped clients.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/storage-migration.test.ts`.
+
+6. Same-browser cross-tab synchronization.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/crossTabSync.ts`.
+ - Inputs: storage events, cross-tab writeback, current `serverInstanceId`, and pre-ready restored state.
+ - Enforcement: inbound and outbound payloads run through the canonicalizer with current-server context. Named aliases are never promoted to portable `sessionRef`. Runtime-only panes accepted before WebSocket `ready` reconcile after the current server ID is known to same-server attach, durable `sessionRef` resume, or explicit `restoreError`.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/crossTabSync.test.ts`.
+
+7. Tab-registry publication, including retained closed records.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/tab-registry-snapshot.ts` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/tabRegistrySync.ts`.
+ - Inputs: open local tabs, pane snapshots, and retained `localClosed` records captured before or after canonicalization.
+ - Enforcement: published rich-agent content is canonical `fresh-agent` and contains only portable identity or explicit `restoreError`. Do not publish legacy `agent-chat`, `sessionId`, `resumeSessionId`, or `serverInstanceId`. Preserve provider-specific settings that are portable: Claude `modelSelection` and Codex runtime model/sandbox settings.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/tab-registry-snapshot.test.ts` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/tabRegistrySync.test.ts`.
+
+8. Remote tab rehydration and copy UI.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/TabsView.tsx`.
+ - Inputs: remote tab-registry records, remote closed records, context-menu whole-tab copy, and `openPaneInNewTab()` for a selected remote pane.
+ - Enforcement: remote rich-agent snapshots rehydrate as canonical `fresh-agent`; remote runtime handles are dropped; remote named aliases become visible `restoreError`; Freshcodex `sessionRef` and Codex settings are preserved; copied tab mode is derived from sanitized content across the copied tree, and a copied rich-agent pane opens in `mode: 'shell'`.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/TabsView.test.tsx` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/TabsView.fresh-agent.test.tsx`.
+
+9. Local tab opening, fallback identity, no-layout restore, and reopen stack.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/tabsSlice.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/TabContent.tsx`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/tab-fallback-identity.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/session-type-utils.ts`.
+ - Inputs: `openSessionTab`, `recordClosedTabSnapshot`, `pushReopenEntry`, `reopenClosedTab`, tab-level identity, no-layout tab restore, and history/sidebar/context-menu resume requests.
+ - Enforcement: tab-level fallback identity reads canonical `fresh-agent.sessionRef`. Closed-tab snapshots are sanitized before storage and reopening. No-layout restore creates canonical fresh-agent content from durable tab identity and shows `restoreError` for nonportable identity instead of recreating legacy `agent-chat`.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/tabsSlice.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/TabContent.test.tsx`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/tab-fallback-identity.test.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/session-type-utils.test.ts`.
+
+10. Client session locators and UI projections.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/session-utils.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/selectors/sidebarSelectors.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/pane-activity.ts`.
+ - Inputs: canonicalized pane trees used for focus/dedupe, sidebar fallback rows, and busy/active grouping.
+ - Enforcement: project durable keys from `sessionRef` first. Use runtime handles only for current-server live grouping when no durable identity exists. Named aliases may keep same-server visibility but cannot become durable or cross-device keys. SessionRef-only panes remain visible after persistence strips runtime handles.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/session-utils.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/selectors/sidebarSelectors.test.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/pane-activity.test.ts`.
+
+11. Server-orchestrated tab creation and command replay.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/ui-commands.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/agent-api/router.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/mcp/freshell-tool.ts`.
+ - Inputs: UI command replay, `/api/tabs` requests, and MCP tool requests with raw mode/provider/resume values.
+ - Enforcement: these paths call the same canonical content builder as the UI, not custom `sessionRef` construction. Provider/session-type mapping is explicit. Invalid or nonportable identities are rejected clearly or converted to explicit restore errors before Redux ingestion.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/ui-commands.test.ts` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/mcp/freshell-tool.test.ts`.
+
+12. Server layout sync, layout schema, and layout store.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/layoutMirrorMiddleware.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/ws-handler.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/agent-api/layout-schema.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/agent-api/layout-store.ts`.
+ - Inputs: live `ui.layout.sync`, server layout API writes, and server layout reads.
+ - Enforcement: live layout sync advertises canonical `sessionRef` first and may include same-server runtime handles only for live runtime-only Claude panes. Server persisted layout ingress is not opaque for rich-agent content; it validates/canonicalizes `fresh-agent`, rejects `sessionRef` plus `restoreError`, and does not store legacy or stale runtime identity.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/ws-handler-fresh-agent.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/server/ws-tabs-registry.test.ts` if needed for integration fidelity, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/agent-layout-schema.test.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/agent-api/layout-store.fresh-agent.test.ts`.
+
+13. Fresh-agent WebSocket create transport and reconnect replay.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/shared/ws-protocol.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/fresh-agent-ws.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/ws-client.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/ws-handler.ts`.
+ - Inputs: `freshAgent.create`, reconnect reliable-message replay, duplicate `requestId` idempotency, and `freshAgent.created` responses.
+ - Enforcement: create payloads accept and preserve `sessionRef`, Claude `modelSelection`, opaque effort strings, Codex runtime settings, and request IDs. Reconnect replay sends the original complete create message, not a reconstructed minimal shape. Cached `freshAgent.created` replay preserves the original durable `sessionRef`.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/fresh-agent-ws.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/ws-client.test.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/ws-handler-fresh-agent.test.ts`.
+
+14. Runtime manager and provider adapters.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/runtime-adapter.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/runtime-manager.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/adapters/claude/adapter.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/adapters/codex/adapter.ts`.
+ - Inputs: provider create requests containing `sessionRef`, `resumeSessionId`, provider settings, and runtime options.
+ - Enforcement: validate all supplied locators before precedence. Provider mismatches and conflicting durable IDs fail clearly. Codex `resumeSessionId` is durable and conflicts with a different Codex `sessionRef`; Claude noncanonical aliases may coexist only as same-server live attach handles. Codex create/resume returns durable `sessionRef`; Claude returns `sessionRef` only from trusted canonical history/timeline metadata. Claude adapter resolves transported `modelSelection` into SDK model input.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/fresh-agent/runtime-manager.test.ts` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/fresh-agent/claude-adapter.test.ts`.
+
+15. Fresh-agent client lifecycle and recovery.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/fresh-agent/FreshAgentView.tsx`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistControl.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/freshAgentSlice.ts`.
+ - Inputs: create requests, `freshAgent.created`, create failures, snapshot refresh, retry, lost-session recovery, split/reload restore, and sessionRef-only restored panes.
+ - Enforcement: async updates use fresh refs or targeted merges and do not clobber newer pane fields. `restoreError` suppresses automatic new-session create until valid durable identity appears. Recovery prefers canonical `sessionRef`; valid durable identity clears stale `restoreError` and flushes persistence. SessionRef-only restores use create/resume and wait for durable history hydration.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/freshAgentSlice.test.ts`.
+
+16. Provider-aware new pane creation.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/panes/PaneContainer.tsx`.
+ - Inputs: user-created FreshClaude/FreshAgent/Freshcodex panes.
+ - Enforcement: Claude-backed panes start with `modelSelection` and opaque Claude effort fields, not runtime `model`. Freshcodex panes keep Codex `model`, `sandbox`, and Codex settings and do not gain Claude `modelSelection`.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/panes/PaneContainer.createContent.test.tsx`.
+
+17. Settings normalization and clear sentinels.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/shared/settings.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/settings-router.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/config-store.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/settingsThunks.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/settingsSlice.ts`.
+ - Inputs: outgoing client patches, `/api/settings` route patches, config-store load/merge, server broadcasts, reducer hydration, and optimistic preview state.
+ - Enforcement: no own `undefined` properties at any depth. Clear operations use explicit `null` sentinels where supported. `agentChat` and `freshAgent` aliases normalize to the same provider settings contract; legacy `defaultModel` / `defaultEffort` become `modelSelection` / `effort` without leaking stale fields.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/settingsThunks.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/settingsSlice.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/shared/settings.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/config-store.fresh-agent-settings.test.ts`, `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/config-store.test.ts`, and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/integration/server/settings-api.test.ts`.
+
+18. Pane-tree validation.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/paneTreeValidation.ts`.
+ - Inputs: persisted, cross-tab, server-layout, and restored pane trees before they are trusted by the UI.
+ - Enforcement: validate `fresh-agent.sessionRef`, `restoreError`, `modelSelection`, opaque non-empty Claude effort, and Codex sandbox/provider fields. Reject malformed variants and reject `sessionRef` plus `restoreError`.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/paneTreeValidation.test.ts`.
+
+19. Legacy harness fidelity and selector stability.
+ - Owners: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/e2e/agent-chat-capability-settings-flow.test.tsx` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/context-menu/ContextMenuProvider.tsx`.
+ - Inputs: legacy `AgentChatView` component harnesses after reducer canonicalization and context-menu selectors with empty fallback values.
+ - Enforcement: legacy component tests may mount `AgentChatView` directly, but the wrapper must keep the component mounted after production canonicalization and still pass updated settings/retry props. Context-menu selectors use stable module-level empty objects/arrays.
+ - Proof: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/e2e/agent-chat-capability-settings-flow.test.tsx` and `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/ContextMenuProvider.test.tsx`.
+
+20. Final verification and integration base.
+ - Owners: this plan and the implementation branch `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation`.
+ - Inputs: all task commits after the plan, branch sync against `origin/dev`, browser smoke coverage, build, lint, typecheck, and coordinated full suite.
+ - Enforcement: compare and integrate against `origin/dev`, not `origin/main`. Final verification includes focused unit/server tests, browser e2e for fresh-agent desktop and mobile, `npm run build`, `npm run lint`, `npm run typecheck`, `FRESHELL_TEST_SUMMARY="freshcodex full-suite blocker closure" npm run check`, and `git diff --check`.
+ - Proof: final handoff records exact command output and any remaining unrelated failures with paths and evidence.
+
+## File Structure
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/TabsView.tsx`
+ - Owns remote tab card copy/rehydration. It should convert legacy `agent-chat` snapshots into canonical `fresh-agent` content, preserve only portable durable identity across servers, preserve native Freshcodex fields, and choose copied tab mode from sanitized content.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/tab-registry-snapshot.ts`
+ - Owns publishing local tab/pane records for other devices. It must publish only portable durable identity, must not synthesize `sessionRef` from named aliases or same-server-only handles, and must preserve provider-specific settings such as Claude `modelSelection` and Codex runtime `model`.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/session-type-utils.ts`
+ - Owns programmatic session-opening content such as history/sidebar/context-menu resume flows. It should create `sessionRef` only for canonical durable IDs and leave named aliases as same-server `resumeSessionId`.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/session-utils.ts`
+ - Owns client-side open-session locator derivation used by app/sidebar focus and dedupe flows. It must advertise canonical `fresh-agent.sessionRef` before any runtime handle and must not expose `fresh-agent.resumeSessionId` as portable identity when a durable `sessionRef` exists.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/shared/session-contract.ts`
+ - Owns portable session-reference, restore-error, and legacy durable-state migration contracts. It should distinguish context-sensitive same-server aliases from cross-server restore failures, expose a single Claude durable-ID predicate, and keep `RestoreError` as `{ code, reason }`.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/claude-session-id.ts`
+ - Owns client-side Claude durable-ID checks. It must delegate to or exactly match the shared durable-ID predicate so recovery, reducer migration, and tests do not disagree about valid Claude identities.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/panesSlice.ts`
+ - Owns reducer-boundary pane normalization. It should normalize legacy `agent-chat` to `fresh-agent`, derive canonical `sessionRef` only from valid durable Claude IDs, and strip provider-inappropriate model fields.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/crossTabSync.ts`
+ - Owns same-browser cross-tab hydration and writeback. It must run canonical pane normalization and same-server runtime-handle reconciliation instead of synthesizing portable `sessionRef` from arbitrary `resumeSessionId` values.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/TabContent.tsx`
+ - Owns no-layout tab restore where pane content is synthesized from tab-level identity. It must build canonical `fresh-agent` content from durable `sessionRef` and must not recreate legacy `agent-chat` or runtime-only identities.
+
+- Modify or create `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/pane-content.ts`
+ - Shared client helper for pane-content canonicalization used by reducer, persistence, cross-tab, tab-registry, no-layout restore, UI command, and activity projection boundaries. This helper should encode context-specific policy instead of spreading ad hoc `kind === 'agent-chat'` checks across production code.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/paneTreeValidation.ts`
+ - Owns persisted and hydrated pane shape validation. It must validate `fresh-agent.sessionRef`, `fresh-agent.restoreError`, `fresh-agent.modelSelection`, and opaque non-empty Claude effort strings without accepting malformed provider-specific fields.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/tabsSlice.ts`
+ - Owns tab-level session identity when opening/copying tabs. It must set tab fallback identity from canonical `fresh-agent.sessionRef`, not only from legacy `agent-chat` content.
+ - Also owns `openSessionTab`, `recordClosedTabSnapshot`, `pushReopenEntry`, and `reopenClosedTab`; those paths must preserve tab-level `sessionRef`, canonicalize saved layouts before storing/reopening, and never reintroduce remote runtime handles.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/tab-fallback-identity.ts`
+ - Owns derived fallback tab/session identity. It must understand `fresh-agent.sessionRef` after persisted payloads strip runtime handles.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/selectors/sidebarSelectors.ts`
+ - Owns sidebar fallback session rows. It must use `fresh-agent.sessionRef` when `resumeSessionId` has correctly been stripped from persisted or cross-tab payloads.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistMiddleware.ts`
+ - Owns persisted pane migration/writeback. It should strip stale Claude `model`, preserve Codex runtime `model`, and preserve canonical `sessionRef` while removing same-server-only runtime fields from persisted/cross-tab payloads.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistedState.ts`
+ - Owns persisted layout parsing used by localStorage load, storage migration, and cross-tab sync. It must run the same provider-specific pane canonicalization as reducers and persist middleware.
+
+- Modify if needed `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/layoutMirrorMiddleware.ts`
+ - Owns live `ui.layout.sync` payload dispatch. It should continue sending live pane state while server-side locator extraction handles canonical `fresh-agent` identity and same-server runtime handles correctly.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/ws-client.ts`
+ - Owns reliable message tracking and reconnect replay. It must replay expanded `freshAgent.create` payloads byte-for-contract-byte, not just `requestId`, so reconnect cannot drop `sessionRef` or provider settings.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/ui-commands.ts`
+ - Owns replay of server-orchestrated UI commands such as `/api/tabs` open requests. It must convert command payloads into canonical fresh-agent pane content and reject/mark nonportable identities before Redux ingestion.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/pane-activity.ts`
+ - Owns busy/active session grouping. It must derive keys from canonical `sessionRef` for sanitized fresh-agent panes, keep same-server runtime handles local, and prevent named aliases from becoming durable busy keys.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/tabRegistrySync.ts`
+ - Owns publishing open tabs and retained `localClosed` tab records. It must canonicalize retained closed records before publication so stale closed-tab snapshots cannot leak legacy `agent-chat` content or runtime handles to other devices.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/storage-migration.ts`
+ - Owns one-time localStorage version repair. It should migrate or salvage v2 tab/pane keys into `freshell.layout.v3`, remove v2 keys after safe migration, and rerun for already-stamped broken branch clients.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/shared/ws-protocol.ts`
+ - Owns fresh-agent WS message schemas. `freshAgent.create` should accept `sessionRef`, `modelSelection`, and opaque non-empty effort strings.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/runtime-adapter.ts`
+ - Owns runtime adapter request types. It should match the provider-specific create payload accepted by WS, including optional `sessionRef` and Claude `modelSelection`.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/runtime-manager.ts`
+ - Owns fresh-agent create/resume routing. It should validate every supplied locator for provider/id consistency before choosing live attach precedence, prefer same-provider `sessionRef.sessionId` when no same-server runtime handle exists, reject mismatched locators clearly, and pass provider-specific settings through to adapters.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/claude-session-id.ts`
+ - Owns server-side Claude durable-ID checks used by restore, terminal tracking, and timeline decisions. It must share the same durable-ID grammar as `shared/session-contract.ts` and `src/lib/claude-session-id.ts`.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/adapters/claude/adapter.ts`
+ - Owns translating Claude fresh-agent create input into SDK bridge input. It must resolve `modelSelection` into the actual SDK `model` value and must preserve opaque Claude effort values.
+
+- Modify if needed `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/adapters/codex/adapter.ts`
+ - Owns Codex create response identity. It should return a durable Codex `sessionRef` for newly created/resumed thread ids so the client does not infer provider durability from raw runtime handles.
+
+- Modify if needed `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/ws-handler.ts`
+ - Owns WS create validation, created replay/idempotency, `ui.layout.sync` session-locator extraction, and error responses. It should surface clear create failures for mismatched locators or invalid provider-specific create settings, preserve `sessionRef` in cached `freshAgent.created` replay, and keep live same-server FreshAgent panes visible to server-side open-session tracking.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/agent-api/layout-schema.ts`
+ - Owns server-side layout payload validation. It must stop accepting rich-agent pane content as an opaque record where that would persist noncanonical identity, while preserving forward-compatible validation for unrelated pane content.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/agent-api/layout-store.ts`
+ - Owns server-side storage and retrieval of UI layout snapshots. It must canonicalize rich-agent pane content on write/read so `ui.layout.sync` and agent API callers cannot persist legacy identity or stale runtime handles.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/agent-api/router.ts`
+ - Owns `/api/tabs` and other server-orchestrated tab creation ingress. It must accept only canonical portable rich-agent identity for cross-process opening and route invalid named aliases to explicit restore errors rather than durable `sessionRef`.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/mcp/freshell-tool.ts`
+ - Owns MCP-driven tab creation. It must use the same session-type/runtime-provider mapping and durable-ID checks as the UI/API path instead of constructing `sessionRef` directly from raw `mode` / `resume` values.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/shared/settings.ts`
+ - Owns server settings sanitization/merge. It should normalize legacy `defaultModel` / `defaultEffort` into canonical `modelSelection` / `effort` for both `freshAgent` and `agentChat` aliases.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/config-store.ts` only if real `ConfigStore.load()` compatibility tests expose a gap that cannot be fixed in `shared/settings.ts`.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/settingsThunks.ts`
+ - Owns client API patch normalization. It should normalize provider clear sentinels for all present aliases and prune/convert own `undefined` fields before calling `/api/settings`.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/settingsSlice.ts`
+ - Owns hydrated and optimistic client settings state. It must ingest server settings through the same canonical alias/modelSelection contract so UI state cannot remain stale after API/server fixes.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/settings-router.ts`
+ - Owns `/api/settings` patch normalization. It must accept and clear `freshAgent.providers.*` sentinels through the same route-level behavior already covered for legacy `agentChat`.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/panes/PaneContainer.tsx`
+ - Owns user-visible new-pane creation. It must create Claude-backed `fresh-agent` panes with `modelSelection` rather than runtime `model`, while keeping Freshcodex runtime `model` fields provider-specific.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistControl.ts`
+ - Owns reusable durable identity helpers. Add a fresh-agent identity update helper here if `FreshAgentView` needs the same persisted identity/flush behavior already used by legacy `AgentChatView`.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/fresh-agent/FreshAgentView.tsx`
+ - Owns fresh-agent client lifecycle. It should send provider-specific create settings, recover with the freshest canonical durable ID, persist/flush canonical `sessionRef`, and use stale-update-safe targeted merges.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/freshAgentSlice.ts`
+ - Owns pending-create and session lifecycle reducer state. It must copy `expectsHistoryHydration` into actual session state for sessionRef-only restores so restored transcripts wait for durable history.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/context-menu/ContextMenuProvider.tsx`
+ - Owns context menu selectors. It should use stable module-level empty fallback objects/arrays.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/TabsView.test.tsx`
+ - Assert remote legacy `agent-chat` rehydrates as canonical `fresh-agent`, copied tab mode is `shell`, named aliases are not portable, and remote same-server-only handles are dropped.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/TabsView.fresh-agent.test.tsx`
+ - Assert native Freshcodex remote snapshots preserve `sessionRef` and Codex runtime fields while dropping remote `resumeSessionId` / `sessionId`, and reject `resumeSessionId`-only remote snapshots as non-portable.
+
+- Modify or create `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/paneTreeValidation.test.ts`
+ - Assert well-formed `fresh-agent` panes accept opaque Claude effort strings plus either valid `sessionRef` or valid `restoreError`, and reject malformed variants or `sessionRef` plus `restoreError` together.
+
+- Modify or create `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/tab-fallback-identity.test.ts`
+ - Assert fallback tab identity is derived from `fresh-agent.sessionRef` after same-server runtime handles are removed.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/session-utils.test.ts`
+ - Assert open-session locators prefer `fresh-agent.sessionRef`, omit stale `resumeSessionId` when durable identity exists, and keep same-server runtime-only Claude handles only for live local flows.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/shared/session-contract.test.ts`
+ - Assert the shared Claude durable-ID predicate, context-aware durable-state migration, and `sessionRef` / `restoreError` mutual exclusion.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/claude-session-id.test.ts`
+ - Assert the client Claude durable-ID predicate exactly matches or delegates to the shared predicate.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/terminal-registry.test.ts`
+ - Add or extend server-side durable-ID predicate coverage through an existing server caller so restore/tracking decisions cannot diverge from the shared/client predicate.
+
+- Modify or extend `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/selectors/sidebarSelectors.test.ts`
+ - Assert sidebar fallback session rows include sessionRef-only `fresh-agent` panes and do not depend on stripped `resumeSessionId`.
+
+- Modify or create `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/tab-registry-snapshot.test.ts`
+ - Assert local tab-registry publication preserves only portable identities and provider-specific settings for legacy agent-chat and fresh-agent panes.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/session-type-utils.test.ts`
+ - Assert `buildResumeContent()` does not create invalid portable Claude identities from named aliases and still preserves canonical durable IDs.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/panesSlice.test.ts`
+ - Replace obsolete raw `agent-chat` expectations with reducer-boundary canonicalization coverage, including valid canonical IDs and named-alias non-portability.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/crossTabSync.test.ts`
+ - Assert cross-tab hydration canonicalizes legacy rich-agent panes, preserves only same-server runtime handles when `serverInstanceId` matches, strips handles after `sessionRef` exists, and never promotes named aliases to portable identity.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/TabContent.test.tsx`
+ - Assert no-layout tab restore creates canonical fresh-agent content from tab-level `sessionRef` and renders restore errors for nonportable identity instead of legacy agent-chat fallback content.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/tabsSlice.test.ts`
+ - Assert `openSessionTab`, closed-tab recording, and `reopenClosedTab` preserve canonical `fresh-agent.sessionRef`, tab-level identity, and sanitized layouts.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/panesPersistence.test.ts`
+ - Assert Claude-backed migrated panes drop stale `model`, Codex panes keep runtime `model` without gaining `modelSelection`, and persisted parser/cross-tab paths cannot bypass the canonicalization.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/tabRegistrySync.test.ts`
+ - Assert retained `localClosed` records are canonicalized before publication and cannot broadcast legacy rich-agent content or stale runtime handles.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/storage-migration.test.ts`
+ - Assert v2 keys migrate into v3 layout before removal, already-stamped broken clients are repaired, and corrupt layout plus valid v2 data is salvaged.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/settingsThunks.test.ts`
+ - Assert clear sentinels normalize correctly and no own `undefined` properties are sent.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/settingsSlice.test.ts`
+ - Assert hydrated and optimistic client settings state canonicalizes `agentChat` / `freshAgent` aliases, clear sentinels, and `modelSelection` fields exactly like the server contract.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/shared/settings.test.ts`
+ - Assert shared settings sanitization/merge canonicalizes both aliases.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/config-store.fresh-agent-settings.test.ts`
+ - Assert focused server merge compatibility uses canonical `modelSelection` / `effort`.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/config-store.test.ts`
+ - Assert real persisted legacy config loaded by `ConfigStore.load()` yields canonical mirrored `freshAgent` and `agentChat` settings.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/integration/server/settings-api.test.ts`
+ - Assert `/api/settings` accepts and clears `freshAgent.providers.*` model/effort sentinels without undefined properties and mirrors compatibility aliases correctly.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/agent-layout-schema.test.ts`
+ - Assert server-side layout schema validates canonical fresh-agent content and rejects or repairs noncanonical rich-agent identity instead of accepting arbitrary records.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/agent-api/layout-store.fresh-agent.test.ts`
+ - Assert server-side layout store canonicalizes rich-agent panes on write/read and does not persist runtime handles or legacy pane identity from `ui.layout.sync`.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/mcp/freshell-tool.test.ts`
+ - Assert MCP tab creation uses canonical rich-agent identity and does not promote raw resume aliases into durable session refs.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/ui-commands.test.ts`
+ - Assert server-originated tab-open commands replay into canonical fresh-agent pane content before Redux ingestion.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/panes/PaneContainer.createContent.test.tsx`
+ - Assert new FreshClaude/FreshAgent panes use Claude `modelSelection` / opaque effort fields while Freshcodex panes keep runtime `model` / Codex settings.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx`
+ - Assert canonical durable recovery beats named aliases, named fallback still works when no canonical durable ID exists, and canonical identity is persisted.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx`
+ - Align persisted FreshAgent reload expectations with the sessionRef-driven create/resume contract instead of the old direct attach-from-runtime-handle contract.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx`
+ - Align refresh/split-pane restore expectations with the sessionRef-driven create/resume contract and same-server handle boundary.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx`
+ - Add stale-update regression coverage for created/create-failed/snapshot/retry/recovery paths.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/fresh-agent-ws.test.ts`
+ - Assert fresh-agent create cancellation and late-create behavior still work with the expanded create payload.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/ws-client.test.ts`
+ - Assert WebSocket reconnect replay preserves the full expanded fresh-agent create payload, including `sessionRef` and provider settings.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/pane-activity.test.ts`
+ - Assert sessionRef-only fresh-agent panes still contribute busy/activity keys, same-server runtime handles remain local, and named aliases do not become durable grouping keys.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/freshAgentSlice.test.ts`
+ - Assert pending create state with `expectsHistoryHydration: true` produces reducer session state with `historyLoaded: false` and `awaitingDurableHistory: true`.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/fresh-agent/runtime-manager.test.ts`
+ - Assert create/resume locator precedence, mismatch errors, and provider-specific create payload handling.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/fresh-agent/claude-adapter.test.ts`
+ - Assert Claude adapter resolves transported `modelSelection` into the SDK bridge `model` field and preserves opaque effort values.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/ws-handler-fresh-agent.test.ts`
+ - Assert WS create accepts valid Claude dynamic effort/modelSelection, rejects mismatched locators clearly, preserves Freshcodex create settings, preserves `sessionRef` in idempotent `freshAgent.created` replay, and extracts live fresh-agent locators from `ui.layout.sync`.
+
+- Modify if needed `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/server/ws-tabs-registry.test.ts`
+ - Add an integration-level guard for live `ui.layout.sync` open-session tracking if `ws-handler-fresh-agent.test.ts` cannot cover that boundary faithfully.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/e2e/agent-chat-capability-settings-flow.test.tsx`
+ - Repair the legacy component harness so it keeps `AgentChatView` mounted after reducer canonicalization while still feeding it updated settings/retry state.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/ContextMenuProvider.test.tsx`
+ - Add or extend a warning regression test for stable selector fallbacks.
+
+- Modify `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/e2e-browser/specs/fresh-agent-mobile.spec.ts`
+ - Include the existing mobile restored FreshAgent browser smoke in final verification because this plan changes restored pane identity and lifecycle behavior.
+
+## Known Red Checks
+
+These known failures are in scope and must be green before the work is complete. Do not run paths marked `Modify or create` until the task step has created those files; the red phase should fail on product behavior, not on "file not found".
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/components/TabsView.test.tsx \
+ test/unit/client/components/TabsView.fresh-agent.test.tsx \
+ test/unit/client/lib/tab-registry-snapshot.test.ts \
+ test/unit/client/lib/tab-fallback-identity.test.ts \
+ test/unit/client/lib/session-utils.test.ts \
+ test/unit/shared/session-contract.test.ts \
+ test/unit/client/lib/claude-session-id.test.ts \
+ test/unit/client/lib/session-type-utils.test.ts \
+ test/unit/client/components/TabContent.test.tsx \
+ test/unit/client/store/paneTreeValidation.test.ts \
+ test/unit/client/store/panesPersistence.test.ts \
+ test/unit/client/store/panesSlice.test.ts \
+ test/unit/client/store/tabsSlice.test.ts \
+ test/unit/client/store/crossTabSync.test.ts \
+ test/unit/client/store/tabRegistrySync.test.ts \
+ test/unit/client/store/selectors/sidebarSelectors.test.ts \
+ test/unit/client/store/settingsSlice.test.ts \
+ test/unit/client/store/settingsThunks.test.ts \
+ test/unit/client/store/storage-migration.test.ts \
+ test/unit/client/components/panes/PaneContainer.createContent.test.tsx \
+ test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx \
+ test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx \
+ test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \
+ test/unit/client/lib/fresh-agent-ws.test.ts \
+ test/unit/client/lib/ws-client.test.ts \
+ test/unit/client/lib/pane-activity.test.ts \
+ test/unit/client/store/freshAgentSlice.test.ts \
+ test/unit/client/ui-commands.test.ts \
+ test/e2e/agent-chat-capability-settings-flow.test.tsx \
+ test/unit/client/components/ContextMenuProvider.test.tsx
+```
+
+```bash
+npm run test:server -- --run \
+ test/unit/server/config-store.fresh-agent-settings.test.ts \
+ test/unit/server/config-store.test.ts \
+ test/unit/server/agent-layout-schema.test.ts \
+ test/unit/server/agent-api/layout-store.fresh-agent.test.ts \
+ test/unit/server/mcp/freshell-tool.test.ts \
+ test/unit/server/terminal-registry.test.ts \
+ test/unit/server/fresh-agent/claude-adapter.test.ts \
+ test/unit/server/fresh-agent/runtime-manager.test.ts \
+ test/unit/server/ws-handler-fresh-agent.test.ts \
+ test/integration/server/settings-api.test.ts
+```
+
+Final verification must also include:
+
+```bash
+npm run typecheck
+npm run lint
+npm run build
+npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent.spec.ts test/e2e-browser/specs/fresh-agent-mobile.spec.ts
+FRESHELL_TEST_SUMMARY="freshcodex full-suite blocker closure" npm run check
+git diff --check
+```
+
+If coordinated `npm run check` reports additional failures touching fresh-agent, freshcodex, legacy agent-chat compatibility, settings, pane persistence, storage migration, remote tab rehydration, or the context menu warning fixed here, continue fixing them in this same implementation cycle. If it reports genuinely unrelated pre-existing failures, stop with exact paths, logs, and evidence; do not silently declare success.
+
+### Task 1: Lock Canonical Pane Identity And Remote Rehydration
+
+**Files:**
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/TabsView.tsx`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/TabContent.tsx`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/tab-registry-snapshot.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/session-type-utils.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/session-utils.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/shared/session-contract.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/claude-session-id.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/claude-session-id.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/panesSlice.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/crossTabSync.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/tabsSlice.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/tab-fallback-identity.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/selectors/sidebarSelectors.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/ui-commands.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/pane-activity.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/agent-api/router.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/mcp/freshell-tool.ts`
+- Modify or create: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/pane-content.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/TabsView.test.tsx`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/TabsView.fresh-agent.test.tsx`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/TabContent.test.tsx`
+- Modify or create: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/tab-registry-snapshot.test.ts`
+- Modify or create: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/tab-fallback-identity.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/session-utils.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/session-type-utils.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/ui-commands.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/pane-activity.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/selectors/sidebarSelectors.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/panesSlice.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/tabsSlice.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/crossTabSync.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/mcp/freshell-tool.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/terminal-registry.test.ts`
+
+- [ ] **Step 1: Identify the failing tests**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/components/TabsView.test.tsx \
+ test/unit/client/components/TabsView.fresh-agent.test.tsx \
+ test/unit/client/components/TabContent.test.tsx \
+ test/unit/client/lib/tab-registry-snapshot.test.ts \
+ test/unit/client/lib/session-utils.test.ts \
+ test/unit/shared/session-contract.test.ts \
+ test/unit/client/lib/claude-session-id.test.ts \
+ test/unit/client/lib/session-type-utils.test.ts \
+ test/unit/client/lib/pane-activity.test.ts \
+ test/unit/client/ui-commands.test.ts \
+ test/unit/client/store/selectors/sidebarSelectors.test.ts \
+ test/unit/client/store/crossTabSync.test.ts \
+ test/unit/client/store/tabsSlice.test.ts \
+ test/unit/client/store/panesSlice.test.ts
+npm run test:server -- --run \
+ test/unit/server/mcp/freshell-tool.test.ts \
+ test/unit/server/terminal-registry.test.ts
+```
+
+Expected before changes: remote legacy `agent-chat` snapshots either remain `agent-chat`, keep stale CLI tab mode, synthesize non-portable resume aliases, publish invalid portable identities, fail to preserve native Freshcodex durable identity correctly, lose no-layout/tab-level identity, or let server-orchestrated open paths promote raw aliases.
+
+- [ ] **Step 2: Update tests to assert the steady-state identity contract**
+
+In `test/unit/client/components/TabsView.test.tsx`, update the remote legacy `agent-chat` copy test so the copied pane content is canonical:
+
+```ts
+expect(copiedTab.mode).toBe('shell')
+expect(copiedLayout.content).toMatchObject({
+ kind: 'fresh-agent',
+ sessionType: 'freshclaude',
+ provider: 'claude',
+ sessionRef: {
+ provider: 'claude',
+ sessionId: '00000000-0000-4000-8000-000000000444',
+ },
+ modelSelection: { kind: 'tracked', modelId: 'tracked-fixture-claude-model' },
+ permissionMode: 'plan',
+ effort: 'turbo',
+ plugins: ['planner'],
+})
+expect(copiedLayout.content.serverInstanceId).toBeUndefined()
+expect(copiedLayout.content.resumeSessionId).toBeUndefined()
+expect(copiedLayout.content.sessionId).toBeUndefined()
+```
+
+Add a sibling legacy remote test where `resumeSessionId: 'named-resume'` is the only legacy identity. Assert it becomes `fresh-agent` but has no `sessionRef`, no remote `resumeSessionId`, and a visible `restoreError` if the existing contract supports one. If the current product intentionally opens an un-restorable shell with no error for remote named aliases, change the implementation to provide the clear `restoreError`; do not silently create a new durable session.
+
+In `test/unit/client/components/TabsView.fresh-agent.test.tsx`, strengthen native Freshcodex remote coverage:
+
+```ts
+expect(copiedTab.mode).toBe('shell')
+expect(copiedLayout.content).toMatchObject({
+ kind: 'fresh-agent',
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ sessionRef: {
+ provider: 'codex',
+ sessionId: 'codex-thread-123',
+ },
+ model: 'codex-model',
+ sandbox: 'workspace-write',
+})
+expect(copiedLayout.content.resumeSessionId).toBeUndefined()
+expect(copiedLayout.content.sessionId).toBeUndefined()
+expect(copiedLayout.content.modelSelection).toBeUndefined()
+```
+
+Add a negative native `fresh-agent` remote snapshot test where the payload has only `resumeSessionId: 'codex-runtime-handle'` and no `sessionRef`. Assert the copied pane has no `sessionRef`, no remote `resumeSessionId`, and a visible `restoreError`. This covers the canonical `fresh-agent` steady-state case, not only legacy `agent-chat` snapshots.
+
+Add a multi-pane context-menu test for `openPaneInNewTab()`: make the remote record's first pane a terminal or legacy agent pane, open a later sanitized rich-agent pane in its own tab, and assert the new tab uses `mode: 'shell'` derived from the clicked pane's sanitized content rather than `deriveModeFromRecord(record)`.
+
+Add a whole-tab multi-pane copy test where the first raw pane is terminal/CLI-like and a later pane sanitizes to `fresh-agent`. Assert the copied tab uses `mode: 'shell'` because the sanitized tree contains a rich-agent pane anywhere in the copied layout.
+
+In `test/unit/client/lib/tab-registry-snapshot.test.ts`, add publication tests for `buildOpenTabRegistryRecord()` / `collectPaneSnapshots()`:
+
+- legacy `agent-chat` with a canonical Claude durable ID publishes canonical `kind: 'fresh-agent'`, `sessionRef`, and `modelSelection`, and does not publish `sessionId`, `resumeSessionId`, or `serverInstanceId`;
+- legacy `agent-chat` with `resumeSessionId: 'named-resume'` does not publish `sessionRef`;
+- native `fresh-agent` Codex publishes canonical `kind: 'fresh-agent'`, explicit `sessionRef`, Codex `model`, and Codex runtime fields, and does not publish same-server runtime handles;
+- native `fresh-agent` with only `resumeSessionId` does not synthesize a portable `sessionRef`.
+
+In `test/unit/client/lib/session-type-utils.test.ts`, add `buildResumeContent()` coverage:
+
+- `freshclaude` / `kilroy` with a canonical Claude durable ID include `sessionRef`;
+- `freshclaude` / `kilroy` with a named alias keep `resumeSessionId` but omit `sessionRef`;
+- `freshcodex` with a Codex durable thread ID includes `sessionRef`;
+- provider-specific defaults remain provider-specific (`modelSelection` for Claude-backed sessions, runtime `model` for Codex).
+
+In `test/unit/client/lib/session-utils.test.ts`, add open-session locator coverage:
+
+- a `fresh-agent` pane with `sessionRef` and `resumeSessionId` yields a single durable locator based on `sessionRef`, not a duplicate/stale `resumeSessionId` locator;
+- a `fresh-agent` pane with only a same-server Claude runtime `resumeSessionId` yields a live local locator but is not marked portable;
+- a Freshcodex pane with conflicting `sessionRef` and `resumeSessionId` is treated as invalid/ambiguous by any helper that would otherwise advertise it.
+
+In `test/unit/client/lib/pane-activity.test.ts`, add activity-key projection coverage for canonical `fresh-agent` panes: sessionRef-only panes remain grouped as busy/active after runtime handles are stripped, current-server runtime-only handles are local-only, and named aliases do not pollute durable activity keys.
+
+In `test/unit/shared/session-contract.test.ts`, `test/unit/client/lib/claude-session-id.test.ts`, and server-side coverage through `test/unit/server/terminal-registry.test.ts`, add direct predicate coverage so the shared, client, and server Claude durable-ID checks accept and reject the same fixture IDs.
+
+In `test/unit/client/lib/tab-fallback-identity.test.ts`, add coverage that a single-pane tab containing `fresh-agent.sessionRef` yields a stable fallback identity even when `sessionId` and `resumeSessionId` are absent.
+
+In `test/unit/client/store/selectors/sidebarSelectors.test.ts`, add coverage that sessionRef-only `fresh-agent` panes still appear in sidebar fallback session rows after persisted/cross-tab sanitization has stripped `resumeSessionId`.
+
+In `test/unit/client/components/TabContent.test.tsx`, add no-layout restore coverage: a tab with only tab-level `sessionRef` synthesizes canonical `fresh-agent` content, while a tab with only a nonportable named alias renders a restore error and does not synthesize legacy `agent-chat` content.
+
+In `test/unit/client/store/crossTabSync.test.ts`, add cross-tab coverage for canonicalization on incoming payloads and writeback: runtime handles are retained only when tagged with the current `serverInstanceId` and no durable `sessionRef` exists; once `sessionRef` exists, `sessionId` and `resumeSessionId` are stripped; named aliases are never promoted to portable `sessionRef`.
+
+In `test/unit/client/store/tabsSlice.test.ts`, add `openSessionTab` and reopen-stack coverage: opening a fresh-agent session tab copies tab-level `sessionRef` for both legacy and canonical input, closed-tab snapshots are sanitized before storage, and `reopenClosedTab` restores canonical fresh-agent content instead of raw saved legacy/runtime-only content.
+
+In `test/unit/client/ui-commands.test.ts`, add a server-originated tab-open command that carries rich-agent identity through `/api/tabs` replay. Assert the command is canonicalized before reducer ingestion and that nonportable aliases become explicit restore errors.
+
+In `test/unit/server/mcp/freshell-tool.test.ts`, add MCP tab creation coverage for the same identity contract: raw `resume` aliases must not become portable `sessionRef`, and runtime provider/session type mapping must be explicit.
+
+In `test/unit/client/store/panesSlice.test.ts`, replace the obsolete assertion that no `sessionRef` is synthesized with two stronger cases:
+
+```ts
+it('normalizes legacy agent-chat freshclaude input with a canonical Claude id to fresh-agent', () => {
+ const state = panesReducer(
+ initialState,
+ initLayout({
+ tabId: 'tab-1',
+ content: {
+ kind: 'agent-chat',
+ provider: 'freshclaude',
+ resumeSessionId: VALID_CLAUDE_SESSION_ID,
+ },
+ }),
+ )
+
+ const leaf = state.layouts['tab-1'] as Extract
+ expect(leaf.content).toMatchObject({
+ kind: 'fresh-agent',
+ sessionType: 'freshclaude',
+ provider: 'claude',
+ resumeSessionId: VALID_CLAUDE_SESSION_ID,
+ sessionRef: {
+ provider: 'claude',
+ sessionId: VALID_CLAUDE_SESSION_ID,
+ },
+ })
+})
+
+it('does not synthesize a portable sessionRef from a named legacy resume alias', () => {
+ const state = panesReducer(
+ initialState,
+ initLayout({
+ tabId: 'tab-1',
+ content: {
+ kind: 'agent-chat',
+ provider: 'freshclaude',
+ resumeSessionId: 'named-resume',
+ },
+ }),
+ )
+
+ const leaf = state.layouts['tab-1'] as Extract
+ expect(leaf.content).toMatchObject({
+ kind: 'fresh-agent',
+ sessionType: 'freshclaude',
+ provider: 'claude',
+ resumeSessionId: 'named-resume',
+ })
+ expect(leaf.content.sessionRef).toBeUndefined()
+})
+```
+
+- [ ] **Step 3: Run tests to verify they fail for the intended gaps**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/components/TabsView.test.tsx \
+ test/unit/client/components/TabsView.fresh-agent.test.tsx \
+ test/unit/client/components/TabContent.test.tsx \
+ test/unit/client/lib/tab-registry-snapshot.test.ts \
+ test/unit/client/lib/tab-fallback-identity.test.ts \
+ test/unit/client/lib/session-utils.test.ts \
+ test/unit/shared/session-contract.test.ts \
+ test/unit/client/lib/claude-session-id.test.ts \
+ test/unit/client/lib/session-type-utils.test.ts \
+ test/unit/client/lib/pane-activity.test.ts \
+ test/unit/client/ui-commands.test.ts \
+ test/unit/client/store/selectors/sidebarSelectors.test.ts \
+ test/unit/client/store/crossTabSync.test.ts \
+ test/unit/client/store/tabsSlice.test.ts \
+ test/unit/client/store/panesSlice.test.ts
+npm run test:server -- --run \
+ test/unit/server/mcp/freshell-tool.test.ts \
+ test/unit/server/terminal-registry.test.ts
+```
+
+Expected: failures point to missing remote conversion, stale tab mode, invalid publisher/session-helper identity synthesis, no-layout restore gaps, server-orchestrated open path gaps, cross-tab alias promotion, open/reopen stack identity loss, missing native Freshcodex assertions, or reducer sessionRef derivation.
+
+- [ ] **Step 4: Implement canonicalization at reducer and remote snapshot boundaries**
+
+In `shared/session-contract.ts`, first make the durable-state helper context-aware. The helper should separate "same-server local normalization" from "remote/cross-server portable restore":
+
+- In local reducer/session-opening contexts, a named Claude `resumeSessionId` remains a same-server runtime alias and does not become `restoreError`.
+- In remote/cross-server contexts, a named Claude alias is not portable and becomes `restoreError: buildRestoreError('missing_canonical_identity')`.
+- Trusted `sessionRef`, `cliSessionId`, and `timelineSessionId` values are accepted through the shared Claude durable-ID predicate; a bare `resumeSessionId` is promoted only if it satisfies that predicate.
+
+Update `src/lib/claude-session-id.ts` to delegate to or exactly match the shared predicate so client recovery tests and shared migration agree.
+
+In `src/store/panesSlice.ts`, canonicalize `agent-chat` through that context-aware durable-state helper rather than ad hoc string checks:
+
+- For legacy `agent-chat` with a valid `sessionRef`, valid `cliSessionId`, valid `timelineSessionId`, or valid canonical `resumeSessionId`, produce `sessionRef`.
+- For legacy `agent-chat` with a named `resumeSessionId`, preserve it only as same-pane `resumeSessionId`; do not create `sessionRef`.
+- For Claude-backed `fresh-agent`, ensure stale `model` is removed after conversion to `modelSelection`.
+- For Codex-backed `fresh-agent`, preserve `model` and do not synthesize `modelSelection`.
+
+Create or update `src/lib/pane-content.ts` so reducer, persistence, cross-tab, tab-registry, no-layout restore, UI command, and activity projection boundaries share the same context-aware rich-agent canonicalization. Keep helpers small and type-focused, such as:
+
+```ts
+export function normalizeFreshAgentPaneModelFields(input: {
+ provider?: unknown
+ model?: unknown
+ modelSelection?: unknown
+ effort?: unknown
+}): {
+ model?: string
+ modelSelection?: AgentChatModelSelection
+ effort?: string
+} {
+ if (input.provider === 'codex') {
+ return {
+ model: typeof input.model === 'string' ? input.model : undefined,
+ effort: normalizeAgentChatEffortOverride(input.effort),
+ }
+ }
+ return {
+ modelSelection: normalizeAgentChatModelSelection(input.modelSelection, input.model),
+ effort: normalizeAgentChatEffortOverride(input.effort),
+ }
+}
+```
+
+In `src/components/TabsView.tsx`, change `sanitizePaneSnapshot()` so `snapshot.kind === 'agent-chat'` returns canonical `fresh-agent` content. Use durable-state migration, not raw `resumeSessionId`, to build portable `sessionRef`. Preserve `resumeSessionId` and `sessionId` only when `sameServer` is true. For remote named aliases, return a clear `restoreError` rather than silently starting a new session.
+
+For native `snapshot.kind === 'fresh-agent'`, preserve explicit `sessionRef`, preserve Freshcodex runtime fields, and drop remote `resumeSessionId` / `sessionId` unless `sameServer` is true. Do not use `resumeSessionId` as a `sessionRef` fallback for remote native fresh-agent snapshots; if no portable identity remains, set `restoreError`.
+
+Update copied tab mode derivation so mode is based on sanitized content. A copied tab with any sanitized `fresh-agent` pane anywhere in the copied tree must be `mode: 'shell'`, even if the remote registry snapshot was legacy `agent-chat` or the first raw pane was terminal-like. Apply the same rule in `openPaneInNewTab()`: derive mode from the sanitized clicked pane, not from the whole remote record's first pane.
+
+In `src/lib/tab-registry-snapshot.ts`, use the same durable-state helper to publish portable identity. Never synthesize `sessionRef` from a named alias or a same-server-only handle. Preserve `modelSelection` for Claude-backed panes and Codex runtime `model` / `sandbox` / settings for Codex panes.
+
+Publish canonical `fresh-agent` snapshot content for rich-agent panes. Do not publish legacy `agent-chat` `kind`, `sessionId`, `resumeSessionId`, or `serverInstanceId` across devices. Same-server-only handles are local runtime implementation details, not tab-registry data.
+
+In `src/lib/session-type-utils.ts`, update `buildResumeContent()` so explicit `sessionRef` is created only when `opts.sessionId` is a canonical durable ID for the runtime provider. Non-canonical Claude aliases remain `resumeSessionId` only.
+
+In `src/store/tabsSlice.ts`, `src/components/TabContent.tsx`, and `src/lib/tab-fallback-identity.ts`, teach tab-level identity derivation about canonical `fresh-agent.sessionRef`. This prevents a correctly sanitized rich-agent pane from losing tab identity just because it no longer has legacy `agent-chat` content or runtime handles. Apply the same canonicalization when `openSessionTab`, `recordClosedTabSnapshot`, `pushReopenEntry`, and `reopenClosedTab` create, store, or restore a rich-agent layout.
+
+In `src/store/selectors/sidebarSelectors.ts`, read `fresh-agent.sessionRef` before same-server `resumeSessionId` for fallback session rows. SessionRef-only panes must remain visible after persistence strips runtime handles.
+
+In `src/store/crossTabSync.ts`, canonicalize incoming and outgoing pane payloads with the same helper and current-server context. Do not synthesize a portable `sessionRef` from a named `resumeSessionId`; do not reapply runtime `sessionId` / `resumeSessionId` after a durable `sessionRef` is known; and preserve current-server runtime-only handles only until WebSocket `ready` can reconcile the server instance.
+
+In `src/lib/ui-commands.ts`, `server/agent-api/router.ts`, and `server/mcp/freshell-tool.ts`, route all server-orchestrated rich-agent tab creation through the same canonical content builder. These paths must not construct `sessionRef` directly from raw `mode`, `resume`, or request payload strings.
+
+In `src/lib/pane-activity.ts`, derive fresh-agent activity keys from canonical `sessionRef` first and runtime handles only for current-server live grouping. Named aliases can keep same-server visibility but must not become durable activity keys that cross devices or persist after sanitization.
+
+In `server/claude-session-id.ts`, remove any duplicate grammar or make it delegate to the shared predicate. Server restore, terminal tracking, and timeline decisions must agree with client migration/recovery tests.
+
+- [ ] **Step 5: Refactor and verify**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/components/TabsView.test.tsx \
+ test/unit/client/components/TabsView.fresh-agent.test.tsx \
+ test/unit/client/components/TabContent.test.tsx \
+ test/unit/client/lib/tab-registry-snapshot.test.ts \
+ test/unit/client/lib/tab-fallback-identity.test.ts \
+ test/unit/client/lib/session-utils.test.ts \
+ test/unit/shared/session-contract.test.ts \
+ test/unit/client/lib/claude-session-id.test.ts \
+ test/unit/client/lib/session-type-utils.test.ts \
+ test/unit/client/lib/pane-activity.test.ts \
+ test/unit/client/ui-commands.test.ts \
+ test/unit/client/store/selectors/sidebarSelectors.test.ts \
+ test/unit/client/store/crossTabSync.test.ts \
+ test/unit/client/store/tabsSlice.test.ts \
+ test/unit/client/store/panesSlice.test.ts
+npm run test:server -- --run \
+ test/unit/server/mcp/freshell-tool.test.ts \
+ test/unit/server/terminal-registry.test.ts
+```
+
+Expected: all selected tests pass for the canonical identity contract.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add \
+ src/components/TabsView.tsx \
+ src/components/TabContent.tsx \
+ src/lib/tab-registry-snapshot.ts \
+ src/lib/session-type-utils.ts \
+ src/lib/session-utils.ts \
+ shared/session-contract.ts \
+ src/lib/claude-session-id.ts \
+ server/claude-session-id.ts \
+ src/store/panesSlice.ts \
+ src/store/crossTabSync.ts \
+ src/store/tabsSlice.ts \
+ src/lib/tab-fallback-identity.ts \
+ src/store/selectors/sidebarSelectors.ts \
+ src/lib/ui-commands.ts \
+ src/lib/pane-activity.ts \
+ server/agent-api/router.ts \
+ server/mcp/freshell-tool.ts \
+ src/lib/pane-content.ts \
+ test/unit/client/components/TabsView.test.tsx \
+ test/unit/client/components/TabsView.fresh-agent.test.tsx \
+ test/unit/client/components/TabContent.test.tsx \
+ test/unit/client/lib/tab-registry-snapshot.test.ts \
+ test/unit/client/lib/tab-fallback-identity.test.ts \
+ test/unit/client/lib/session-utils.test.ts \
+ test/unit/shared/session-contract.test.ts \
+ test/unit/client/lib/claude-session-id.test.ts \
+ test/unit/client/lib/session-type-utils.test.ts \
+ test/unit/client/lib/pane-activity.test.ts \
+ test/unit/client/ui-commands.test.ts \
+ test/unit/client/store/selectors/sidebarSelectors.test.ts \
+ test/unit/client/store/crossTabSync.test.ts \
+ test/unit/client/store/tabsSlice.test.ts \
+ test/unit/client/store/panesSlice.test.ts \
+ test/unit/server/mcp/freshell-tool.test.ts \
+ test/unit/server/terminal-registry.test.ts
+git commit -m "Canonicalize fresh-agent pane identity"
+```
+
+If `src/lib/pane-content.ts` was not created, omit it from `git add`.
+
+### Task 2: Make Fresh-Agent Create Payloads Provider-Aware
+
+**Files:**
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/shared/ws-protocol.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/runtime-adapter.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/runtime-manager.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/adapters/claude/adapter.ts`
+- Modify if needed: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/fresh-agent/adapters/codex/adapter.ts`
+- Modify if needed: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/ws-handler.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/panes/PaneContainer.tsx`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/fresh-agent/FreshAgentView.tsx`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/ws-client.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/panes/PaneContainer.createContent.test.tsx`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/fresh-agent-ws.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/ws-client.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/fresh-agent/claude-adapter.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/fresh-agent/runtime-manager.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/ws-handler-fresh-agent.test.ts`
+
+- [ ] **Step 1: Identify or write failing protocol tests**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/lib/fresh-agent-ws.test.ts \
+ test/unit/client/lib/ws-client.test.ts \
+ test/unit/client/components/panes/PaneContainer.createContent.test.tsx
+npm run test:server -- --run \
+ test/unit/server/fresh-agent/claude-adapter.test.ts \
+ test/unit/server/fresh-agent/runtime-manager.test.ts \
+ test/unit/server/ws-handler-fresh-agent.test.ts
+```
+
+Expected before changes: fresh-agent create does not support enough provider-specific fields, adapter settings resolution, or locator semantics to resume remote copied Freshcodex/FreshClaude panes safely.
+
+- [ ] **Step 2: Add tests for provider-aware create payloads**
+
+In `test/unit/server/ws-handler-fresh-agent.test.ts`, add coverage that `freshAgent.create` accepts:
+
+```ts
+{
+ type: 'freshAgent.create',
+ requestId: 'req-1',
+ sessionType: 'freshclaude',
+ provider: 'claude',
+ sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID },
+ modelSelection: { kind: 'tracked', modelId: 'tracked-fixture-claude-model' },
+ effort: 'turbo',
+}
+```
+
+Assert no Zod/schema validation failure occurs for opaque effort strings.
+
+Add a mismatch case:
+
+```ts
+{
+ type: 'freshAgent.create',
+ requestId: 'req-1',
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID },
+}
+```
+
+Expected result: a clear `freshAgent.create.failed` with a locator-mismatch code such as `FRESH_AGENT_SESSION_LOCATOR_MISMATCH`; do not fall back to creating a new session.
+
+Add an idempotent replay case: send a `freshAgent.create` request that succeeds with `sessionRef`, resend the same `requestId` after reconnect, and assert the cached `freshAgent.created` replay includes the original `sessionRef` as well as the runtime `sessionId`.
+
+Add a live layout-sync case: send `ui.layout.sync` containing a `fresh-agent` pane with `sessionRef` and assert server-side open-session tracking records the canonical locator. Add a second case for a current-server runtime-only Claude-backed `fresh-agent` pane with `resumeSessionId` / `sessionId` and matching `serverInstanceId`, and assert the live same-server locator remains tracked. If `ws-handler-fresh-agent.test.ts` cannot exercise this integration faithfully, add the case to `test/server/ws-tabs-registry.test.ts`.
+
+In `test/unit/server/fresh-agent/runtime-manager.test.ts`, assert create/resume precedence:
+
+- provider mismatch in any supplied locator fails before resume/create precedence is applied;
+- two conflicting canonical durable locators fail clearly instead of silently choosing one;
+- Freshcodex `sessionRef: { provider: 'codex', sessionId: 'codex-thread-a' }` plus `resumeSessionId: 'codex-thread-b'` fails clearly because Codex thread IDs are durable and cannot be treated as same-server aliases;
+- `resumeSessionId` wins for same-server live resumes only after locator consistency has passed;
+- a non-canonical same-server Claude `resumeSessionId` may coexist with matching-provider `sessionRef` for live attach, and persistence still keeps only `sessionRef`;
+- `sessionRef.sessionId` is used when `resumeSessionId` is absent and `sessionRef.provider` matches the runtime provider.
+- Codex create preserves `model`, `sandbox`, and Codex effort/settings.
+- Claude create preserves `modelSelection` and opaque effort strings.
+
+Add created-identity tests:
+
+- Codex create/resume returns a `sessionRef` in the created payload because Codex thread ids are durable.
+- Claude create does not synthesize `sessionRef` from the SDK bridge runtime `sessionId` alone; it returns/persists `sessionRef` only when the adapter has trusted canonical SDK/timeline history metadata.
+
+In `test/unit/server/fresh-agent/claude-adapter.test.ts`, assert the Claude adapter resolves `modelSelection` into the actual `sdkBridge.createSession({ model })` value:
+
+- `{ kind: 'exact', modelId: 'fixture-claude-model' }` becomes `model: 'fixture-claude-model'`;
+- tracked/alias model selections are resolved by the same helper used by legacy AgentChat where possible, or by a small shared resolver if the legacy helper is client-only;
+- no `modelSelection` means the adapter uses the existing provider default behavior;
+- opaque effort strings such as `turbo` are passed through without the shared transport rejecting them.
+
+In `test/unit/client/lib/fresh-agent-ws.test.ts`, assert create cancellation/late-created handling still works when create messages include `sessionRef` and `modelSelection`.
+
+In `test/unit/client/lib/ws-client.test.ts`, assert reconnect replay preserves the exact expanded `freshAgent.create` payload for representative FreshClaude and Freshcodex messages: `sessionRef`, `modelSelection`, opaque effort, Codex `model`, Codex `sandbox`, runtime settings, and request id. The replay helper must not rebuild the message from a minimal pending-create shape.
+
+In `test/unit/client/components/panes/PaneContainer.createContent.test.tsx`, assert user-visible new-pane creation keeps provider fields separate:
+
+- FreshClaude/FreshAgent panes use `modelSelection` and opaque Claude effort fields and do not get a runtime `model`.
+- Freshcodex panes keep runtime `model`, `sandbox`, and Codex settings and do not get Claude `modelSelection`.
+
+- [ ] **Step 3: Run tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/lib/fresh-agent-ws.test.ts \
+ test/unit/client/lib/ws-client.test.ts \
+ test/unit/client/components/panes/PaneContainer.createContent.test.tsx
+npm run test:server -- --run \
+ test/unit/server/fresh-agent/claude-adapter.test.ts \
+ test/unit/server/fresh-agent/runtime-manager.test.ts \
+ test/unit/server/ws-handler-fresh-agent.test.ts
+```
+
+Expected: validation/type/runtime gaps are red.
+
+- [ ] **Step 4: Implement provider-aware create contract**
+
+In `shared/ws-protocol.ts`, extend `FreshAgentCreateSchema`:
+
+- Add `sessionRef: SessionLocatorSchema.optional()`.
+- Add `modelSelection: AgentChatModelSelectionSchema.optional()` or the local shared equivalent already used for pane settings.
+- Change `effort` from a fixed Codex enum to a trimmed non-empty string. Provider adapters can reject unsupported values later, but the shared fresh-agent transport must not reject Claude dynamic efforts.
+- Add optional `sessionRef` to `freshAgent.created` responses if it is not already present, but only as an explicitly supplied durable identity from the runtime manager/adapter. Do not require clients to infer portability from raw `sessionId`.
+
+Update exported `FreshAgentCreateRequest` types in `server/fresh-agent/runtime-adapter.ts` to match.
+
+In `src/components/fresh-agent/FreshAgentView.tsx`, include `sessionRef` and `modelSelection` in `buildCreateMessage()`. Keep `model` for Codex runtime model selection.
+
+In `src/components/panes/PaneContainer.tsx`, update new-pane content creation so Claude-backed `fresh-agent` panes are initialized with `modelSelection` / opaque `effort` and no runtime `model`; Freshcodex panes keep runtime `model` / `sandbox` fields and no Claude `modelSelection`.
+
+In `server/fresh-agent/runtime-manager.ts`, validate locators before resolving create identity:
+
+1. If any supplied `sessionRef.provider` differs from the runtime provider, throw a clear typed error for the WS handler.
+2. If `sessionRef.provider === 'codex'`, treat any supplied `resumeSessionId` as a durable Codex thread ID; if it differs from `sessionRef.sessionId`, throw a clear conflicting-locator error.
+3. If `sessionRef.provider === 'claude'` and `resumeSessionId` is a canonical durable id that differs from `sessionRef.sessionId`, throw a clear conflicting-locator error.
+4. If `resumeSessionId` is a non-canonical same-server Claude alias and `sessionRef` is present, allow live attach with `resumeSessionId` but keep `sessionRef` as the durable identity.
+5. If `resumeSessionId` is present and locator consistency passed, use it for same-server live resume.
+6. Else if `sessionRef` is present, use `sessionRef.sessionId`.
+7. Else create a new session.
+
+Do not silently ignore mismatched locators.
+
+In `server/ws-handler.ts`, extend the pending create/cache record so it stores and replays the durable `sessionRef` from the original create result. The idempotent duplicate-request path must not reconstruct `freshAgent.created` from only `sessionId`, `sessionType`, and `runtimeProvider`.
+
+Also update `ui.layout.sync` session extraction so canonical `fresh-agent.sessionRef` is advertised first, and same-server runtime handles are used only for live local tracking when no durable identity exists. This boundary must not publish or dedupe remote sessions by stale `fresh-agent.resumeSessionId` when a `sessionRef` is present.
+
+In `src/lib/ws-client.ts`, keep reliable create tracking payloads immutable and complete. Store the original `freshAgent.create` message shape, minus only socket-local bookkeeping, and replay that complete message after reconnect. Do not reconstruct create payloads from `requestId`, `provider`, or stale pane content.
+
+In `server/fresh-agent/adapters/claude/adapter.ts`, resolve `input.modelSelection` before calling the SDK bridge. Prefer an existing shared resolver if one exists; otherwise extract a server-safe helper whose contract is covered by `claude-adapter.test.ts`. The adapter, not `FreshAgentView`, owns converting `modelSelection` to the SDK `model` field so FreshClaude/FreshAgent behavior stays consistent across REST/WS callers.
+
+In the adapter/runtime-manager create result, distinguish runtime handle from portable identity. Codex adapter results should carry `sessionRef: { provider: 'codex', sessionId: threadId }`; Claude adapter results should carry `sessionRef` only when canonical CLI/timeline history metadata is available, never from the SDK bridge's generated runtime handle.
+
+- [ ] **Step 5: Refactor and verify**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/lib/fresh-agent-ws.test.ts \
+ test/unit/client/lib/ws-client.test.ts \
+ test/unit/client/components/panes/PaneContainer.createContent.test.tsx
+npm run test:server -- --run \
+ test/unit/server/fresh-agent/claude-adapter.test.ts \
+ test/unit/server/fresh-agent/runtime-manager.test.ts \
+ test/unit/server/ws-handler-fresh-agent.test.ts
+```
+
+Expected: all selected tests pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add \
+ shared/ws-protocol.ts \
+ server/fresh-agent/runtime-adapter.ts \
+ server/fresh-agent/runtime-manager.ts \
+ server/fresh-agent/adapters/claude/adapter.ts \
+ server/fresh-agent/adapters/codex/adapter.ts \
+ server/ws-handler.ts \
+ test/server/ws-tabs-registry.test.ts \
+ src/components/panes/PaneContainer.tsx \
+ src/components/fresh-agent/FreshAgentView.tsx \
+ src/lib/ws-client.ts \
+ test/unit/client/components/panes/PaneContainer.createContent.test.tsx \
+ test/unit/client/lib/fresh-agent-ws.test.ts \
+ test/unit/client/lib/ws-client.test.ts \
+ test/unit/server/fresh-agent/claude-adapter.test.ts \
+ test/unit/server/fresh-agent/runtime-manager.test.ts \
+ test/unit/server/ws-handler-fresh-agent.test.ts
+git commit -m "Support provider-aware fresh-agent create"
+```
+
+Omit `server/ws-handler.ts`, `server/fresh-agent/adapters/codex/adapter.ts`, and `test/server/ws-tabs-registry.test.ts` if they did not change.
+
+### Task 3: Fix Persisted Pane And Storage-Key Migration
+
+**Files:**
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistMiddleware.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistedState.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/crossTabSync.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/tabRegistrySync.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/storage-migration.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/paneTreeValidation.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/agent-api/layout-schema.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/agent-api/layout-store.ts`
+- Modify or create: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/pane-content.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/panesPersistence.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/crossTabSync.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/tabRegistrySync.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/storage-migration.test.ts`
+- Modify or create: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/paneTreeValidation.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/agent-layout-schema.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/agent-api/layout-store.fresh-agent.test.ts`
+
+- [ ] **Step 1: Identify the failing tests**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/store/panesPersistence.test.ts \
+ test/unit/client/store/crossTabSync.test.ts \
+ test/unit/client/store/tabRegistrySync.test.ts \
+ test/unit/client/store/storage-migration.test.ts
+npm run test:server -- --run \
+ test/unit/server/agent-layout-schema.test.ts \
+ test/unit/server/agent-api/layout-store.fresh-agent.test.ts
+```
+
+If `test/unit/client/store/paneTreeValidation.test.ts` already exists when execution starts, include it in this red run. If it does not exist, create it in Step 2 before adding it to Step 3; the red phase must fail on validation behavior, not on a missing test module.
+
+Expected before changes: stale Claude `model` survives migration and v2 storage keys are not safely migrated/cleared.
+
+- [ ] **Step 2: Strengthen persistence tests**
+
+In `test/unit/client/store/panesPersistence.test.ts`, extend the legacy model test so it covers the actual post-parse path that converts legacy `agent-chat` to `fresh-agent`. Assert:
+
+```ts
+expect(content.kind).toBe('fresh-agent')
+expect(content.sessionType).toBe('freshclaude')
+expect(content.provider).toBe('claude')
+expect(content.model).toBeUndefined()
+expect(content.modelSelection).toEqual({
+ kind: 'exact',
+ modelId: 'fixture-claude-model',
+})
+```
+
+Add a sibling Codex regression:
+
+```ts
+expect(content.kind).toBe('fresh-agent')
+expect(content.provider).toBe('codex')
+expect(content.model).toBe('codex-model')
+expect(content.modelSelection).toBeUndefined()
+```
+
+Add or extend cross-tab/persisted hydration coverage so `parsePersistedLayoutRaw()` / `parsePersistedPanesRaw()` / `hydratePanes()` cannot keep stale Claude `model` via a path that bypasses `loadPersistedPanes()`. This must cover `src/store/persistedState.ts`, because storage migration and cross-tab sync use that parser boundary directly.
+
+Add persisted runtime-handle coverage:
+
+- A fresh-agent pane with `sessionRef` plus `sessionId` / `resumeSessionId` persists with `sessionRef` and without same-server runtime handles.
+- A same-server runtime-only Claude-backed fresh-agent pane with current `serverInstanceId` and no `sessionRef` remains locally resumable instead of being converted to `restoreError`.
+- A localStorage payload restored before WebSocket `ready` with a stale `serverInstanceId` is reconciled after the current server instance is known; it either switches to durable `sessionRef` resume or displays `restoreError`, and it never auto-creates a new unrelated session.
+- The same runtime-only pane copied/published as a remote/cross-server payload drops runtime handles and receives `restoreError`.
+- A restored persisted pane with only `sessionRef` remains in a lifecycle state that will resume through `freshAgent.create` rather than being treated as already connected.
+- A rich-agent pane with neither `sessionRef` nor same-server handles gets a `restoreError` and does not become a new-session create on reload.
+
+In `test/unit/client/store/crossTabSync.test.ts`, assert the same persistence rules apply to incoming storage events and cross-tab writeback: no stale runtime handle survives after `sessionRef` exists, runtime-only handles survive only for the current `serverInstanceId`, and stale handles reconcile after the ready message provides the current server id.
+
+In `test/unit/client/store/tabRegistrySync.test.ts`, add retained-closed-record publication coverage. Seed `state.tabRegistry.localClosed` with legacy `agent-chat` content and runtime-only `fresh-agent` handles, then assert the published registry payload is canonicalized just like open tabs.
+
+In `test/unit/server/agent-layout-schema.test.ts` and `test/unit/server/agent-api/layout-store.fresh-agent.test.ts`, add server-side layout ingress coverage: `ui.layout.sync` / layout-store writes with rich-agent pane content are validated/canonicalized on write/read, not stored as opaque `z.record(z.any())` payloads that can later expose stale identity.
+
+In `test/unit/client/store/paneTreeValidation.test.ts`, assert `isWellFormedPaneTree()` accepts a fresh-agent Claude pane with a valid durable identity:
+
+```ts
+{
+ kind: 'fresh-agent',
+ sessionType: 'freshclaude',
+ provider: 'claude',
+ createRequestId: 'req-1',
+ status: 'idle',
+ effort: 'turbo',
+ modelSelection: { kind: 'tracked', modelId: 'tracked-fixture-claude-model' },
+ sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID },
+}
+```
+
+Assert it separately accepts an un-restorable pane with only `restoreError`:
+
+```ts
+{
+ kind: 'fresh-agent',
+ sessionType: 'freshclaude',
+ provider: 'claude',
+ createRequestId: 'req-1',
+ status: 'create-failed',
+ restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'missing_canonical_identity' },
+}
+```
+
+Also assert malformed `sessionRef`, malformed `restoreError`, malformed `modelSelection`, non-string `effort`, and the invalid combination of `sessionRef` plus `restoreError` are rejected.
+
+In `test/unit/client/store/storage-migration.test.ts`, strengthen v2 migration coverage:
+
+- Old-version path: `freshell.tabs.v2` / `freshell.panes.v2` become `freshell.layout.v3`, and v2 keys are removed.
+- Already-stamped broken path: `freshell_version` already equals the old branch version, stale v2 keys remain, and the repair still runs because `STORAGE_VERSION` is bumped.
+- Corrupt-layout salvage path: an invalid `freshell.layout.v3` plus valid v2 keys writes a valid v3 layout before removing v2 keys.
+- Valid-layout path: an existing valid v3 layout is not overwritten by stale v2 keys, and stale v2 keys are removed.
+
+- [ ] **Step 3: Run tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/store/panesPersistence.test.ts \
+ test/unit/client/store/crossTabSync.test.ts \
+ test/unit/client/store/tabRegistrySync.test.ts \
+ test/unit/client/store/storage-migration.test.ts \
+ test/unit/client/store/paneTreeValidation.test.ts
+npm run test:server -- --run \
+ test/unit/server/agent-layout-schema.test.ts \
+ test/unit/server/agent-api/layout-store.fresh-agent.test.ts
+```
+
+Expected: failures point to stale provider model cleanup and storage repair gaps.
+
+- [ ] **Step 4: Implement provider-specific pane persistence migration**
+
+In `persistMiddleware.ts` and `persistedState.ts`, use the same provider-specific model normalization contract as `panesSlice.ts`:
+
+- `agent-chat`: migrate `model` into `modelSelection`, omit `model`.
+- `fresh-agent` with `provider === 'claude'`: migrate `model` into `modelSelection`, omit `model`.
+- `fresh-agent` with `provider === 'codex'`: preserve runtime `model`, omit stale `modelSelection`, preserve valid Codex runtime fields.
+
+Update `stripTransientSessionFields()` so fresh-agent persisted/cross-tab payloads keep `sessionRef` but strip same-server-only `resumeSessionId` when a durable `sessionRef` exists or when the payload is crossing a server boundary. Do not strip portable `sessionRef`.
+
+Also strip same-server `sessionId` for persisted/cross-tab fresh-agent payloads when a durable `sessionRef` exists or the source server does not match the current server. For a current-server runtime-only Claude pane that has no `sessionRef` yet, keep the runtime handle and `serverInstanceId` only in local same-server storage/cross-tab state so reloads during the same server lifetime can attach; never publish those fields to tab-registry/remote copies.
+
+In `persistedState.ts`, normalize parsed pane content before it is returned to callers. The invariant is that any persisted, cross-tab, or migrated rich-agent payload leaving this parser is already canonical: legacy `agent-chat` has become `fresh-agent`, Claude `model` has been migrated to `modelSelection` and removed, Codex runtime `model` is preserved, and invalid provider/session fields are stripped.
+
+In `crossTabSync.ts`, call that same parser/canonicalizer for inbound and outbound payloads. Add a post-ready reconciliation path for local restored panes whose runtime handles were accepted provisionally before the current `serverInstanceId` was available.
+
+In `tabRegistrySync.ts`, canonicalize retained `localClosed` records before publishing them. Do not assume records captured before this branch are already sanitized.
+
+In `server/agent-api/layout-schema.ts` and `server/agent-api/layout-store.ts`, apply the same rich-agent canonicalization at the server layout boundary. The schema can remain forward-compatible for non-rich pane fields, but rich-agent content must not pass through as arbitrary opaque JSON when it contains legacy identity or runtime handles.
+
+In `paneTreeValidation.ts`, align validation with the canonical content shape:
+
+- validate `fresh-agent.sessionRef` and `fresh-agent.restoreError` using the same shape checks as terminal/legacy agent-chat panes;
+- reject `fresh-agent` content that contains both a valid `sessionRef` and `restoreError`; a valid durable identity clears the stale restore error before persistence;
+- validate `fresh-agent.modelSelection` with `isAgentChatModelSelection`;
+- accept opaque non-empty string `effort` values for Claude-backed panes while still rejecting non-string effort values;
+- keep Codex `sandbox` enum validation.
+
+- [ ] **Step 5: Implement idempotent storage repair**
+
+In `storage-migration.ts`:
+
+- Bump `STORAGE_VERSION` so clients already stamped by the broken branch run the repair.
+- Import `TABS_STORAGE_KEY`, `PANES_STORAGE_KEY`, and `migrateV2ToV3` from the existing storage modules.
+- Attempt to parse/migrate existing `LAYOUT_STORAGE_KEY`.
+- If the layout key is absent or corrupt and v2 tabs/panes are recoverable, call `migrateV2ToV3()` and write the v3 layout before deleting v2 keys.
+- If the layout key is valid, do not overwrite it from stale v2 keys; remove v2 keys as stale compatibility keys.
+- Remove v2 keys only after either a valid layout exists or v2 data is unrecoverable.
+- Keep auth and browser-preference migration behavior unchanged.
+
+The invariant is: recoverable v2 data is written to `freshell.layout.v3` before `freshell.tabs.v2` / `freshell.panes.v2` are removed.
+
+- [ ] **Step 6: Refactor and verify**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/store/panesPersistence.test.ts \
+ test/unit/client/store/crossTabSync.test.ts \
+ test/unit/client/store/tabRegistrySync.test.ts \
+ test/unit/client/store/storage-migration.test.ts \
+ test/unit/client/store/paneTreeValidation.test.ts
+npm run test:server -- --run \
+ test/unit/server/agent-layout-schema.test.ts \
+ test/unit/server/agent-api/layout-store.fresh-agent.test.ts
+```
+
+Expected: all selected tests pass.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add \
+ src/store/persistMiddleware.ts \
+ src/store/persistedState.ts \
+ src/store/crossTabSync.ts \
+ src/store/tabRegistrySync.ts \
+ src/store/storage-migration.ts \
+ src/store/paneTreeValidation.ts \
+ server/agent-api/layout-schema.ts \
+ server/agent-api/layout-store.ts \
+ src/lib/pane-content.ts \
+ test/unit/client/store/panesPersistence.test.ts \
+ test/unit/client/store/crossTabSync.test.ts \
+ test/unit/client/store/tabRegistrySync.test.ts \
+ test/unit/client/store/storage-migration.test.ts \
+ test/unit/client/store/paneTreeValidation.test.ts \
+ test/unit/server/agent-layout-schema.test.ts \
+ test/unit/server/agent-api/layout-store.fresh-agent.test.ts
+git commit -m "Repair fresh-agent persistence migrations"
+```
+
+Omit `src/lib/pane-content.ts` if it was not created.
+
+### Task 4: Normalize Fresh-Agent Settings Aliases And Clear Sentinels
+
+**Files:**
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/shared/settings.ts`
+- Modify if needed: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/config-store.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/server/settings-router.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/settingsThunks.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/settingsSlice.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/settingsThunks.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/settingsSlice.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/config-store.fresh-agent-settings.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/server/config-store.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/integration/server/settings-api.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/shared/settings.test.ts`
+
+- [ ] **Step 1: Identify the failing tests**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/store/settingsThunks.test.ts \
+ test/unit/client/store/settingsSlice.test.ts \
+ test/unit/shared/settings.test.ts
+npm run test:server -- --run \
+ test/unit/server/config-store.fresh-agent-settings.test.ts \
+ test/unit/server/config-store.test.ts \
+ test/integration/server/settings-api.test.ts
+```
+
+Expected before changes: client clear-sentinel payloads include own `undefined` fields or server tests still expect obsolete `{ defaultModel: 'x' }`.
+
+- [ ] **Step 2: Update tests to the canonical settings contract**
+
+In `test/unit/client/store/settingsThunks.test.ts`, add a recursive helper:
+
+```ts
+function expectNoUndefinedOwnProperties(value: unknown, path = 'payload'): void {
+ if (!value || typeof value !== 'object') return
+ for (const [key, child] of Object.entries(value as Record)) {
+ expect(child, `${path}.${key}`).not.toBeUndefined()
+ expectNoUndefinedOwnProperties(child, `${path}.${key}`)
+ }
+}
+```
+
+Use this helper on the exact payload passed to `api.patch`. Do not rely on `JSON.stringify()`, because JSON drops `undefined` object properties.
+
+Assert clear sentinels use explicit `null` values:
+
+```ts
+expect(apiPatch).toHaveBeenCalledWith('/api/settings', expect.objectContaining({
+ agentChat: {
+ providers: {
+ freshclaude: {
+ modelSelection: null,
+ effort: null,
+ },
+ },
+ },
+}))
+expectNoUndefinedOwnProperties(apiPatch.mock.calls[0][1])
+```
+
+If the caller intentionally sends both `freshAgent` and `agentChat`, assert both aliases have the same normalized null sentinels. If the caller sends only one alias, assert the normalizer does not create a top-level sibling alias with value `undefined`.
+
+In `test/unit/client/store/settingsSlice.test.ts`, add reducer-ingestion coverage for `setServerSettings` and `previewServerSettingsPatch`: hydrated server settings, optimistic patches, and clear sentinels produce the same canonical `freshAgent` / `agentChat` aliases and resolved provider settings that the API route returns. This is required because route/thunk fixes alone do not prove UI selectors see canonical state.
+
+In `test/unit/server/config-store.fresh-agent-settings.test.ts`, replace the legacy expectation with canonical mirrored settings:
+
+```ts
+expect(settings.freshAgent.defaultPlugins).toEqual(['/tmp/plugin'])
+expect(settings.agentChat.defaultPlugins).toEqual(['/tmp/plugin'])
+expect(settings.freshAgent.providers.freshclaude).toEqual({
+ modelSelection: { kind: 'exact', modelId: 'fixture-claude-model' },
+ effort: 'high',
+})
+expect(settings.agentChat.providers.freshclaude).toEqual({
+ modelSelection: { kind: 'exact', modelId: 'fixture-claude-model' },
+ effort: 'high',
+})
+```
+
+In `test/unit/server/config-store.test.ts`, add or strengthen a real `ConfigStore.load()` legacy-config test. Persist a version-1 config with legacy `agentChat.providers.freshclaude.defaultModel/defaultEffort` and assert the loaded config has canonical mirrored `settings.freshAgent` and `settings.agentChat`. This is required because direct `mergeServerSettings()` tests do not prove file-load compatibility.
+
+Add two more `ConfigStore.load()` compatibility cases:
+
+- a legacy config that contains only `freshAgent.providers.freshclaude.defaultModel/defaultEffort` also loads into canonical mirrored `freshAgent` and `agentChat` settings;
+- a conflict config containing both aliases uses the explicitly documented precedence from `shared/settings.ts` and proves the lower-precedence alias does not overwrite a newer canonical `modelSelection` / `effort`.
+
+In `test/integration/server/settings-api.test.ts`, add route-level coverage for `PATCH /api/settings` that sends `freshAgent.providers.freshclaude.modelSelection: null` and `effort: null`. Assert the route accepts the patch, clears the provider defaults, mirrors compatibility aliases as intended, and does not reintroduce `undefined` provider keys. Keep the existing legacy `agentChat` clear-sentinel test green.
+
+While touching these tests, replace real provider-looking model identifiers with neutral fixture identifiers such as `fixture-claude-model`, `fixture-codex-model`, `fixture-tracked-model`, `fixture-unavailable-model`, and `fixture-generic-provider-model`. This stabilization work tests pass-through and migration behavior only; it must not pin or discuss current provider model names.
+
+- [ ] **Step 3: Run tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/store/settingsThunks.test.ts \
+ test/unit/client/store/settingsSlice.test.ts \
+ test/unit/shared/settings.test.ts
+npm run test:server -- --run \
+ test/unit/server/config-store.fresh-agent-settings.test.ts \
+ test/unit/server/config-store.test.ts \
+ test/integration/server/settings-api.test.ts
+```
+
+Expected: tests fail until client patch normalization and server load compatibility are aligned.
+
+- [ ] **Step 4: Implement API patch normalization without top-level undefined pollution**
+
+In `settingsThunks.ts`, normalize only sections that exist as records:
+
+```ts
+function normalizeAgentProviderDefaultsPatchForApiSection(section: unknown): unknown {
+ if (!isRecord(section) || !isRecord(section.providers)) return section
+ return {
+ ...section,
+ providers: Object.fromEntries(
+ Object.entries(section.providers).map(([providerName, providerPatch]) => [
+ providerName,
+ isRecord(providerPatch) ? normalizeAgentChatProviderPatchForApi(providerPatch) : providerPatch,
+ ]),
+ ),
+ }
+}
+```
+
+Assign back only when the original key exists:
+
+```ts
+if (Object.prototype.hasOwnProperty.call(normalizedPatch, 'freshAgent')) {
+ normalizedPatch.freshAgent = normalizeAgentProviderDefaultsPatchForApiSection(normalizedPatch.freshAgent)
+}
+if (Object.prototype.hasOwnProperty.call(normalizedPatch, 'agentChat')) {
+ normalizedPatch.agentChat = normalizeAgentProviderDefaultsPatchForApiSection(normalizedPatch.agentChat)
+}
+```
+
+After alias-specific normalization, run a general recursive sanitizer over the outgoing API patch:
+
+- remove own properties whose value is `undefined` when the key has no defined clear sentinel;
+- convert known clearable agent-provider fields such as `modelSelection`, `effort`, and provider default fields to `null`;
+- recurse through nested objects and arrays without mutating the caller's input;
+- never create a top-level `freshAgent` or `agentChat` sibling just to hold `undefined` children.
+
+Preserve existing clear behavior for coding CLI provider fields. The final payload passed to `api.patch()` must satisfy `expectNoUndefinedOwnProperties()` at every depth, not only inside `freshAgent.providers`.
+
+In `settingsSlice.ts`, route `setServerSettings`, optimistic preview state, and reducer-level patch ingestion through the shared canonical settings sanitizer. The reducer must not keep stale `defaultModel`, stale `defaultEffort`, or provider entries with own `undefined` values after server hydration.
+
+In `server/settings-router.ts`, route-level normalization must handle `freshAgent.providers.*` clear sentinels in addition to the legacy `agentChat` alias. The HTTP route is part of the contract; do not rely solely on lower-level `shared/settings.ts` unit tests.
+
+- [ ] **Step 5: Verify shared/server settings migration**
+
+In `shared/settings.ts`, ensure the existing sanitization/merge path maps:
+
+- `defaultModel` to `modelSelection: { kind: 'exact', modelId }`
+- `defaultEffort` to `effort`
+- legacy `agentChat` input to mirrored `freshAgent` and `agentChat`
+- legacy `freshAgent` input to mirrored `freshAgent` and `agentChat`
+- both aliases present with conflicting provider defaults to the same documented precedence covered by `ConfigStore.load()` tests
+
+Prefer fixing `shared/settings.ts` over patching `server/config-store.ts`; `ConfigStore.load()` should become green by using the shared contract.
+
+- [ ] **Step 6: Refactor and verify**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/store/settingsThunks.test.ts \
+ test/unit/client/store/settingsSlice.test.ts \
+ test/unit/shared/settings.test.ts
+npm run test:server -- --run \
+ test/unit/server/config-store.fresh-agent-settings.test.ts \
+ test/unit/server/config-store.test.ts \
+ test/integration/server/settings-api.test.ts
+```
+
+Expected: all selected tests pass.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add \
+ shared/settings.ts \
+ server/config-store.ts \
+ server/settings-router.ts \
+ src/store/settingsThunks.ts \
+ src/store/settingsSlice.ts \
+ test/unit/client/store/settingsThunks.test.ts \
+ test/unit/client/store/settingsSlice.test.ts \
+ test/unit/server/config-store.fresh-agent-settings.test.ts \
+ test/unit/server/config-store.test.ts \
+ test/integration/server/settings-api.test.ts \
+ test/unit/shared/settings.test.ts
+git commit -m "Normalize fresh-agent settings compatibility"
+```
+
+Omit `server/config-store.ts` if it did not change.
+
+### Task 5: Make Fresh-Agent Recovery Stale-Update Safe
+
+**Files:**
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/fresh-agent/FreshAgentView.tsx`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/lib/fresh-agent-ws.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/freshAgentSlice.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/store/persistControl.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/lib/fresh-agent-ws.test.ts`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/store/freshAgentSlice.test.ts`
+
+- [ ] **Step 1: Identify the failing tests**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx \
+ test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx \
+ test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \
+ test/unit/client/lib/fresh-agent-ws.test.ts \
+ test/unit/client/store/freshAgentSlice.test.ts
+```
+
+Expected before changes: lost-session recovery can use `named-resume` instead of the canonical durable ID, stale async updates can overwrite newer pane fields, remote restore errors can still auto-create a replacement session, and sessionRef-only resumes may not hydrate history correctly.
+
+- [ ] **Step 2: Strengthen recovery and stale-update tests**
+
+In `AgentChatView.session-lost.test.tsx`, keep the existing canonical assertion and add:
+
+```ts
+expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({
+ type: 'freshAgent.create',
+ resumeSessionId: 'named-resume',
+}))
+```
+
+Add a fallback test where no valid `timelineSessionId` or `cliSessionId` exists and assert `named-resume` is still used. The fix must not break legitimate non-canonical same-server resumes.
+
+In `AgentChatView.reload.test.tsx` and `AgentChatView.split-pane.test.tsx`, replace old assertions that a persisted FreshAgent pane sends `freshAgent.attach` from stripped runtime handles. Assert a pane with only `sessionRef` resumes through the idempotent `freshAgent.create` path, while a current-server runtime-only pane with matching `serverInstanceId` can still use the live same-server handle.
+
+In `FreshAgentView.test.tsx`, add stale-update coverage for these cases:
+
+- `paneContent.restoreError` is rendered as a clear user-facing error and suppresses automatic `freshAgent.create`;
+- `freshAgent.created` merges `sessionId`, `status`, and `createError` without clobbering a newer `model`, `modelSelection`, `permissionMode`, `plugins`, `sessionRef`, or `settingsDismissed`.
+- `freshAgent.created` for a newly created durable Freshcodex session writes `sessionRef: { provider: 'codex', sessionId: message.sessionId }` before the pane can be persisted without `resumeSessionId`.
+- `freshAgent.created` for a Claude-backed pane does not write `sessionRef` from `message.sessionId` unless the message contains an explicit trusted `sessionRef` or canonical history metadata.
+- `freshAgent.create.failed` merges `status` and `createError` without clobbering newer fields.
+- snapshot refresh merges `status` / canonical resume identity without clobbering newer pane settings.
+- retry/recovery resets lifecycle fields but preserves current provider-specific settings and writes canonical `sessionRef`.
+- when a canonical Claude durable ID appears after a named resume, the pane gets `sessionRef: { provider: 'claude', sessionId }`, clears or deprioritizes the named resume for durable persistence as appropriate, and dispatches `flushPersistedLayoutNow()`.
+- a Freshcodex pane with `sessionRef` and no `resumeSessionId` recovers using `paneContentRef.current.sessionRef.sessionId` rather than falling through to a stale named alias or failing to resume.
+- a pane that gains a valid `sessionRef` clears any stale `restoreError` in the same merge so the error does not suppress the next legitimate recovery.
+
+In `fresh-agent-ws.test.ts`, add a sessionRef-only restore case:
+
+```ts
+registerFreshAgentCreate(dispatch, 'req-1', {
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ sessionRef: { provider: 'codex', sessionId: 'codex-thread-123' },
+})
+```
+
+Assert pending-create state is marked as expecting history hydration even though `resumeSessionId` is absent. This prevents remote copied panes that intentionally drop runtime handles from being treated as new empty sessions.
+
+In `freshAgentSlice.test.ts`, assert the reducer-level result, not just pending-create metadata: a `freshAgent.created` transition from a pending create with `expectsHistoryHydration: true` creates/updates session state with `historyLoaded: false` and `awaitingDurableHistory: true`.
+
+- [ ] **Step 3: Run tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx \
+ test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx \
+ test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \
+ test/unit/client/lib/fresh-agent-ws.test.ts \
+ test/unit/client/store/freshAgentSlice.test.ts
+```
+
+Expected: tests fail until recovery uses fresh refs and async handlers stop dispatching whole captured pane objects.
+
+- [ ] **Step 4: Implement canonical recovery identity**
+
+In `FreshAgentView.tsx`, track the latest preferred resume ID in a ref:
+
+```ts
+const preferredResumeSessionIdRef = useRef(preferredResumeSessionId)
+preferredResumeSessionIdRef.current = preferredResumeSessionId
+```
+
+Update recovery to compute:
+
+```ts
+const recoveryResumeSessionId =
+ (paneContentRef.current.sessionRef?.provider === paneContentRef.current.provider
+ ? paneContentRef.current.sessionRef.sessionId
+ : undefined)
+ ?? preferredResumeSessionIdRef.current
+ ?? getPreferredResumeSessionId(claudeSessionRef.current)
+ ?? paneContentRef.current.resumeSessionId
+```
+
+When `recoveryResumeSessionId` comes from `sessionRef`, keep that existing provider-specific durable identity. When the fallback recovery ID is a valid canonical Claude ID, also set:
+
+```ts
+sessionRef: { provider: 'claude', sessionId: recoveryResumeSessionId }
+```
+
+Use `mergePaneContent()` for targeted recovery updates unless a full replacement is genuinely required. Do not send a direct ad hoc `freshAgent.create` from recovery; recovery should continue to flow through pane state and the existing idempotent create effect.
+
+At the create effect boundary, add an explicit guard:
+
+```ts
+if (paneContent.restoreError) return
+```
+
+Render `paneContent.restoreError` through a local helper such as `formatRestoreError(reason)` in the same visible error area as create/load/restore failures. Do not expect a message property; the shared `RestoreError` contract is `{ code, reason }`. A pane copied from another device with no portable identity is a restore failure, not a request to start a fresh unrelated session.
+
+- [ ] **Step 5: Implement persisted identity and no-clobber lifecycle updates**
+
+If needed, add a `buildFreshAgentPersistedIdentityUpdate()` helper in `persistControl.ts`, analogous to `buildAgentChatPersistedIdentityUpdate()`, for Claude-backed `fresh-agent` panes.
+
+In `FreshAgentView.tsx`:
+
+- Replace async `updatePaneContent({ content: { ...paneContent, ... } })` calls from `freshAgent.created`, `freshAgent.create.failed`, and snapshot refresh with `mergePaneContent()` targeted updates or with a freshly-read `paneContentRef.current`.
+- When canonical durable identity changes, merge `sessionRef`, clear stale `restoreError`, and dispatch `flushPersistedLayoutNow()`.
+- When `freshAgent.created` arrives and there is no current `sessionRef`, persist a `sessionRef` only from `message.sessionRef` or from a provider contract that explicitly declares the created id durable. Codex thread ids are durable; Claude SDK bridge runtime ids are not. This is required for new Freshcodex threads before `resumeSessionId` is stripped from persisted payloads without corrupting FreshClaude portable identity.
+- Preserve current provider-specific settings on retry/recovery.
+- Keep create idempotency and reconnect behavior intact.
+
+In `fresh-agent-ws.ts`, extend `registerFreshAgentCreate()` options to accept `sessionRef`. Set `expectsHistoryHydration` when either `resumeSessionId` exists or `sessionRef` exists. Do not weaken late-create cancellation behavior.
+
+In `freshAgentSlice.ts`, preserve `expectsHistoryHydration` through the reducer transition that materializes a session from pending create state. The user-visible invariant is that a restored sessionRef-only Freshcodex pane waits for durable transcript hydration instead of rendering as a brand-new empty session.
+
+- [ ] **Step 6: Refactor and verify**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx \
+ test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx \
+ test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \
+ test/unit/client/lib/fresh-agent-ws.test.ts \
+ test/unit/client/store/freshAgentSlice.test.ts
+```
+
+Expected: all selected tests pass.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add \
+ src/components/fresh-agent/FreshAgentView.tsx \
+ src/lib/fresh-agent-ws.ts \
+ src/store/freshAgentSlice.ts \
+ src/store/persistControl.ts \
+ test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx \
+ test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx \
+ test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \
+ test/unit/client/lib/fresh-agent-ws.test.ts \
+ test/unit/client/store/freshAgentSlice.test.ts
+git commit -m "Harden fresh-agent recovery identity"
+```
+
+Omit `src/store/persistControl.ts` if it did not change.
+
+### Task 6: Repair Legacy AgentChat Harness And Context Menu Selectors
+
+**Files:**
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/e2e/agent-chat-capability-settings-flow.test.tsx`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/src/components/context-menu/ContextMenuProvider.tsx`
+- Modify: `/home/user/code/freshell/.worktrees/freshcodex-contract-foundation/test/unit/client/components/ContextMenuProvider.test.tsx`
+
+- [ ] **Step 1: Identify the failing/warning tests**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/e2e/agent-chat-capability-settings-flow.test.tsx \
+ test/unit/client/components/ContextMenuProvider.test.tsx
+```
+
+Expected before changes: the capability settings flow can render an empty DOM in full-suite conditions, and context menu selectors can emit React Redux stability warnings.
+
+- [ ] **Step 2: Repair the legacy AgentChatView harness**
+
+In `test/e2e/agent-chat-capability-settings-flow.test.tsx`, keep this file as legacy `AgentChatView` coverage. Do not convert it to `FreshAgentView`.
+
+Change `renderStoreBackedPane()` so the legacy component is mounted from an explicit raw `AgentChatPaneContent` prop and does not disappear when reducer effects canonicalize the backing store to `fresh-agent`.
+
+The harness should:
+
+- Seed `preloadedState.panes.layouts` with raw legacy `agent-chat` state directly, not via `initLayout()`.
+- Render `AgentChatView` through a test-only adapter that always keeps the component mounted.
+- Keep the prop live, not frozen: initialize from the raw legacy content, then update a local/ref-backed `AgentChatPaneContent` from store changes or reducer-dispatched pane updates by converting canonical `fresh-agent` fields back into the legacy prop shape expected by `AgentChatView`.
+- Use Redux store state for dependencies and dispatch effects.
+- If a test needs to inspect reducer effects, inspect `store.getState()` separately. Do not gate component rendering on `state.panes.layouts.t1.content.kind === 'agent-chat'` after the first reducer update.
+- Preserve settings/retry fidelity: after a test changes model/effort settings, the next Retry click must use the updated pane settings from the reducer-backed state rather than the initial raw prop.
+- Preserve the existing user-visible assertions around provider capability rows, unavailable models, settings buttons, and create failure messages.
+
+This fixes the empty-DOM symptom at the correct level: the legacy component test harness should not use a production canonicalization boundary as its render predicate.
+
+- [ ] **Step 3: Add context menu warning regression coverage**
+
+In `ContextMenuProvider.test.tsx`, spy on `console.warn`, render `ContextMenuProvider` with a store state where optional selector sources such as `connection.featureFlags` are absent, dispatch an unrelated action or rerender, and assert no React Redux selector instability warning appears.
+
+Use a narrow assertion:
+
+```ts
+expect(consoleWarnSpy.mock.calls.map((call) => String(call[0])).join('\n')).not.toContain('Selector')
+```
+
+- [ ] **Step 4: Run tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/e2e/agent-chat-capability-settings-flow.test.tsx \
+ test/unit/client/components/ContextMenuProvider.test.tsx
+```
+
+Also run the smallest known order-sensitive batch that previously exposed the empty-DOM symptom:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx \
+ test/unit/client/store/panesSlice.test.ts \
+ test/e2e/agent-chat-capability-settings-flow.test.tsx
+```
+
+Expected: harness/warning gaps are red or reproduce. If the standalone file is green but the combined batch fails, keep the combined batch as the red check for this task.
+
+- [ ] **Step 5: Implement stable selector fallbacks**
+
+In `ContextMenuProvider.tsx`, replace inline object/array fallbacks in `useAppSelector()` with module-level constants:
+
+```ts
+const EMPTY_FEATURE_FLAGS: Record = {}
+const EMPTY_ARRAY: readonly unknown[] = []
+```
+
+Use specific typed constants instead of allocating `{}` or `[]` inside selectors. Review nearby selectors and fix every inline fallback that can return a new reference on each selector call.
+
+- [ ] **Step 6: Refactor and verify**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/e2e/agent-chat-capability-settings-flow.test.tsx \
+ test/unit/client/components/ContextMenuProvider.test.tsx
+```
+
+Run the combined order-sensitive batch again:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx \
+ test/unit/client/store/panesSlice.test.ts \
+ test/e2e/agent-chat-capability-settings-flow.test.tsx
+```
+
+Expected: all selected tests pass and no selector instability warning is emitted.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add \
+ test/e2e/agent-chat-capability-settings-flow.test.tsx \
+ src/components/context-menu/ContextMenuProvider.tsx \
+ test/unit/client/components/ContextMenuProvider.test.tsx
+git commit -m "Stabilize legacy agent-chat harness"
+```
+
+### Task 7: Full Freshcodex/Fresh-Agent Regression Verification
+
+**Files:**
+- Modify only if failures expose real defects in already touched Freshcodex/fresh-agent code.
+
+- [ ] **Step 1: Run the complete known-failure subset**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/components/TabsView.test.tsx \
+ test/unit/client/components/TabsView.fresh-agent.test.tsx \
+ test/unit/client/components/TabContent.test.tsx \
+ test/unit/client/lib/tab-registry-snapshot.test.ts \
+ test/unit/client/lib/tab-fallback-identity.test.ts \
+ test/unit/client/lib/session-utils.test.ts \
+ test/unit/shared/session-contract.test.ts \
+ test/unit/client/lib/claude-session-id.test.ts \
+ test/unit/client/lib/session-type-utils.test.ts \
+ test/unit/client/ui-commands.test.ts \
+ test/unit/client/store/paneTreeValidation.test.ts \
+ test/unit/client/store/panesPersistence.test.ts \
+ test/unit/client/store/panesSlice.test.ts \
+ test/unit/client/store/tabsSlice.test.ts \
+ test/unit/client/store/crossTabSync.test.ts \
+ test/unit/client/store/tabRegistrySync.test.ts \
+ test/unit/client/store/selectors/sidebarSelectors.test.ts \
+ test/unit/client/store/settingsSlice.test.ts \
+ test/unit/client/store/settingsThunks.test.ts \
+ test/unit/client/store/storage-migration.test.ts \
+ test/unit/client/components/panes/PaneContainer.createContent.test.tsx \
+ test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx \
+ test/unit/client/components/agent-chat/AgentChatView.session-lost.test.tsx \
+ test/unit/client/components/agent-chat/AgentChatView.split-pane.test.tsx \
+ test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \
+ test/unit/client/lib/fresh-agent-ws.test.ts \
+ test/unit/client/lib/ws-client.test.ts \
+ test/unit/client/lib/pane-activity.test.ts \
+ test/unit/client/store/freshAgentSlice.test.ts \
+ test/e2e/agent-chat-capability-settings-flow.test.tsx \
+ test/unit/client/components/ContextMenuProvider.test.tsx
+```
+
+Run:
+
+```bash
+npm run test:server -- --run \
+ test/unit/server/config-store.fresh-agent-settings.test.ts \
+ test/unit/server/config-store.test.ts \
+ test/unit/server/agent-layout-schema.test.ts \
+ test/unit/server/agent-api/layout-store.fresh-agent.test.ts \
+ test/unit/server/mcp/freshell-tool.test.ts \
+ test/unit/server/terminal-registry.test.ts \
+ test/unit/server/fresh-agent/claude-adapter.test.ts \
+ test/unit/server/fresh-agent/runtime-manager.test.ts \
+ test/unit/server/ws-handler-fresh-agent.test.ts \
+ test/integration/server/settings-api.test.ts \
+ test/server/ws-tabs-registry.test.ts
+```
+
+Expected: all pass.
+
+- [ ] **Step 2: Run adjacent Freshcodex/fresh-agent regression suites**
+
+Run:
+
+```bash
+npm run test:vitest -- --run \
+ test/unit/client/lib/fresh-agent-ws.test.ts \
+ test/unit/client/sdk-message-handler.test.ts \
+ test/unit/client/ws-client-sdk.test.ts \
+ test/unit/client/components/fresh-agent/FreshAgentView.test.tsx \
+ test/unit/client/components/panes/PaneContainer.test.tsx
+```
+
+Run:
+
+```bash
+npm run test:server -- --run \
+ test/unit/server/fresh-agent/codex-adapter.test.ts \
+ test/unit/server/ws-handler-fresh-agent.test.ts \
+ test/unit/server/coding-cli/codex-app-server/client.test.ts \
+ test/unit/server/coding-cli/codex-app-server/runtime.test.ts
+```
+
+Expected: all pass. If any fail, fix the real defect or update obsolete expectations only when the new assertion is stronger and matches the fresh-agent contract.
+
+- [ ] **Step 3: Run browser Fresh Agent smoke**
+
+Run:
+
+```bash
+npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent.spec.ts
+npm run test:e2e:chromium -- test/e2e-browser/specs/fresh-agent-mobile.spec.ts
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 4: Run typecheck, lint, and diff hygiene**
+
+Run:
+
+```bash
+npm run typecheck
+npm run lint
+npm run build
+git diff --check
+```
+
+Expected: all pass.
+
+- [ ] **Step 5: Run the full coordinated suite**
+
+Run:
+
+```bash
+FRESHELL_TEST_SUMMARY="freshcodex full-suite blocker closure" npm run check
+```
+
+Expected: all pass. If failures are reported, classify them:
+
+- If they touch fresh-agent, freshcodex, legacy agent-chat compatibility, settings, pane persistence, storage migration, remote tab rehydration, or context menu warnings, continue fixing them in this plan.
+- If they are genuinely unrelated pre-existing failures, do not ignore them silently. Record exact tests and evidence, then stop with a clear blocker report.
+
+- [ ] **Step 6: Commit final verification fixes if needed**
+
+If Step 5 required additional code/test changes, commit them:
+
+```bash
+git add
+git commit -m "Close freshcodex full-suite regressions"
+```
+
+If no files changed after the earlier task commits, do not create an empty commit.
+
+### Task 8: Final Review Handoff
+
+**Files:**
+- No required file changes.
+
+- [ ] **Step 1: Inspect final diff**
+
+Run:
+
+```bash
+git status --short
+git diff --check
+git log --oneline --max-count=8
+git diff --stat origin/dev...HEAD
+```
+
+Expected: worktree clean except intentionally uncommitted user work if any appears during execution; no whitespace errors.
+
+- [ ] **Step 2: Summarize resolved issues**
+
+Prepare the implementation report with:
+
+- Current `HEAD`
+- Each known blocker and the file/test that now proves it fixed
+- Exact commands run and pass/fail status
+- Any residual failures or explicitly unrelated blockers, with paths and evidence
+
+- [ ] **Step 3: Do not land to dev/main automatically**
+
+Stop after implementation and verification. The conductor/user will decide whether to run another review loop, squash, or integrate into `dev`.
diff --git a/docs/superpowers/plans/2026-05-07-tabs-registry-compact-state.md b/docs/superpowers/plans/2026-05-07-tabs-registry-compact-state.md
new file mode 100644
index 000000000..488906e28
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-07-tabs-registry-compact-state.md
@@ -0,0 +1,1063 @@
+# Tabs Registry Compact State Plan
+
+Status: revised plan after second adversarial review, based on `dev` at `71c0542d`
+Worktree: `.worktrees/tabs-registry-device-snapshots-dev`
+Branch: `feature/tabs-registry-device-snapshots-dev`
+
+## Summary
+
+The root problem is not that the Tabs view has too many visible tabs. The root problem is that the server persists every tab sync event forever in `~/.freshell/tabs-registry/tabs-registry.jsonl`, then reads and splits that whole file on startup.
+
+Measured evidence from the production incident:
+
+- `tabs-registry.jsonl`: about 291 MiB, 438,708 lines, about 1,367 unique tab keys.
+- Standalone hydrate benchmark: about 1.14 GiB heap used during hydrate.
+- Restart logs: first production heap sample about 1.275 GiB.
+- Current code path:
+ - `server/tabs-registry/store.ts` reads the whole JSONL file in the constructor.
+ - `server/tabs-registry/store.ts` appends every accepted record.
+ - `server/ws-handler.ts` handles `tabs.sync.push` by upserting each record one by one.
+
+The correct fix is to remove the append-only event log from the active path and replace it with compact bounded state.
+
+The initial "one snapshot per device" design is not safe. A Freshell device identity is persisted in `localStorage`, so multiple browser windows on the same machine share the same `deviceId`. If the server treats a whole-device snapshot as authoritative, a stale hidden window can erase tabs from an active window. The revised design separates ownership:
+
+- Open tabs are replaceable per running browser instance, not per device.
+- Closed tabs are retained as merged tombstones, not deleted by omission.
+- Devices are grouped for display, but they are not the write-concurrency boundary.
+
+## Plain-English Model
+
+There are three identities:
+
+1. Device
+ - A durable local-machine identity stored in browser storage.
+ - Used for display grouping: "This machine", "Studio Mac", etc.
+ - Multiple browser windows can share it.
+
+2. Client instance
+ - A short-lived identity for one Freshell browser window.
+ - Stored in `sessionStorage`, so a reload keeps replacing the same window-owned snapshot.
+ - A separate browser window gets a separate client instance.
+ - This is the ownership boundary for open-tab snapshots.
+
+3. Tab key
+ - The stable key for one tab record.
+ - Used to dedupe competing open/closed records with last-write-wins semantics.
+
+The server stores compact state:
+
+- `openSnapshotsByClient`: latest open snapshot from each active browser instance.
+- `closedByTabKey`: latest closed tombstone for each recently closed tab.
+- `devicesById`: recent device metadata based on server receipt time, used only for device display and naming.
+
+On query, the server combines fresh open snapshots with retained closed tombstones for conflict resolution, filters closed winners to the requested retention window, and returns:
+
+- `localOpen`
+- `sameDeviceOpen`
+- `remoteOpen`
+- `closed`
+
+This keeps the small useful state while removing the unbounded historical journal.
+
+## Strategic Decisions
+
+### 1. Open State Is Authoritative Only Per Client Instance
+
+Rejected design:
+
+- `deviceId -> records[]`
+- A push replaces all records for that device.
+
+Reason rejected:
+
+- Multiple tabs/windows share `deviceId`.
+- Stale windows can send incomplete state.
+- Omitted records would become destructive.
+
+Revised design:
+
+- `(deviceId, clientInstanceId) -> open records[]`
+- A push replaces only that client instance's open snapshot.
+- Query aggregates all fresh client snapshots for a device.
+
+This gives us replacement semantics without pretending there is only one writer per device.
+
+### 2. Closed History Is a Tombstone Set
+
+Closed tabs cannot be controlled by omission. `localClosed` is currently memory-only in Redux. If a browser reloads and immediately sends a replacement snapshot without its earlier closed records, that must not erase server-side recently closed history.
+
+Revised rule:
+
+- Incoming closed records merge into `closedByTabKey`.
+- A closed record remains until it loses last-write-wins resolution or exceeds retention.
+- Omission from a later push does not delete a closed tombstone.
+- If an incoming open record wins last-write-wins against an existing closed tombstone for the same `tabKey`, the server deletes that tombstone in the same committed mutation as the open snapshot replacement.
+
+This preserves the behavior users see today: recently closed tabs survive browser reloads and server restarts within retention.
+It also prevents a reopened tab from leaving behind a stale closed card that could reappear after the new open snapshot expires.
+
+### 3. Default Closed Retention Is 30 Days
+
+The user-requested default is 30 days.
+
+Rules:
+
+- Default: 30 days.
+- Allowed setting range: 1 to 30 days.
+- Old browser preference values:
+ - missing -> 30
+ - 1..30 -> preserve
+ - greater than 30 -> clamp to 30
+- Server stores closed tombstones up to the max retention window. Query uses all retained tombstones for conflict resolution, then filters closed winners to the requested local setting.
+
+The old 90-day and 365-day UI options go away.
+
+### 4. Open Liveness, Device Freshness, And Closed Retention Are Separate
+
+Remote open tabs should fall away when a device has not been seen recently.
+
+Rules:
+
+- Open snapshot freshness uses server receipt time, not record `updatedAt`.
+- Default open snapshot TTL: 30 minutes.
+- Default device display TTL: 7 days.
+- Device display freshness is persisted in `devicesById`, not inferred from closed tombstones and not tied to open snapshot object refs.
+- A running idle browser should stay fresh via a low-frequency forced heartbeat/snapshot.
+- `updatedAt` remains the conflict-resolution timestamp for a tab record.
+
+This distinction matters:
+
+- `snapshotReceivedAt` answers "is this browser instance still around?"
+- `devicesById[deviceId].lastSeenAt` answers "should this remote device still appear in device management?"
+- `record.updatedAt` answers "which version of this tab record wins?"
+
+Open snapshots are meant to represent currently open tabs. If a browser window closes or crashes and cannot send a final retire message, its open snapshot should expire quickly. Device rows can remain visible longer for management and naming, but stale device metadata must not keep open tabs alive.
+
+### 5. Last-Write-Wins Must Still Resolve Open vs Closed
+
+Query must not simply append all fresh open records and all recent closed records.
+
+It must first combine candidate records by `tabKey` and select the newest record using event freshness, not heartbeat freshness:
+
+- higher `updatedAt` wins
+- if `updatedAt` ties, higher `revision` wins
+- if both tie, closed wins over open
+- if still tied, use a deterministic source-key tie breaker
+
+Then it returns winners by status.
+
+This prevents a stale-but-fresh hidden window from resurrecting a tab that another window closed. The hidden window may keep sending its old open snapshot, but the newer closed tombstone wins for that `tabKey`.
+
+Important correction from the current code: client record `revision` is module-local and can reset after reload. It must not be the primary ordering signal. Heartbeats also must not rewrite `record.updatedAt` for unchanged open tabs; `snapshotReceivedAt` is the heartbeat/liveness time and must stay separate from the tab event time.
+
+### 6. No Silent Fallbacks
+
+If compact state is corrupt, migration fails, or the registry is unavailable, return a clear server/client error. Do not silently serve an empty snapshot.
+
+Atomic writes may keep a manual recovery copy, but the server should not automatically load an older backup as a hidden fallback without explicit approval.
+
+## Proposed Server Data Shape
+
+Add compact persistence under `~/.freshell/tabs-registry/`.
+
+Preferred active layout:
+
+```text
+v1/
+ manifest.json
+ objects/
+ .json
+ tmp/
+```
+
+`manifest.json` is the only committed root. Object files are immutable JSON blobs referenced by the manifest:
+
+- one object per client open snapshot
+- one object for closed tombstones
+- one object for device metadata
+
+This keeps heartbeat writes small while making multi-file commits crash-safe. A mutation writes new object files first, then publishes one new manifest last. Startup loads only the objects referenced by the manifest and ignores orphaned objects from interrupted writes.
+
+In-memory shape:
+
+```ts
+type CompactTabsRegistryStateV1 = {
+ version: 1
+ savedAt: number
+ openSnapshotTtlMinutes: 30
+ deviceDisplayTtlDays: 7
+ maxClosedRetentionDays: 30
+ openSnapshotsByClient: Record
+ closedByTabKey: Record
+ devicesById: Record
+}
+
+type ClientOpenSnapshot = {
+ deviceId: string
+ deviceLabel: string
+ clientInstanceId: string
+ snapshotRevision: number
+ lastPushPayloadHash: string
+ snapshotReceivedAt: number
+ records: RegistryTabRecord[]
+}
+
+type RegistryDeviceEntry = {
+ deviceId: string
+ deviceLabel: string
+ lastSeenAt: number
+}
+
+type TabsRegistryManifestV1 = {
+ version: 1
+ manifestRevision: number
+ committedAt: number
+ openSnapshots: Record
+ closedTombstones: ObjectRef
+ devices: ObjectRef
+ settings: {
+ openSnapshotTtlMinutes: 30
+ deviceDisplayTtlDays: 7
+ maxClosedRetentionDays: 30
+ }
+}
+
+type ObjectRef = {
+ path: string
+ sha256: string
+ bytes: number
+}
+```
+
+Snapshot key:
+
+```ts
+const clientSnapshotKey = `${deviceId}:${clientInstanceId}`
+```
+
+The manifest key is `clientSnapshotKey`. On-disk object filenames must be derived from validated content hashes, not raw user-controlled strings.
+
+Important constraints:
+
+- `records` in `ClientOpenSnapshot` must contain open records only.
+- Incoming push payload may contain open and closed records.
+- Server separates them:
+ - open records replace that client's open snapshot
+ - closed records merge into `closedByTabKey`
+- Accepted pushes and retires update `devicesById[deviceId].lastSeenAt` from server receipt time.
+- A heartbeat that does not change tab records updates only `snapshotReceivedAt` and the manifest/object state for that client snapshot, not the individual records' `updatedAt`.
+- Incoming open records that beat existing closed tombstones remove those tombstones before the next manifest commit.
+
+Reason for per-client open objects:
+
+- Heartbeats should rewrite one small client snapshot, not a whole 5 MiB registry file.
+- Closed tombstones change much less often and can live in their own bounded file.
+- Device metadata is small and separate from tab history.
+- Startup still loads bounded compact state, but the active write path is no longer a whole-registry rewrite for every idle heartbeat.
+
+## Protocol Changes
+
+This is a protocol-breaking change.
+
+- Bump `WS_PROTOCOL_VERSION` from 4 to 5.
+- Updated browser bundles send protocol version 5 in `hello`.
+- Old loaded browser bundles using version 4 receive the existing protocol mismatch error path with clear reload-required copy.
+- Do not add a hidden compatibility adapter unless the user explicitly approves it.
+
+Current push:
+
+```ts
+{
+ type: 'tabs.sync.push',
+ deviceId,
+ deviceLabel,
+ records,
+}
+```
+
+Revised push:
+
+```ts
+{
+ type: 'tabs.sync.push',
+ deviceId,
+ deviceLabel,
+ clientInstanceId,
+ snapshotRevision,
+ records,
+}
+```
+
+Rules:
+
+- `clientInstanceId` is required.
+- `snapshotRevision` is monotonically increasing per client instance, including across reloads that keep the same `sessionStorage` client id.
+- Server rejects same-key snapshots with `snapshotRevision < current.snapshotRevision`.
+- If `snapshotRevision === current.snapshotRevision`, the server treats it as an idempotent retry only when the canonical hash of the validated incoming push matches the already committed `lastPushPayloadHash`. Same revision with different content is a clear duplicate-revision error.
+- `lastPushPayloadHash` excludes server receipt time and transport framing, but includes the validated device identity, client identity, revision, and open/closed records.
+- Server acks only after validation and atomic persistence succeed.
+- Ack should describe replacement semantics, not claim `updated: records.length`.
+
+Revised ack:
+
+```ts
+{
+ type: 'tabs.sync.ack',
+ accepted: true,
+ openRecords: number,
+ closedRecords: number,
+}
+```
+
+Best-effort client retire:
+
+```ts
+{
+ type: 'tabs.sync.client.retire',
+ deviceId,
+ clientInstanceId,
+ snapshotRevision,
+}
+```
+
+Rules:
+
+- Sent on explicit disconnect where possible and from `pagehide`/unload using a keepalive request or beacon if WebSocket delivery is not reliable.
+- Server deletes only that `(deviceId, clientInstanceId)` open snapshot.
+- Server ignores stale retire messages with `snapshotRevision < current.snapshotRevision`.
+- Retire is an optimization, not the correctness mechanism; the 30-minute open snapshot TTL remains required for crashes and missed unloads.
+
+Current query uses `rangeDays`. Revised query should use the semantic name:
+
+```ts
+{
+ type: 'tabs.sync.query',
+ requestId,
+ deviceId,
+ clientInstanceId,
+ closedTabRetentionDays,
+}
+```
+
+Rules:
+
+- `clientInstanceId` is required so the server can distinguish the current browser window from other windows on the same device.
+- `closedTabRetentionDays` is required from updated clients.
+- Schema clamps/rejects outside 1..30 at the WebSocket boundary.
+- Prefer rejection with a clear error for invalid client payloads.
+
+Revised snapshot data:
+
+```ts
+{
+ localOpen: RegistryTabRecord[]
+ sameDeviceOpen: RegistryTabRecord[]
+ remoteOpen: RegistryTabRecord[]
+ closed: RegistryTabRecord[]
+ devices: RegistryDeviceEntry[]
+}
+```
+
+Rules:
+
+- `localOpen` contains only records owned by the querying `(deviceId, clientInstanceId)`.
+- `sameDeviceOpen` contains records from other browser windows with the same `deviceId`.
+- `remoteOpen` contains records from other devices.
+- `devices` contains recent device metadata from `listDevices()`, filtered by the 7-day device display TTL and sorted by `lastSeenAt` descending.
+- Open records should include source metadata (`deviceId`, `deviceLabel`, `clientInstanceId`) so the UI cannot accidentally treat same-device-other-window records as jumpable local tabs.
+- The Tabs view may continue deriving currently open local tabs from Redux for jump actions, but server-returned same-device records must be treated like copy/pullable records, not like current-window jump targets.
+- The Settings Devices view reads device rows from `tabs.sync.snapshot.data.devices` and combines that with the current local device identity if the current device has not yet received a server ack.
+
+## Store API
+
+Replace the current `upsert(record)` API with batch operations that match ownership.
+
+```ts
+type ReplaceClientSnapshotInput = {
+ deviceId: string
+ deviceLabel: string
+ clientInstanceId: string
+ snapshotRevision: number
+ records: RegistryTabRecord[]
+}
+
+class TabsRegistryStore {
+ static async open(rootDir: string, options?: TabsRegistryStoreOptions): Promise
+
+ async replaceClientSnapshot(input: ReplaceClientSnapshotInput): Promise<{
+ accepted: boolean
+ openRecords: number
+ closedRecords: number
+ }>
+
+ async retireClientSnapshot(input: {
+ deviceId: string
+ clientInstanceId: string
+ snapshotRevision: number
+ }): Promise<{ accepted: boolean }>
+
+ async query(input: {
+ deviceId: string
+ clientInstanceId: string
+ closedTabRetentionDays: number
+ }): Promise
+
+ listDevices(): Array<{
+ deviceId: string
+ deviceLabel: string
+ lastSeenAt: number
+ }>
+
+ count(): number
+}
+```
+
+`open()` must be async because migration is streaming and must complete before the store is usable.
+`listDevices()` reads `devicesById`, prunes entries older than 7 days through queued maintenance, and never derives device rows from closed tombstones.
+
+## Persistence Rules
+
+Active persistence:
+
+- Write compact JSON object files plus one manifest commit pointer only.
+- No active append-only JSONL.
+- Persist open snapshots as per-client immutable objects under `v1/objects/`.
+- Persist closed tombstones and device metadata as separate immutable objects under `v1/objects/`.
+- Persist registry version/settings and object references in `v1/manifest.json`.
+- Write all mutations through a serialized write queue.
+- Atomic publish is manifest-last:
+ 1. write changed object blobs to `v1/tmp/`
+ 2. validate size/hash while writing
+ 3. fsync object files and containing directories where supported
+ 4. rename objects into `v1/objects/`
+ 5. write and fsync `manifest.json.tmp`
+ 6. atomically rename `manifest.json.tmp` to `manifest.json`
+ 7. fsync `v1/`
+- Startup loads exactly the object refs named by the latest valid manifest. It ignores orphaned objects and temp files.
+- Garbage collection of unreferenced objects is a separate maintenance step after a successful commit.
+- Use copy-on-write state mutation:
+ 1. clone or derive the next bounded in-memory state
+ 2. validate caps and schemas against the next state
+ 3. write changed objects and publish the manifest
+ 4. swap the live in-memory state only after the manifest commit succeeds
+- Validate compact state before accepting it into memory on startup.
+
+Caps:
+
+- Max records per push: 500.
+- Max open records per client snapshot: 500.
+- Max closed records accepted per push: 500.
+- Max panes per tab record: 20.
+- Max serialized push bytes: 1 MiB.
+- Max serialized client snapshot object bytes: 512 KiB.
+- Max serialized closed tombstone object bytes: 2 MiB.
+- Max serialized device metadata object bytes: 256 KiB.
+- Max compact state bytes after retention maintenance: 5 MiB.
+- Max client snapshot object refs: 200.
+- Max closed tombstones after retention pruning: 2,000 newest.
+- Max retained bytes during migration: 5 MiB, enforced as records are retained, not only after final compaction.
+
+If caps are exceeded:
+
+- Reject push.
+- Send clear WS error.
+- Do not truncate open snapshots silently.
+
+Closed tombstones may be pruned by age and by the max-tombstone cap, keeping newest records first. That is not a fallback; it is an explicit retention policy.
+
+Read/query behavior:
+
+- `query()` must be pure. It can compute filtered results from the current in-memory state, but it must not mutate state or write files.
+- Retention cleanup runs as a queued maintenance write, either after successful pushes/retires or on a low-frequency timer.
+- If maintenance cleanup fails, queries should still use snapshot isolation over the last successfully persisted in-memory state and expose/log the maintenance error clearly.
+
+Failure behavior:
+
+- A failed write before manifest publish must not alter live query results or startup-visible disk state.
+- Once manifest publish succeeds, the mutation is committed even if the process crashes before ack; retry handling should be idempotent for the already-committed snapshot revision from the same client.
+- A failed write must return a clear error to the WebSocket caller.
+- Tests must prove that injected object-write, object-rename, manifest-write, and manifest-rename failures leave memory and startup-visible disk on the previous committed state.
+- Tests must simulate crash/restart between object writes and manifest publish, and after manifest publish before ack.
+
+## Query Algorithm
+
+Inputs:
+
+- `deviceId`
+- `clientInstanceId`
+- `closedTabRetentionDays`
+- `now`
+
+Steps:
+
+1. Compute fresh client snapshots where `snapshotReceivedAt >= now - 30 minutes`.
+ - Do not mutate or persist from `query()`.
+ - Expired snapshots are excluded from this response and removed later by queued maintenance.
+2. Build conflict-resolution candidates:
+ - all open records from fresh client snapshots
+ - all closed tombstones retained by the server's max closed retention window, even if they are older than the caller's requested range
+3. Resolve candidates by `tabKey` using the event-time LWW helper:
+ - higher `updatedAt`
+ - then higher `revision`
+ - then closed over open
+ - then deterministic source-key tie breaker
+4. Apply the caller's requested `closedTabRetentionDays` only to closed winners.
+ - Example: if a tab was closed 10 days ago and the user selects 7 days, that closed winner is omitted from `closed`, but an older open snapshot for the same `tabKey` must still stay suppressed.
+ - This prevents shorter display retention from becoming a resurrection path.
+5. Split remaining winners:
+ - open + same `deviceId` and same `clientInstanceId` -> `localOpen`
+ - open + same `deviceId` and different `clientInstanceId` -> `sameDeviceOpen`
+ - open + different `deviceId` -> `remoteOpen`
+ - closed within requested retention -> `closed`
+6. Sort:
+ - open by `updatedAt` descending
+ - closed by `closedAt ?? updatedAt` descending
+
+Maintenance write, not query:
+
+1. Remove open snapshots older than the open snapshot TTL.
+2. Remove closed tombstones older than max closed retention.
+3. Remove device metadata older than the device display TTL.
+4. Enforce max snapshot-object-ref, tombstone, device, and byte caps.
+5. Persist cleanup through the serialized copy-on-write queue.
+
+This preserves the current mental model while avoiding historical storage.
+
+## Legacy Migration
+
+Legacy file:
+
+```text
+tabs-registry.jsonl
+```
+
+Migration must be one-time and streaming.
+
+Rules:
+
+1. If the compact `v1/manifest.json` exists, do not read legacy JSONL.
+2. If compact state does not exist and legacy JSONL exists, stream it line by line.
+3. Parse each valid record with the existing schema.
+4. Enforce migration safety caps while streaming:
+ - Max legacy line bytes: 256 KiB.
+ - Max valid unique tab keys retained during migration: 10,000.
+ - Max migrated open snapshots/devices: 200.
+ - Max serialized retained record bytes: 5 MiB, enforced as records are retained and replaced.
+ - Max migrated compact state after retention maintenance: 5 MiB.
+ - If a cap is exceeded, fail startup with a clear recovery error rather than continuing toward memory pressure.
+ - Large valid pane payloads count toward the retained-byte budget before they can accumulate in memory.
+5. Compute latest record per `tabKey` first using the same event-time LWW helper as query.
+6. Only after latest-per-tab resolution:
+ - closed latest records within 30 days become `closedByTabKey`
+ - open latest records are grouped into synthetic migrated snapshots by `deviceId`
+7. Synthetic migrated snapshots use:
+ - `clientInstanceId: 'legacy-migration'`
+ - `snapshotRevision: 1`
+ - `snapshotReceivedAt: migrationStartedAt`
+ - normal open snapshot TTL expiration
+8. `devicesById` entries are created from migrated latest records with `lastSeenAt: migrationStartedAt`, then expire under the normal 7-day device display TTL unless a real client reconnects.
+9. The migration-time receipt gives currently loaded clients a short grace period to reconnect and publish real per-window snapshots. It does not keep legacy opens alive for 7 days.
+10. Write compact object files and publish the manifest atomically.
+11. Rename legacy JSONL to an archived name only after compact manifest publish succeeds.
+
+Archive name example:
+
+```text
+tabs-registry.jsonl.migrated-20260507-143012
+```
+
+Critical ordering:
+
+- Do not prune closed records before latest-per-tab resolution.
+- Otherwise an old closed tombstone could be discarded and an older open record could be resurrected.
+- Do not use legacy record `updatedAt` as open snapshot liveness evidence. It is tab-event time, not proof that the browser is still around.
+
+Startup rule:
+
+- Store opening must be awaited before `WsHandler` is created.
+- If migration fails, startup should expose a clear registry error. Do not serve empty tab snapshots.
+
+## Client Changes
+
+### Client Instance Identity
+
+Add a per-window `clientInstanceId`.
+
+Rules:
+
+- Generated once per browser window.
+- Stored in `sessionStorage`, not `localStorage`.
+- Reused across reloads of the same browser window.
+- Included in every `tabs.sync.push`.
+- New browser window gets a different `clientInstanceId`.
+- Browser reload keeps the same `clientInstanceId` and replaces the same server snapshot.
+- If a duplicated tab copies `sessionStorage`, use `BroadcastChannel` or an equivalent local lease to detect an already-active identical `clientInstanceId` and mint a fresh one for the duplicate window.
+
+Candidate location:
+
+- `src/store/tabRegistrySync.ts` local module state, or
+- `tabRegistrySlice` state if UI/debug display needs it.
+
+Prefer module state unless tests become cleaner with Redux state.
+
+### Snapshot Revision
+
+Keep a monotonic `snapshotRevision` per client instance.
+
+Rules:
+
+- Store the last sent revision beside `clientInstanceId` in `sessionStorage`.
+- Increment when sending a push.
+- Continue from the stored value after reload.
+- Do not use tab record revision for snapshot ordering.
+- Server rejects stale snapshot revisions for the same `(deviceId, clientInstanceId)` and handles exact duplicate retries idempotently only when the payload matches.
+- Retire messages also carry a revision so an old unload cannot delete a newer reloaded snapshot.
+
+### Push Behavior
+
+Current behavior already builds records from:
+
+- current open tabs
+- `tabRegistry.localClosed`
+
+Revised behavior:
+
+- Keep sending open records for current tabs.
+- Send closed records from local memory while they exist and are within retention.
+- Do not rely on omission to delete server-side closed records.
+- Add a forced heartbeat/snapshot interval so idle active browsers refresh `snapshotReceivedAt`.
+- Real tab lifecycle changes still update the affected record's `updatedAt` before the next push.
+- Do not update per-record `updatedAt` for unchanged open tabs during heartbeat.
+- Send a best-effort `tabs.sync.client.retire` when the app/window is closing, while keeping TTL as the correctness backstop.
+
+Suggested intervals:
+
+- Existing push interval remains 5 seconds for changed lifecycle state.
+- Add forced heartbeat snapshot every 5 minutes while WebSocket is ready.
+- Heartbeat can send the same compact payload with a new `snapshotRevision`.
+
+### Closed Retention State
+
+Rename client state:
+
+- from `searchRangeDays`
+- to `closedTabRetentionDays`
+
+Default:
+
+- 30
+
+Browser preferences:
+
+- Load old `tabs.searchRangeDays` for migration.
+- Store new `tabs.closedTabRetentionDays`.
+- Write only the new key after any preferences save.
+- Clamp to 1..30.
+
+Cross-tab sync:
+
+- Update browser preference hydration to carry `closedTabRetentionDays`.
+- Preserve pending local writes as current code does for `searchRangeDays`.
+
+Tabs View:
+
+- Replace `Last 30 days / Last 90 days / Last year` with bounded options:
+ - `Last 1 day`
+ - `Last 7 days`
+ - `Last 14 days`
+ - `Last 30 days`
+- Default selected: `Last 30 days`.
+- Always send the chosen retention to the server query.
+
+Settings:
+
+- Add a setting row for closed tab history if we want it outside the Tabs view.
+- If only the Tabs view selector owns it, make the selector label clear enough.
+
+Device management:
+
+- Settings "Devices" should represent own device plus recent remote devices from `tabs.sync.snapshot.data.devices`.
+- `devicesById` is updated only from server receipt of accepted client messages, not from historical closed-record timestamps.
+- The server sends `devices` in every `tabs.sync.snapshot` response; no separate device endpoint is needed for this change.
+- Do not keep a remote device row alive solely because it has a closed tombstone retained for 30 days.
+- Keep a remote device row for up to 7 days after `lastSeenAt`, even after its open snapshots expire at 30 minutes.
+- Closed tab cards can still show the record's device label.
+
+This satisfies both:
+
+- remote open snapshots fall away after the freshness TTL
+- remote device rows fall away after the device display TTL
+- closed tab history can remain visible for 30 days
+
+## ServerInstanceId Rules
+
+The current server overwrites pushed records with the connected server's `serverInstanceId`.
+
+Keep that for live open snapshots from the current connection.
+
+Be careful with closed records:
+
+- If a closed record originated on the current server, preserving/overwriting to current server is fine.
+- `localClosed` must track the `ready.serverInstanceId` it belongs to.
+- If `ready.serverInstanceId` changes during the same app session, clear `localClosed` before the next push or namespace the in-memory closed map by `serverInstanceId` and send only the current namespace.
+- This prevents closed records from server A being re-authored as server B.
+- This plan avoids requiring client-side persisted closed history, so the immediate implementation can keep closed history memory-only and clear it on server switch.
+
+Legacy migration should preserve the `serverInstanceId` already stored in each legacy record.
+
+Reason:
+
+- TabsView uses `serverInstanceId` to decide whether live terminal handles can be reused.
+- Migration is restoring historical records, not re-authoring them through a live WebSocket connection.
+
+## Implementation Phases
+
+### Phase 1: Contract Tests For New Semantics
+
+Add failing tests before implementation.
+
+Server store unit tests:
+
+- Open snapshot replacement is scoped to `(deviceId, clientInstanceId)`.
+- Two client instances on the same device do not erase each other.
+- Query splits current-client `localOpen` from same-device-other-window `sameDeviceOpen`.
+- Reloaded same-window client reuses `clientInstanceId` and replaces the prior snapshot.
+- Stale snapshot revision for the same client is rejected.
+- Retry of an already committed same-client snapshot revision is idempotent after a lost ack.
+- Stale retire does not delete a newer snapshot.
+- Closed tombstone survives later open snapshot omission.
+- Newer closed tombstone suppresses stale open record.
+- Newer open record suppresses older closed tombstone.
+- Newer open record deletes the older closed tombstone on write, so the old closed card does not return after open TTL expiry or restart.
+- Closed tombstone older than requested retention still participates in LWW and can suppress an older open.
+- `updatedAt` beats reset-prone `revision`; a reload-then-close record with lower revision can beat an older open record.
+- Deterministic LWW ties choose closed over open and produce stable results.
+- Query uses server receipt time for snapshot freshness.
+- Query is pure and does not prune/write.
+- Open snapshot TTL is 30 minutes; device display TTL is 7 days.
+- Device metadata survives restart after open snapshot TTL but before 7-day device TTL.
+- Device metadata is not created or kept alive from closed tombstones alone.
+- Closed retention defaults to 30 and clamps/rejects outside 1..30.
+- Stale snapshots are excluded from query and pruned by queued maintenance.
+- Oversized pushes are rejected.
+- Oversized pane snapshots are rejected by byte budget, even when record counts are under caps.
+- Duplicate tab keys in one push are resolved or rejected explicitly.
+
+Integration/persistence tests:
+
+- Manifest-referenced per-client snapshot objects, closed tombstones, and devices rehydrate without JSONL.
+- Orphaned objects/temp files from interrupted writes are ignored on startup.
+- Maintenance garbage-collects unreferenced objects after successful commits and preserves every object referenced by the current manifest.
+- Crash/restart before manifest publish loads the previous committed state.
+- Crash/restart after manifest publish loads the new committed state.
+- Legacy JSONL migration computes latest per tab before pruning.
+- Old closed tombstone does not resurrect older open record.
+- Legacy migration uses migration-time liveness for open snapshots, not legacy `updatedAt`.
+- Migration caps fail with a clear recovery error before unbounded memory growth.
+- Migration retained-byte budget fails on large valid pane payloads before memory climbs.
+- Legacy file is archived only after compact write succeeds.
+- Startup awaits migration before WS can query.
+- Corrupt compact file produces a clear error, not empty data.
+- Injected object-write, object-rename, manifest-write, and manifest-rename failures leave memory and startup-visible disk at the previous committed state.
+- Concurrent query during queued push sees either old or new committed state, never partial state.
+
+WebSocket tests:
+
+- `WS_PROTOCOL_VERSION` is bumped from 4 to 5.
+- Version 4 clients receive a clear reload-required protocol mismatch.
+- `tabs.sync.push` requires `clientInstanceId` and `snapshotRevision`.
+- Ack reports accepted/open/closed counts.
+- `tabs.sync.client.retire` removes only that client snapshot and rejects/ignores stale revisions.
+- Query requires/uses `clientInstanceId` and `closedTabRetentionDays`.
+- Snapshot data includes `sameDeviceOpen`.
+- Snapshot data includes `devices` from `listDevices()`.
+- `closedTabRetentionDays > 30` is rejected.
+- Missing registry returns clear error for query, not empty snapshot.
+
+Client tests:
+
+- Sync includes `sessionStorage` `clientInstanceId` and increasing `snapshotRevision`.
+- Tabs sync query includes the same `clientInstanceId` used by push.
+- Reload preserves client id/revision; new window gets a distinct id.
+- Duplicated-tab `sessionStorage` collision is detected and rotated.
+- Forced heartbeat sends even when record fingerprint is unchanged.
+- Real tab lifecycle changes update the changed open record's `updatedAt`.
+- Heartbeat does not mutate tab record `updatedAt`.
+- Best-effort retire is sent on close/pagehide where the environment supports it.
+- Closed records older than retention are not sent.
+- `localClosed` clears or namespaces when `ready.serverInstanceId` changes.
+- Browser preference migration clamps old `searchRangeDays`.
+- Old/new preference mixed cross-tab sync converges on `closedTabRetentionDays`.
+- Cross-tab preference sync preserves pending local `closedTabRetentionDays`.
+- Tabs view no longer offers 90/365.
+- Tabs view does not offer jump actions for `sameDeviceOpen` records from other browser windows.
+- Settings devices are not kept alive solely by closed tombstones.
+- Settings devices read `tabs.sync.snapshot.data.devices` and are kept by `devicesById` until the 7-day display TTL.
+- `docs/index.html` mock is updated if it shows the old 90/365 retention options.
+
+### Phase 2: Compact Store Types And Helpers
+
+Modify:
+
+- `server/tabs-registry/types.ts`
+- `server/tabs-registry/store.ts`
+- `server/tabs-registry/device-store.ts`
+
+Add:
+
+- compact state schema
+- push input schema
+- event-time LWW helper shared by migration/query
+- pure filter helpers and queued maintenance prune helpers
+- size/cap validation
+- copy-on-write manifest commit helper
+- content-hash object writer
+- safe client snapshot manifest key validation helper
+- explicit tombstone retirement helper for incoming open records that win LWW
+- device metadata helper backed by `devicesById`
+
+Keep imports NodeNext-compatible with `.js` extensions.
+
+### Phase 3: Async Open And Migration
+
+Modify:
+
+- `server/tabs-registry/store.ts`
+- `server/index.ts`
+
+Replace synchronous constructor hydration with:
+
+```ts
+const tabsRegistryStore = await createTabsRegistryStore()
+```
+
+or:
+
+```ts
+const tabsRegistryStore = await TabsRegistryStore.open(...)
+```
+
+The factory must:
+
+1. ensure directory exists
+2. load compact `v1/` state if present
+3. otherwise migrate legacy JSONL if present
+4. otherwise initialize empty compact state
+
+No WebSocket handler should receive the store before this completes.
+
+### Phase 4: WebSocket Protocol Wiring
+
+Modify:
+
+- `server/ws-handler.ts`
+- `src/lib/ws-client.ts`
+- `shared/ws-protocol.ts`
+- related tests
+
+Replace looped `upsert` calls with one store call:
+
+```ts
+await tabsRegistryStore.replaceClientSnapshot(...)
+```
+
+Add protocol handling for:
+
+```ts
+await tabsRegistryStore.retireClientSnapshot(...)
+```
+
+Include `clientInstanceId` in query messages and return `sameDeviceOpen` plus `devices` in snapshots.
+Increment `WS_PROTOCOL_VERSION` to 5 and rely on the existing protocol mismatch path for old loaded clients, with clearer reload-required copy if needed.
+Do not send empty snapshots when the registry is unavailable. Send a clear error.
+
+### Phase 5: Client Sync And Preferences
+
+Modify:
+
+- `src/store/tabRegistrySync.ts`
+- `src/store/tabRegistrySlice.ts`
+- `src/lib/browser-preferences.ts`
+- `src/store/browserPreferencesPersistence.ts`
+- `src/store/crossTabSync.ts`
+- `src/store/selectors/tabsRegistrySelectors.ts`
+- `src/store/types.ts`
+- tests under `test/unit/client`
+
+Add:
+
+- `sessionStorage` `clientInstanceId`
+- `sessionStorage` `snapshotRevision`
+- duplicated-tab client id collision handling
+- query messages carrying `clientInstanceId`
+- heartbeat push
+- best-effort retire
+- retention rename/migration
+- retention-aware closed pruning
+- `localClosed` server-instance guard
+
+Keep `localClosed` memory-only unless implementation proves a client-side persistence gap remains. Server tombstones should handle reload survival.
+
+### Phase 6: Tabs View And Device UI
+
+Modify:
+
+- `src/components/TabsView.tsx`
+- `src/components/settings/SafetySettings.tsx`
+- `src/lib/known-devices.ts`
+- `docs/index.html` if it contains stale Tabs mock retention options
+- relevant unit/e2e tests
+
+Tabs View:
+
+- remove 90/365 options
+- default to 30
+- send `closedTabRetentionDays`
+- show or merge `sameDeviceOpen` separately from current-window local records
+- do not render same-device-other-window records with current-window jump actions
+
+Devices:
+
+- base device rows on `devicesById`/server device metadata
+- hydrate device rows from `tabs.sync.snapshot.data.devices`
+- keep aliases/dismissal behavior
+- do not use closed-only records to keep stale devices alive
+
+### Phase 7: Verification
+
+Focused commands:
+
+```bash
+npm run test:vitest -- test/unit/server/tabs-registry/store.test.ts --run
+npm run test:vitest -- --config vitest.server.config.ts test/integration/server/tabs-registry-store.persistence.test.ts --run
+npm run test:vitest -- --config vitest.server.config.ts test/server/ws-tabs-registry.test.ts --run
+npm run test:vitest -- test/unit/client/store/tabRegistrySync.test.ts test/unit/client/lib/browser-preferences.test.ts test/unit/client/store/browserPreferencesPersistence.test.ts test/unit/client/store/crossTabSync.test.ts --run
+npm run test:vitest -- test/unit/client/components/TabsView.test.tsx test/unit/client/components/SettingsView.behavior.test.tsx --run
+```
+
+Then coordinated broad checks:
+
+```bash
+FRESHELL_TEST_SUMMARY="tabs registry compact state" npm run test:status
+FRESHELL_TEST_SUMMARY="tabs registry compact state" npm run check
+```
+
+Manual perf verification:
+
+1. Build from the worktree.
+2. Copy a large legacy `tabs-registry.jsonl` fixture into a temp Freshell home.
+3. Start production server on a unique port with that temp home.
+4. Confirm startup does not read/split the full file into heap.
+5. Confirm compact files are small.
+6. Confirm legacy JSONL is archived.
+7. Confirm remote tabs and recently closed tabs still appear correctly.
+8. Benchmark heartbeat write latency near the configured caps and confirm it writes only the relevant client snapshot object, small device metadata object if needed, and manifest.
+9. Confirm unreferenced-object garbage collection bounds disk usage after repeated heartbeat commits and never removes manifest-referenced objects.
+
+Expected result:
+
+- No active `tabs-registry.jsonl` growth.
+- Compact state well under a few MiB in normal use.
+- Startup heap does not spike around 1 GiB from tabs registry hydrate.
+
+## Acceptance Criteria
+
+- Server no longer appends to active `tabs-registry.jsonl`.
+- Server startup does not `readFileSync` and `split` a large tabs-registry JSONL file.
+- Legacy migration streams line by line.
+- Compact state is versioned, schema-validated, and committed through a manifest pointer.
+- Startup ignores orphaned object/temp files and loads only manifest-referenced objects.
+- Open replacement is scoped to `(deviceId, clientInstanceId)`.
+- Same-device multiple browser windows cannot erase each other's open tabs.
+- Same-device other-window tabs are distinguishable from current-window local tabs.
+- Same-window reloads reuse the same `sessionStorage` client id and replace the prior snapshot.
+- `tabs.sync.snapshot.data.devices` is the client transport for recent device metadata.
+- Closed history survives browser reload and server restart for up to 30 days.
+- Retained closed tombstones participate in conflict resolution before requested-range filtering.
+- Newer reopened open records delete older closed tombstones so stale closed cards do not return after open TTL expiry.
+- Tab conflict ordering lets newer `updatedAt` beat stale higher `revision`.
+- Stale hidden-window open records cannot resurrect newer closed tabs.
+- Remote open snapshots fall away after 30 minutes without server receipt.
+- Remote device rows are backed by `devicesById` and fall away after 7 days without server receipt.
+- Idle active browser instances remain fresh through heartbeat.
+- Real tab lifecycle changes advance the affected record's `updatedAt`.
+- Heartbeat updates snapshot liveness without changing per-record `updatedAt`.
+- Best-effort retire removes only the calling client snapshot and stale retires cannot delete newer snapshots.
+- Retention default is 30 days.
+- Retention setting is clamped/rejected to 1..30.
+- 90-day and 365-day closed history options are gone.
+- Query is pure; pruning happens through queued maintenance writes.
+- Failed atomic writes do not change live query results.
+- Crash/restart before manifest publish loads previous state; crash/restart after manifest publish loads new state.
+- Unreferenced-object garbage collection keeps disk bounded without deleting manifest-referenced objects.
+- Legacy migration has explicit memory/size caps and uses migration-time liveness for synthetic open snapshots.
+- Query failures are explicit; no empty-snapshot fallback.
+- Oversized/malformed pushes are rejected clearly.
+- Large pane snapshots cannot bypass byte caps.
+- WebSocket protocol version is bumped and old loaded clients get a clear reload-required error.
+- Existing Tabs behavior still works:
+ - jump to local tab
+ - pull remote tab copy
+ - reopen closed tab
+ - preserve pane snapshots
+ - preserve serverInstanceId behavior for live handles
+ - preserve device aliases and dismissal
+- Focused tests pass.
+- Coordinated `npm run check` passes before merge.
+
+## Risks And Mitigations
+
+Risk: client instance snapshots accumulate after browser crashes.
+
+Mitigation:
+
+- 30-minute open snapshot TTL.
+- Query excludes stale snapshots without mutating state.
+- Queued maintenance prunes stale snapshot object refs and later garbage-collects unreferenced objects.
+
+Risk: heartbeat creates needless writes.
+
+Mitigation:
+
+- Heartbeat interval is low frequency.
+- Heartbeat writes one small per-client open snapshot object, the small device metadata object if `lastSeenAt` changes, and a manifest.
+- Heartbeat does not rewrite the closed tombstone object unless closed records changed or a reopened open record removes an old tombstone.
+- Writes remain bounded and do not append history.
+
+Risk: changing protocol breaks stale browser bundles.
+
+Mitigation:
+
+- Bump `WS_PROTOCOL_VERSION` to 5.
+- Let version 4 clients fail the handshake through the existing protocol mismatch path with reload-required copy.
+- Reject invalid/missing `clientInstanceId` on version 5 messages with a clear error.
+- Do not maintain a long-term compatibility fallback unless explicitly approved.
+
+Risk: compact state still grows from bad clients.
+
+Mitigation:
+
+- hard caps on push size, records, panes, snapshot object refs, tombstones, and compact state size
+- per-object byte caps plus a global live compact-state byte cap before every manifest commit
+- migration retained-byte budget enforced while streaming
+- clear errors on rejection
+
+Risk: migration shows stale historical open tabs or drops useful current open tabs.
+
+Mitigation:
+
+- migrated open snapshots get a short migration-time grace period
+- current active browsers push immediately after reconnect/startup and replace synthetic snapshots
+- stale historical opens fall away after the open snapshot TTL
+- legacy `updatedAt` is never used as browser liveness evidence
+
+## Implementation Handoff
+
+Implement this plan in the dev-based worktree:
+
+```text
+/home/user/code/freshell/.worktrees/tabs-registry-device-snapshots-dev
+```
+
+Use Red-Green-Refactor. Keep changes committed in the worktree after each coherent phase.
diff --git a/docs/superpowers/plans/2026-05-14-codex-turn-completion-durability.md b/docs/superpowers/plans/2026-05-14-codex-turn-completion-durability.md
new file mode 100644
index 000000000..05428a2f4
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-14-codex-turn-completion-durability.md
@@ -0,0 +1,497 @@
+# Codex Turn-Completion Durability Implementation Plan
+
+> **For Claude:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make Codex terminal restore identity mandatory, observable, and promoted only by deterministic evidence. A fresh Codex pane must not accept user input until Freshell has persisted the provider-reported candidate thread id and rollout path, must not treat that candidate as durable, and must promote to `sessionRef` only after the exact rollout file proves the same Codex root TUI `ThreadId`.
+
+**Architecture:** Add a Freshell-owned websocket proxy between the visible Codex TUI and the Codex app-server sidecar. The proxy observes `thread/start` responses and `thread/started` notifications for candidate capture, observes `turn/completed` for the mandatory proof-check boundary, and forwards traffic normally. Terminal input is gated only until the candidate is atomically written to the Freshell server-side durability store. Durable promotion is an event-driven one-shot proof read of the exact rollout path, not a polling loop.
+
+**Tech Stack:** Node.js/TypeScript ESM, `ws`, `node-pty`, Express WebSocket protocol, React 18, Redux Toolkit, Zod, Vitest, Testing Library, superwstest, Freshell orchestration.
+
+---
+
+## Research Contract
+
+- Codex durable restore identity is not a title, cwd, launch time, shell snapshot, or the bare bootstrap id. It is the root TUI `ThreadId` after the exact provider-reported `.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` begins with matching `session_meta` (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:9`, `:15-19`, `:492-504`).
+- Fresh `codex --remote ` creates a thread before user work. Freshell must capture that candidate before letting user input through, persist it as candidate-only state, and promote only after rollout proof (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:16`, `:99-127`, `:561-570`).
+- Pre-creating a thread through the app-server and launching the TUI with `codex resume ` before the rollout exists fails with `no rollout found for thread id`; this implementation must remove that launch pattern for fresh Codex panes (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:19`, `:369-412`, `:474-480`, `:550`).
+- The Codex source proves the TUI receives `thread/start` response before the `thread/started` notification, both with the candidate id/path, and it does not read terminal input until after the thread is started. Freshell still needs a PTY-side input gate because terminal bytes can queue outside Codex before Freshell persists the candidate (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:440-446`, `:551-553`).
+- `turn/completed` is the required proof-check boundary, not proof. On that event for the candidate thread, Freshell must run exactly one direct proof read of the stored rollout path. It promotes only if the file is regular, readable JSONL whose first record is `type == "session_meta"` and `payload.id == candidateThreadId` (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:17`, `:124-134`, `:456`, `:466-468`, `:520-526`, `:555`).
+- `fs/watch` is only a wake-up source. A missed filesystem event was observed in the probe, so it cannot be the only promotion path. It also cannot replace the direct proof read (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:325-331`, `:482-490`, `:554`, `:586-588`).
+- After `turn/completed`, proof failure is not an acceptable green, grey, or silently live-only steady state. It is `durability_unproven_after_completion` until a deterministic one-shot repair trigger succeeds or the pane becomes non-restorable (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:506-518`, `:520-538`, `:584-590`).
+- Reopen of captured-but-unproven Codex state proof-reads first. If proof succeeds, promote and resume. If proof fails and a live terminal is attachable, attach live while keeping the degraded state visible. If no live terminal is attachable, fresh-create a Codex pane and show that the old captured Codex state could not be proven restorable. Do not try `codex resume ` before proof (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:540-544`).
+
+## Current Gap In This Worktree
+
+- `server/coding-cli/codex-app-server/launch-planner.ts` currently calls `runtime.startThread()` for fresh Codex launches and returns that id as the launch `sessionId`; `server/ws-handler.ts` then passes it as `resumeSessionId`, so `buildSpawnSpec()` launches the visible TUI with `codex --remote resume `. This is exactly the pre-durable resume pattern the research rejects.
+- `server/coding-cli/codex-app-server/protocol.ts`, `client.ts`, and `runtime.ts` handle `thread/started`, lifecycle loss, and `fs/changed`, but do not expose `turn/completed`.
+- `server/terminal-registry.ts` writes PTY input immediately once the terminal exists. It has no state that can block input until candidate persistence is complete.
+- `src/components/TerminalView.tsx` persists canonical identity only after `terminal.session.associated`; it has no candidate-only Codex durability state and no acknowledgement path back to the server.
+- The sidebar and persisted tab state can represent `sessionRef` or legacy `resumeSessionId`, but not a non-canonical Codex candidate. This is why an unpromoted live Codex pane can appear as a generic grey terminal and then split into a second entry when canonical metadata appears later.
+- The research document also mentions `durable-rollout-tracker.ts` and a pre-durable stability timer that promotes to `running_live_only` in `/home/user/code/freshell/.worktrees/dev` (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:576-582`). That timer-based promotion is not present in this `origin/main`-based implementation worktree. A `running_live_only` type string still exists in recovery policy code, so this plan avoids removing the enum value unless implementation proves it is dead.
+
+## State Model To Implement
+
+- `identity_pending`: fresh Codex TUI has been spawned through the proxy, but no candidate has been durably saved by Freshell. PTY output, resize, and narrow terminal-control replies required for TUI startup pass through. User-originating input is dropped, not buffered or replayed, and the server emits `terminal.input.blocked` for observability.
+- `captured_pre_turn`: Freshell has atomically persisted `{ provider: "codex", candidateThreadId, rolloutPath, source, capturedAt }` in the server-side durability store. Input is allowed. This is not restorable/durable. Client localStorage acknowledgement may arrive later and is idempotent.
+- `turn_in_progress_unproven`: the proxy observed `turn/start` or equivalent user-turn activity for the candidate. Live use continues. This is not restorable/durable.
+- `proof_checking`: `turn/completed` or a deterministic repair trigger fired and one exact proof read is running.
+- `durable`: the proof succeeded. Freshell sends the existing `terminal.session.associated` message with `sessionRef.provider == "codex"` and `sessionRef.sessionId == candidateThreadId`; normal resume uses that id.
+- `durable_resuming`: a terminal launched from an existing canonical Codex `sessionRef`. It starts from already-proven durable identity and does not return to candidate capture. Normal launch trusts the saved canonical `sessionRef`; if durable proof metadata with `rolloutPath` is also available, repair/list/open paths may proof-read it before resume. If no proof metadata exists, Freshell must not invent one from cwd/time/title.
+- `durability_unproven_after_completion`: proof failed after completion. Live terminal access remains possible if the PTY is alive, but sidebar/pane state must be degraded, not green/normal.
+- `non_restorable`: no durable proof exists and no live terminal is attachable. Reopening fresh-creates Codex and keeps a local restore-error explanation.
+
+## Implementation Tasks
+
+### 1. Add Codex Durability Types And Proof Reader
+
+- [ ] Create `shared/codex-durability.ts`.
+ - [ ] Export `CodexDurabilityStateName` with the exact state names above.
+ - [ ] Export `CodexCandidateIdentity` with `provider: "codex"`, `candidateThreadId`, `rolloutPath`, `source`, `capturedAt`, and optional `cliVersion`.
+ - [ ] Export `CodexDurabilityRef` with `schemaVersion: 1`, `state`, `candidate`, optional `turnCompletedAt`, optional `lastProofFailure`, optional `durableThreadId`, and optional `nonRestorableReason`.
+ - [ ] Add Zod schemas so persisted client state and websocket payloads are validated instead of using ad hoc objects.
+ - [ ] Keep names explicit: use `candidateThreadId`, `rolloutProofId`, and `durableThreadId`, matching the research terminology (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:492-504`).
+- [ ] Create `server/coding-cli/codex-app-server/durability-proof.ts`.
+ - [ ] Export `proofCodexRollout({ rolloutPath, candidateThreadId, fsImpl? })`.
+ - [ ] Require `rolloutPath` to be absolute and non-empty.
+ - [ ] `stat()` the exact path and require a regular file.
+ - [ ] Read only enough data to parse the first JSONL record; do not scan globs or nearby files.
+ - [ ] Require first record JSON to have `type === "session_meta"` and `payload.id === candidateThreadId`.
+ - [ ] Return a typed success/failure result with a machine-readable reason: `missing`, `not_regular_file`, `empty`, `malformed_json`, `wrong_record_type`, `missing_payload_id`, `mismatched_thread_id`, `read_error`.
+ - [ ] Do not check cwd, date directories, shell snapshots, or filename proximity. The proof is the exact path plus first-record identity (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:456`, `:520-526`).
+- [ ] Create `server/coding-cli/codex-app-server/durability-store.ts`.
+ - [ ] Atomically persist candidate and proof-state records under a Freshell-owned directory, defaulting to `~/.freshell/codex-durability/`.
+ - [ ] Key records by `terminalId`, and include `tabId`, `paneId`, `candidateThreadId`, `rolloutPath`, `state`, `capturedAt`, and `serverInstanceId`.
+ - [ ] Treat this server-side write as the authoritative gate-release persistence. Client localStorage persistence is still required for refresh/reopen UX, but it is not what releases PTY input.
+ - [ ] Make duplicate writes idempotent when `candidateThreadId` and `rolloutPath` match; reject mismatched rewrites for the same terminal.
+ - [ ] Delete records when the terminal is killed and either durable `sessionRef` was promoted or the candidate is intentionally abandoned.
+- [ ] Add `test/unit/server/coding-cli/codex-app-server/durability-proof.test.ts`.
+ - [ ] Success: first line is matching `session_meta`.
+ - [ ] Failure: missing path, directory, empty file, malformed first line, first line not `session_meta`, missing `payload.id`, mismatched id.
+ - [ ] Regression: a later matching line must not succeed if the first record is wrong.
+- [ ] Add `test/unit/server/coding-cli/codex-app-server/durability-store.test.ts`.
+ - [ ] Atomic write/read round trip.
+ - [ ] Duplicate matching candidate is idempotent.
+ - [ ] Mismatched candidate for the same terminal is rejected.
+ - [ ] Missing older persisted layouts with no Codex durability data read cleanly and never synthesize a candidate from `resumeSessionId`.
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/durability-proof.test.ts test/unit/server/coding-cli/codex-app-server/durability-store.test.ts --run
+```
+
+Commit:
+
+```bash
+git add shared/codex-durability.ts server/coding-cli/codex-app-server/durability-proof.ts server/coding-cli/codex-app-server/durability-store.ts test/unit/server/coding-cli/codex-app-server/durability-proof.test.ts test/unit/server/coding-cli/codex-app-server/durability-store.test.ts
+git commit -m "Add Codex rollout durability proof reader"
+```
+
+### 2. Add App-Server Event Schemas For Turns
+
+- [ ] Update `server/coding-cli/codex-app-server/protocol.ts`.
+ - [ ] Add `CodexTurnStartedNotificationSchema` if the protocol surface is present in the observed app-server traffic or fake server tests need it.
+ - [ ] Add `CodexTurnCompletedNotificationSchema` for `method: "turn/completed"` with `params.threadId` and pass-through turn fields.
+ - [ ] Export inferred types.
+ - [ ] Keep lifecycle parsing separate from turn parsing so lifecycle loss recovery behavior remains unchanged.
+- [ ] Update `server/coding-cli/codex-app-server/client.ts`.
+ - [ ] Add `onTurnStarted` and `onTurnCompleted` handlers.
+ - [ ] Dispatch turn events from notification parsing before generic handling.
+ - [ ] Preserve existing `thread/started`, lifecycle loss, disconnect, and `fs/changed` behavior.
+- [ ] Update `server/coding-cli/codex-app-server/runtime.ts`.
+ - [ ] Re-emit client turn events with `onTurnStarted` and `onTurnCompleted`.
+- [ ] Add/update unit tests in:
+ - [ ] `test/unit/server/coding-cli/codex-app-server/client.test.ts`
+ - [ ] `test/unit/server/coding-cli/codex-app-server/runtime.test.ts`
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts --run
+```
+
+Commit:
+
+```bash
+git add server/coding-cli/codex-app-server/protocol.ts server/coding-cli/codex-app-server/client.ts server/coding-cli/codex-app-server/runtime.ts test/unit/server/coding-cli/codex-app-server/client.test.ts test/unit/server/coding-cli/codex-app-server/runtime.test.ts
+git commit -m "Observe Codex turn lifecycle notifications"
+```
+
+### 3. Add A Freshell-Owned Codex Remote Websocket Proxy
+
+- [ ] Create `server/coding-cli/codex-app-server/remote-proxy.ts`.
+ - [ ] Allocate a loopback websocket endpoint for the visible TUI.
+ - [ ] Forward TUI websocket traffic to the real app-server sidecar endpoint.
+ - [ ] Observe client-to-server JSON-RPC requests and remember request id to method for `thread/start`, `thread/resume`, `turn/start`, and any turn methods present in fixtures.
+ - [ ] Parse client-to-server `turn/start` as a generic JSON-RPC envelope by method name; do not require a full request-params Zod schema unless the implementation needs fields beyond the method and id.
+ - [ ] Observe server-to-client JSON-RPC responses. For a `thread/start` response, parse the `thread` payload and emit candidate `{ threadId, rolloutPath, source: "thread_start_response" }`.
+ - [ ] Observe server-to-client notifications. Emit candidate from `thread/started` if no response candidate has been persisted yet; emit `turn_started`, `turn_completed`, `fs_changed`, lifecycle loss, and connection loss events.
+ - [ ] If a `turn/start` request arrives before the server-side candidate persistence write completes, hold that request until the write completes. If the write fails, the terminal is shutting down, or `5_000ms` elapse without a persisted candidate, fail the held request with JSON-RPC error code `-32000` and message `Freshell could not persist Codex restore identity before accepting user input.` Transition the terminal to `non_restorable`, stop that fresh TUI, and fresh-create only if the user explicitly retries.
+ - [ ] Also start a candidate-capture deadline when the visible TUI is spawned, independent of user input. If no candidate has been persisted within `45_000ms`, transition the terminal to `non_restorable`, emit `terminal.codex.durability.updated`, send `terminal.input.blocked` with terminal reason `codex_identity_capture_timeout` for any later input, and stop the fresh TUI/sidecar. This is a bounded startup deadline, not polling; live production validation showed a 10 second deadline can kill a valid cold Codex launch before identity capture completes.
+ - [ ] Apply the candidate-capture deadline and `turn/start` hold only to fresh Codex launches that do not yet have a canonical durable `sessionRef`. Durable resume launches still pass through the proxy for turn/lifecycle observation, but the proxy must start with candidate persistence disabled, must not arm the fresh-candidate timeout, and must not hold `turn/start`.
+ - [ ] On held `turn/start` failure or candidate-capture timeout, return the JSON-RPC error if a request is pending, then close the proxy websocket and kill the PTY process for that failed fresh TUI. Do not leave Codex running against a dead or untrusted proxy, and do not replay held user bytes into a replacement session.
+ - [ ] Do not periodically query the app-server or filesystem from the proxy.
+ - [ ] Include structured logs for proxy start, candidate observed, held turn request, released turn request, turn completed, proof trigger, and proxy close/error.
+- [ ] Ensure readiness ordering is explicit.
+ - [ ] `CodexRemoteProxy.start()` must resolve only after the local proxy websocket server is listening and all local event handlers are installed.
+ - [ ] `launch-planner.ts` must await proxy readiness before returning the plan that will spawn the visible TUI.
+ - [ ] Freshell-owned upstream observer/listener setup must complete before `buildSpawnSpec()` can hand the proxy URL to Codex.
+- [ ] Add `test/unit/server/coding-cli/codex-app-server/remote-proxy.test.ts`.
+ - [ ] Fresh TUI traffic through the proxy captures candidate from `thread/start` response.
+ - [ ] Candidate can also be captured from `thread/started` notification.
+ - [ ] `turn/start` before server-side candidate persistence is held, then forwarded after the store write completes.
+ - [ ] `turn/start` times out and fails cleanly if candidate persistence never completes.
+ - [ ] Candidate-capture timeout fires even when the user never types and no `turn/start` request arrives.
+ - [ ] Durable resume proxy traffic forwards `turn/start` immediately and does not emit candidate-capture timeout when no fresh candidate is expected.
+ - [ ] Timeout/failure closes the proxy websocket and terminates the failed TUI rather than leaving it running.
+ - [ ] `turn/completed` is emitted with the matching thread id.
+ - [ ] Proxy close/error emits a deterministic repair trigger and shuts down without leaking sockets.
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/remote-proxy.test.ts --run
+```
+
+Commit:
+
+```bash
+git add server/coding-cli/codex-app-server/remote-proxy.ts test/unit/server/coding-cli/codex-app-server/remote-proxy.test.ts
+git commit -m "Proxy Codex remote traffic for deterministic identity capture"
+```
+
+### 4. Replace Fresh Codex Pre-Create/Resume With Fresh Remote Launch
+
+- [ ] Update `server/coding-cli/codex-app-server/launch-planner.ts`.
+ - [ ] For fresh Codex launch, call `runtime.ensureReady()` instead of `runtime.startThread()`.
+ - [ ] Start and await a `CodexRemoteProxy` before returning the plan.
+ - [ ] Return a launch plan whose `sessionId` is undefined for fresh launches. The fresh visible command must be `codex --remote ` with no `resume `.
+ - [ ] For durable resume launches, keep `sessionId == resumeSessionId`, route the TUI through the proxy, and keep readiness behavior for the durable id.
+ - [ ] Durable resume launches start in `durable_resuming`/`durable`; they construct the proxy with fresh-candidate persistence disabled, do not arm candidate-capture timeout, and do not re-promote on `thread/started`.
+ - [ ] Sidecar shutdown must close the proxy and the runtime sidecar.
+ - [ ] Sidecar adoption must still update sidecar ownership metadata with terminal id and generation.
+ - [ ] Expose proxy events on the sidecar: `onCandidate`, `markCandidatePersisted`, `onTurnStarted`, `onTurnCompleted`, `onRepairTrigger`, `onLifecycleLoss`, and `onFsChanged`.
+- [ ] Update `server/ws-handler.ts`.
+ - [ ] Do not overwrite `effectiveResumeSessionId` with a fresh Codex plan id when the launch is fresh.
+ - [ ] Continue passing `effectiveResumeSessionId` only for proven durable resumes.
+ - [ ] Record lifecycle events distinguishing `codex_candidate_pending`, `codex_candidate_captured`, and `codex_durable_session_observed`.
+ - [ ] Remove the existing adoption-time `codex_durable_session_observed` emission for fresh Codex. That event must be emitted only after rollout proof success; durable resume can log `codex_durable_resume_started`.
+- [ ] Update `server/terminal-registry.ts` recovery spawning.
+ - [ ] Durable recovery still spawns `resume `.
+ - [ ] Fresh launch never spawns `resume `.
+- [ ] Update `test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts`.
+ - [ ] Fresh `planCreate({ cwd })` must not call `startThread`.
+ - [ ] Fresh launch plan remote wsUrl is the proxy wsUrl.
+ - [ ] Fresh plan has no durable `sessionId`.
+ - [ ] Durable `planCreate({ resumeSessionId })` uses resume id and readiness as before.
+- [ ] Update `test/unit/server/ws-handler-sdk.test.ts` or add a focused server WS test.
+ - [ ] Fresh `terminal.create` for Codex does not return `effectiveResumeSessionId`.
+ - [ ] Durable `terminal.create` for Codex still returns the durable resume id.
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts test/unit/server/ws-handler-sdk.test.ts --run
+```
+
+Commit:
+
+```bash
+git add server/coding-cli/codex-app-server/launch-planner.ts server/ws-handler.ts server/terminal-registry.ts test/unit/server/coding-cli/codex-app-server/launch-planner.test.ts test/unit/server/ws-handler-sdk.test.ts
+git commit -m "Launch fresh Codex without pre-durable resume"
+```
+
+### 5. Persist Candidate State Before Releasing Input
+
+- [ ] Update `shared/ws-protocol.ts`.
+ - [ ] Add server-to-client `terminal.codex.durability.updated` payload carrying `terminalId` and `CodexDurabilityRef`.
+ - [ ] Add client-to-server `terminal.codex.candidate.persisted` with `terminalId`, `candidateThreadId`, `rolloutPath`, and `capturedAt`.
+ - [ ] Register `terminal.codex.candidate.persisted` in every server-side websocket validator, including the dynamic schema built by `server/ws-handler.ts`, so browser acknowledgements cannot be rejected as `INVALID_MESSAGE`.
+ - [ ] Add optional `codexDurability` to `terminal.create` so persisted captured-but-unproven panes can be repaired or fresh-created deterministically on reopen.
+ - [ ] Add server-to-client `terminal.input.blocked` with `reason: "codex_identity_pending"` for diagnostic UI/logging when PTY input arrives during the narrow gate. Do not send `INVALID_TERMINAL_ID` for gated input.
+- [ ] Update `src/store/paneTypes.ts`, `src/store/types.ts`, `src/store/persistedState.ts`, `src/store/storage-migration.ts`, `src/store/panesSlice.ts`, and `src/store/tabsSlice.ts`.
+ - [ ] Add optional `codexDurability?: CodexDurabilityRef` to terminal pane content and tab metadata.
+ - [ ] Persist it in localStorage.
+ - [ ] Preserve it across tab/pane merge logic.
+ - [ ] When canonical `sessionRef.provider == "codex"` is set for the same thread id, retain durable proof metadata if available with `state: "durable"` and clear only degraded/pending warnings. Do not keep a stale non-canonical pending state next to a matching canonical `sessionRef`.
+ - [ ] Do not backfill it from `resumeSessionId`, cwd, title, or time.
+ - [ ] Add a named migration test: older persisted layouts with no `codexDurability` field must load cleanly and must not synthesize candidate state from `resumeSessionId`.
+- [ ] Update `src/components/TerminalView.tsx`.
+ - [ ] On `terminal.codex.durability.updated`, update pane content with the candidate/degraded state and flush persisted layout immediately.
+ - [ ] After flush succeeds, send `terminal.codex.candidate.persisted` for candidate states. This acknowledgement is idempotent and observational; it must not be required for server-side gate release because the server-side durability store is authoritative.
+ - [ ] On `terminal.session.associated`, clear matching `codexDurability` and set the canonical `sessionRef` through the existing durable path.
+ - [ ] Include persisted `codexDurability` in `terminal.create` when there is no canonical Codex `sessionRef`.
+ - [ ] Do not send a candidate thread id as `resumeSessionId`.
+ - [ ] On `terminal.input.blocked`, log the blocked reason and show a throttled terminal-local message so browser-originated input is not silently dropped during the identity gate.
+- [ ] Update `server/terminal-registry.ts`.
+ - [ ] Extend `TerminalRecord` with `codexDurability` and `codexInputGate`.
+ - [ ] When proxy emits a candidate, write it to the server-side durability store first. After that atomic write succeeds, transition to `captured_pre_turn`, emit `terminal.codex.durability.updated`, call sidecar/proxy `markCandidatePersisted()`, and release held `turn/start` requests.
+ - [ ] If the candidate store write fails, do not release PTY input or held `turn/start`. Mark the terminal `non_restorable`, log the failure, and keep user work from entering an untracked Codex session.
+ - [ ] Change `input()` to return `TerminalInputResult`:
+ - [ ] `{ status: "written" }`
+ - [ ] `{ status: "blocked_codex_identity_pending"; terminalId: string }`
+ - [ ] `{ status: "blocked_codex_identity_capture_timeout"; terminalId: string }`
+ - [ ] `{ status: "blocked_codex_identity_unavailable"; terminalId: string; reason?: string }`
+ - [ ] `{ status: "blocked_codex_recovery_pending"; terminalId: string }`
+ - [ ] `{ status: "no_terminal" }`
+ - [ ] `{ status: "not_running" }`
+ - [ ] Update all callers of `TerminalRegistry.input()` to handle the new result shape.
+ - [ ] Keep terminal resize and output flowing while input is gated.
+ - [ ] Duplicate or replayed client `terminal.codex.candidate.persisted` acknowledgements succeed only when they match the stored candidate; mismatched acks are logged and ignored.
+- [ ] Update automation prompt-seeding surfaces.
+ - [ ] For `freshell new-tab --codex --prompt ...` and MCP `new-tab` with `mode: "codex"` and `prompt`, opt into an event-driven `send-keys` wait.
+ - [ ] The server retries the prompt when a matching `terminal.codex.durability.updated` or terminal exit event arrives. Do not poll, and do not send the prompt while the terminal is still `identity_pending`.
+- [ ] Update `server/ws-handler.ts`.
+ - [ ] Handle `terminal.codex.candidate.persisted` and call the registry acknowledgement method.
+ - [ ] For blocked input, log at debug/info with terminal id and bytes, send `terminal.input.blocked`, and do not misreport it as `INVALID_TERMINAL_ID`.
+ - [ ] Blocked input is dropped, not buffered and not replayed. The user can type again after the gate opens; Freshell must not silently submit stale pre-capture bytes.
+- [ ] Add/update tests:
+ - [ ] `test/unit/server/terminal-registry.codex-sidecar.test.ts`: input is blocked before server-side candidate persistence and written after the store write completes.
+ - [ ] `test/unit/client/components/TerminalView.test.tsx` or nearest focused test: candidate update persists and sends ack; canonical association clears candidate state.
+ - [ ] `test/unit/client/store/*`: persisted state keeps `codexDurability`.
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/server/terminal-registry.codex-sidecar.test.ts test/unit/client/components/TerminalView.test.tsx test/unit/client/store --run
+```
+
+Commit:
+
+```bash
+git add shared/ws-protocol.ts shared/codex-durability.ts src/store src/components/TerminalView.tsx server/terminal-registry.ts server/ws-handler.ts test/unit/server/terminal-registry.codex-sidecar.test.ts test/unit/client
+git commit -m "Persist Codex candidate identity before accepting input"
+```
+
+### 6. Promote Durable Codex Identity At Turn Completion
+
+- [ ] Update `server/terminal-registry.ts`.
+ - [ ] Subscribe each Codex sidecar/proxy to `turn_started`, `turn_completed`, `fs_changed`, proxy close/error, and lifecycle loss events.
+ - [ ] On `turn_started` for the candidate, transition to `turn_in_progress_unproven`.
+ - [ ] On `turn_completed` for the candidate, transition to `proof_checking`, run one `proofCodexRollout()` call, and then:
+ - [ ] Success: transition to `durable`, bind `resumeSessionId` through the existing `bindSessionToTerminal()` path, emit `terminal.session.associated`, record `codex_durable_session_observed`, and clear candidate state in the client.
+ - [ ] Failure: transition to `durability_unproven_after_completion`, emit `terminal.codex.durability.updated`, keep the live PTY attachable if running, and log structured proof failure data.
+ - [ ] Coalesce overlapping deterministic proof triggers into at most one extra immediate proof read after the current one finishes. Do not use `setInterval`, delayed backoff loops, or path-existence polling (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:526`).
+ - [ ] Watch the exact rollout path and parent through the Freshell-owned runtime/client connection, not by injecting requests into the TUI proxy socket. This avoids JSON-RPC request-id collisions with the visible TUI. Treat `fs/changed` only as a repair trigger that calls the same proof reader (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:482-490`, `:528-538`).
+ - [ ] On PTY exit or app-server/proxy close/error, run one proof read before finalizing state.
+- [ ] Update `server/ws-handler.ts`.
+ - [ ] Ensure `terminal.session.associated` is sent only after proof success for fresh Codex.
+ - [ ] Ensure `sendError` logs server-side structured errors for `RESTORE_UNAVAILABLE` and Codex proof failures. This closes the silent logging gap from the original observation.
+- [ ] Add/update tests:
+ - [ ] `test/unit/server/terminal-registry.codex-sidecar.test.ts`: `turn_completed` plus matching rollout emits canonical `terminal.session.associated`.
+ - [ ] `test/unit/server/terminal-registry.codex-sidecar.test.ts`: `turn_completed` plus missing/malformed/mismatched rollout emits degraded state and does not bind `resumeSessionId`.
+ - [ ] Test trigger coalescing: two repair events during a proof read cause one extra read, not an unbounded loop.
+ - [ ] Test PTY exit before/after turn completion.
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/server/terminal-registry.codex-sidecar.test.ts test/unit/server/ws-handler-sdk.test.ts --run
+```
+
+Commit:
+
+```bash
+git add server/terminal-registry.ts server/ws-handler.ts test/unit/server/terminal-registry.codex-sidecar.test.ts test/unit/server/ws-handler-sdk.test.ts
+git commit -m "Promote Codex sessions only after rollout proof"
+```
+
+### 7. Reopen Captured-But-Unproven State Deterministically
+
+- [ ] Update `server/ws-handler.ts` create/reuse flow.
+ - [ ] Ensure all user restore/list/open surfaces funnel through this create/reuse decision: sidebar row click, tab restore, background terminal restore, MCP/new-tab restore, and any history/session open path that creates a Codex terminal.
+ - [ ] When `terminal.create` includes `codexDurability` and no canonical `sessionRef`, ask the registry to run one proof read before deciding how to open.
+ - [ ] Permit `restore: true` for Codex candidate-only requests when `codexDurability.candidate` is present, even without `sessionRef`, so the proof-first path runs instead of rejecting the request before repair.
+ - [ ] Reopen of `durability_unproven_after_completion` follows the same proof-first path as captured-but-unproven. Success promotes; failure with an exact live candidate attaches live and remains degraded; failure with no live attachable terminal becomes `non_restorable` and fresh-creates only for a new Codex session.
+ - [ ] If proof succeeds, set `effectiveResumeSessionId` to the proven `durableThreadId` and launch a durable resume.
+ - [ ] If proof fails and a live terminal on this server matches the exact candidate thread id and rollout path, attach that live terminal and keep degraded/unproven state visible.
+ - [ ] If proof fails and no live terminal is attachable, fresh-create a new Codex terminal. Do not pass the candidate as `resumeSessionId`; attach a clear local restore-error/non-restorable state to the pane.
+- [ ] Add `TerminalRegistry.findRunningCodexTerminalByCandidate({ candidateThreadId, rolloutPath })`.
+ - [ ] Match only exact candidate thread id and exact rollout path stored in the live record.
+ - [ ] Do not match by cwd, time, title, or shell snapshot (`docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:540-544`).
+- [ ] Update client sidebar/state rendering.
+ - [ ] A live open terminal can show a live/attached indicator only from terminal inventory.
+ - [ ] A Codex pane/session must not show normal restorable/durable state until canonical `sessionRef` exists.
+ - [ ] `durability_unproven_after_completion` shows degraded/restoration-not-proven state even if live terminal attach is available.
+ - [ ] Newly created Codex panes appear in the sidebar immediately as live pending/captured rather than generic grey entries.
+ - [ ] Own the state-to-sidebar mapping in `src/store/selectors/sidebarSelectors.ts` and render it in `src/components/Sidebar.tsx` or the row component it delegates to:
+ - [ ] `identity_pending`: "Starting Codex; restore identity not captured."
+ - [ ] `captured_pre_turn`: "Codex identity captured; restore proof pending."
+ - [ ] `turn_in_progress_unproven`: "Codex turn running; restore proof pending."
+ - [ ] `proof_checking`: "Checking Codex restore proof."
+ - [ ] `durability_unproven_after_completion`: "Codex restore proof failed after turn completion."
+ - [ ] `non_restorable`: "Codex session could not be proven restorable."
+ - [ ] `durable` / `durable_resuming`: normal Codex restorable display.
+- [ ] Add tests:
+ - [ ] Server: captured unproven reopen proof success resumes durable id.
+ - [ ] Server: captured unproven reopen proof fail plus live exact candidate attaches live and stays degraded.
+ - [ ] Server: captured unproven reopen proof fail plus no live exact candidate fresh-creates without passing candidate to resume.
+ - [ ] Server websocket tests must exercise the real client shape with `restore: true` and candidate-only `codexDurability`, not only raw `terminal.create` messages without restore semantics.
+ - [ ] Client/sidebar: live pending Codex appears as Codex, not a generic grey terminal; durable promotion updates the same entry rather than adding a duplicate.
+ - [ ] Each restore/list/open surface above uses the same proof-first path and has no independent cwd/time/title matching.
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/server/ws-handler-sdk.test.ts test/unit/server/terminal-registry.findRunningTerminal.test.ts test/unit/client --run
+```
+
+Commit:
+
+```bash
+git add server/ws-handler.ts server/terminal-registry.ts src test
+git commit -m "Repair captured Codex reopen without nondeterministic matching"
+```
+
+### 8. Extend Fake Codex Fixtures For Realistic Tests
+
+- [ ] Update `test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs`.
+ - [ ] Keep current app-server fixture mode for `app-server --listen`.
+ - [ ] Add fake TUI mode for `--remote ` that connects to the proxy, sends `thread/start` for fresh launch, writes a visible PTY banner, reads stdin, sends `turn/start`, optionally writes rollout JSONL, then sends `turn/completed`.
+ - [ ] Add fixture controls for delayed candidate, missing rollout, malformed rollout, mismatched rollout id, delayed `turn/completed`, proxy close, and app-server close.
+ - [ ] Ensure fixture processes are tagged with temp env vars so cleanup cannot kill real user sessions.
+- [ ] Add or update e2e/integration tests:
+ - [ ] Fresh Codex launch: candidate captured, input initially gated, server-side candidate persistence releases input, `turn/completed` promotes to canonical `sessionRef`.
+ - [ ] Missing rollout after `turn/completed`: state becomes degraded and no canonical `sessionRef` is persisted.
+ - [ ] Reopen degraded with later rollout proof: proof-read repairs and resumes durable id.
+ - [ ] Reopen degraded without proof after server restart: fresh-creates Codex and does not call `resume `.
+ - [ ] Duplicate sidebar regression: a new live Codex terminal stays one sidebar item before and after durable promotion.
+
+Run:
+
+```bash
+npm run test:vitest -- test/e2e/codex-session-resilience-flow.test.tsx test/e2e/codex-refresh-rehydrate-flow.test.tsx --run
+```
+
+Commit:
+
+```bash
+git add test/fixtures/coding-cli/codex-app-server/fake-app-server.mjs test/e2e
+git commit -m "Cover Codex durability flow end to end"
+```
+
+### 9. Observability And Logging
+
+- [ ] Update `server/session-lifecycle-logger.ts` or the nearest lifecycle telemetry module.
+ - [ ] Add lifecycle event kinds for `codex_candidate_observed`, `codex_candidate_persist_requested`, `codex_candidate_persisted`, `codex_input_gate_blocked`, `codex_turn_completed`, `codex_rollout_proof_success`, `codex_rollout_proof_failure`, `codex_repair_triggered`, `codex_reopen_fresh_created`.
+- [ ] Update `server/ws-handler.ts` `sendError`.
+ - [ ] Log every server-sent error with code, message, requestId, terminalId/session id when present, and connection id.
+ - [ ] Avoid relying on stdout/stderr-only messages from child processes; structured server logs should show the reason Freshell chose degraded/fresh-create/restore-unavailable.
+- [ ] Add log assertions in focused unit tests where practical, especially for proof failure and restore-unavailable paths.
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/server/ws-handler-sdk.test.ts test/unit/server/terminal-registry.codex-sidecar.test.ts --run
+```
+
+Commit:
+
+```bash
+git add server test
+git commit -m "Log Codex durability transitions and server errors"
+```
+
+### 10. Broad Verification
+
+- [ ] Run typecheck and focused tests:
+
+```bash
+npm run typecheck
+npm run test:vitest -- test/unit/server/coding-cli/codex-app-server test/unit/server/terminal-registry.codex-sidecar.test.ts test/unit/server/ws-handler-sdk.test.ts test/unit/client test/e2e/codex-session-resilience-flow.test.tsx test/e2e/codex-refresh-rehydrate-flow.test.tsx --run
+```
+
+- [ ] Run coordinated full check when focused tests are green:
+
+```bash
+FRESHELL_TEST_SUMMARY="codex turn-completion durability implementation" npm run check
+```
+
+- [ ] Commit any fixes:
+
+```bash
+git status --short
+git add
+git commit -m "Stabilize Codex durability verification"
+```
+
+### 11. Review Hardening Items
+
+These checks come from the implementation reviews and are part of the same one-shot delivery, not follow-up work.
+
+- [ ] Arm fresh Codex candidate-capture timeout when the proxy is ready, even if the visible Codex TUI never connects. This closes the stuck `identity_pending` state described in the research evidence that input must not be accepted until Freshell has server-persisted Codex's reported restore identity (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-04-20-coding-cli-session-contract.md`, "Working Codex contract").
+- [ ] Initialize durable Codex resume records as durable in `TerminalRegistry.create()` when the caller supplies a canonical `sessionRef`. The research says `sessionRef` is the only durable restore identity; a terminal created from one must advertise that same identity through inventory and sidebar state (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-04-20-coding-cli-session-contract.md`, "Recommendation").
+- [ ] On final Codex process loss, run exactly one rollout proof if a candidate exists, even if the `turn/completed` notification was lost. Ordinary repair events still wait for `turn/completed`; final loss is the last chance to avoid falsely discarding a restorable session (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-04-20-coding-cli-session-contract.md`, "What remains unproven").
+- [ ] Preserve captured candidate state across browser refresh and use it for the recreate request after the old live terminal id is gone. This is the client-side half of "prefer terminal, then proof-read candidate, then fresh-create if proof fails" (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-04-20-coding-cli-session-contract.md`, "Failure handling without polling").
+- [ ] Extend the fake app-server/fake TUI integration path so tests exercise actual proxy candidate capture, input, `turn/completed`, rollout proof, durable promotion, and sidebar/inventory exposure instead of only direct sidecar callbacks.
+- [ ] Delete transient Codex durability store records when the owning terminal is killed, removed, or reaped. The server-side store is a crash bridge for an active terminal, not a durable session database.
+- [ ] If rollout proof succeeds but canonical session binding fails, do not broadcast `terminal.session.associated`; mark the terminal non-restorable instead so the client cannot persist a session the server does not own.
+- [ ] After an async candidate-store write completes, re-check that the same terminal is still running and still accepting a candidate before mutating in-memory state or calling `markCandidatePersisted()`.
+- [ ] Report input after a candidate-capture timeout as `terminal.input.blocked` with `codex_identity_capture_timeout`, not as a generic invalid/dead terminal.
+- [ ] Treat candidate-only Codex ids as pane/tab locators only. They may focus an existing pane so the sidebar does not duplicate entries, but they must not become `sessionRef`, `resumeSessionId`, or tab session metadata until rollout proof succeeds. This follows the research distinction between candidate identity and durable restore identity (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-04-20-coding-cli-session-contract.md`, "Working Codex contract"; `/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:492-504`).
+- [ ] When a non-restorable Codex row has no live terminal to attach, open a fresh Codex pane without carrying the old candidate id. This preserves user ergonomics without pretending restore succeeded (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-04-20-coding-cli-session-contract.md`, "Failure handling without polling"; `/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:540-544`).
+- [ ] Reject raw Codex resume ids from generic automation surfaces (`/api/tabs`, pane split/respawn, and MCP `new-tab`) unless they are already flowing through the proven canonical `sessionRef` path. These surfaces do not carry rollout proof, so they must fresh-create instead of invoking `codex resume ` (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:369-412`, `:474-480`, `:550`).
+- [ ] Expose Codex durability state in sidebar rows, not just a boolean. Pending, checking, degraded, and non-restorable Codex rows must be distinguishable while durable rows remain normal. The research explicitly rejects green/grey/live-only ambiguity after proof failure (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:506-518`, `:584-590`).
+- [ ] Arm an exact app-server `fs/watch` on the captured rollout path as a wake-up source after candidate persistence, and unwatch it after durable promotion or sidecar teardown. The research says `fs/watch` cannot be the only proof path, but it remains the deterministic repair trigger when the rollout appears after the first proof attempt (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:325-331`, `:482-490`, `:554`, `:586-588`).
+- [ ] When attaching an existing live non-restorable Codex terminal by `terminalId`, carry its full `codexDurability` into the reopened tab and pane. A degraded live session is still failure, but Freshell must retain the evidence needed for proof-first repair instead of collapsing it into a generic grey terminal (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-04-20-coding-cli-session-contract.md`, "Failure handling without polling").
+- [ ] Allow canonical Codex `sessionRef` through automation surfaces while continuing to ignore legacy raw Codex `resumeSessionId`. The durable path is the canonical session contract; the unsafe path is treating an unproven candidate or legacy resume token as durable (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-04-20-coding-cli-session-contract.md`, "Working Codex contract"; `/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:492-504`).
+- [ ] Make rollout proof read only the first JSONL record, not the full rollout file. The proof contract is first-record `session_meta.payload.id`, so proof should be O(first line) rather than O(full transcript) (`/home/user/code/freshell/.worktrees/codex-stability-implementation-20260514/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md:456`, `:520-526`).
+
+## Temporary Server Validation
+
+Use a port that does not interfere with dev, for example `3477`. Do not restart `/home/user/code/freshell/.worktrees/dev`.
+
+- [ ] Build from the implementation worktree:
+
+```bash
+npm run build
+```
+
+- [ ] Start a temporary server from this worktree only:
+
+```bash
+PORT=3477 npm start > /tmp/freshell-codex-durability-3477.log 2>&1 & echo $! > /tmp/freshell-codex-durability-3477.pid
+```
+
+- [ ] Verify the PID belongs to this worktree before stopping it later:
+
+```bash
+ps -fp "$(cat /tmp/freshell-codex-durability-3477.pid)"
+```
+
+- [ ] Use Freshell orchestration against `http://127.0.0.1:3477` and run each fixture-backed scenario at least three times:
+ - [ ] Fresh Codex pane: before candidate capture, user input is not accepted but terminal-control replies needed for TUI startup are allowed; after server-side candidate persistence, user input works.
+ - [ ] Fresh Codex pane with fake TUI: send a test prompt, wait for fake `turn/completed`, verify canonical `sessionRef` is persisted and sidebar entry remains a single Codex item.
+ - [ ] Fresh Codex pane: close/reopen after durable promotion, verify it resumes with `codex --remote resume `.
+ - [ ] Fresh Codex pane: reload browser before first turn completes, verify candidate state is preserved and no candidate id is used as `resumeSessionId`.
+ - [ ] Fresh Codex pane: restart only the temporary server after durable promotion, verify reopen resumes durable id.
+ - [ ] Fresh Codex pane: simulate/mutate missing rollout after `turn/completed`, verify degraded state and no fake green/normal state.
+ - [ ] Captured-but-unproven pane after temporary server restart: verify Freshell proof-reads once and fresh-creates if proof fails, without trying `codex resume `.
+ - [ ] Existing durable Codex pane: sidecar lifecycle loss triggers durable recovery and preserves the same sidebar item.
+ - [ ] Shell and Claude panes: create, type, close/reopen, and server restart to verify Codex changes did not regress non-Codex terminal state.
+- [ ] Run a real Codex smoke only if the machine has working Codex auth and model access:
+ - [ ] Fresh real Codex pane: send a short harmless prompt, wait for completion, verify canonical `sessionRef` is persisted and restore works.
+ - [ ] If real Codex auth/model access is unavailable, record the skipped reason and rely on fixture-backed scenarios plus unit/integration coverage.
+
+- [ ] Inspect `/tmp/freshell-codex-durability-3477.log`.
+ - [ ] Confirm structured events exist for candidate observed, candidate persisted, input gate release, turn completed, proof success/failure, and reopen decisions.
+ - [ ] Confirm no proof-read polling loop is visible.
+ - [ ] Confirm no server-sent errors are silent.
+
+- [ ] Stop only the temporary server:
+
+```bash
+kill "$(cat /tmp/freshell-codex-durability-3477.pid)" && rm -f /tmp/freshell-codex-durability-3477.pid
+```
+
+## Done Criteria
+
+- Fresh Codex launch no longer pre-creates an app-server thread and no longer TUI-resumes a pre-durable id.
+- User-originating Codex input is blocked only until Freshell has persisted the candidate thread id and rollout path.
+- `turn/completed` triggers one exact proof read of the provider-reported rollout path.
+- Canonical Codex `sessionRef` is persisted only after first-record `session_meta.payload.id` matches the candidate thread id.
+- Captured-but-unproven Codex reopen never matches by cwd/time/title and never tries `codex resume ` before proof.
+- New live Codex panes appear in the sidebar immediately as Codex entries, do not stay generic grey, and do not duplicate on promotion.
+- Post-completion proof failure is visible degraded state, not a normal grey/green state.
+- Unit, integration/e2e, coordinated check, and repeated temporary-server scenarios pass.
diff --git a/docs/superpowers/plans/2026-05-18-visible-first-opencode-restore.md b/docs/superpowers/plans/2026-05-18-visible-first-opencode-restore.md
new file mode 100644
index 000000000..e326d3000
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-18-visible-first-opencode-restore.md
@@ -0,0 +1,1800 @@
+# Visible-First OpenCode Restore Implementation Plan
+
+> **For Claude:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make restored OpenCode terminal panes deterministic by representing stale/dead OpenCode restores as visibility-gated restore intents, launching them only when a mounted `TerminalView` is visible, while preserving a constrained replay-gap repair path for same-server restored panes.
+
+**Architecture:** Add an explicit queued-restore state to terminal pane content instead of treating `status: "creating"` as both "create now" and "restore later." `TerminalView` becomes a small restore lifecycle state machine: queued OpenCode panes do not send `terminal.create` while hidden; the same mounted component transitions the queued pane once to a restored create as soon as it is visible; visible-started OpenCode restores carry an immediate attach obligation so `terminal.created` cannot become a hidden detached PTY if the user switches tabs mid-launch.
+
+**Fundamental Invariant:** Durable session identity never grants runtime ownership or destructive authority. `sessionRef` identifies the OpenCode session to restore; `terminalId` identifies a current PTY; only a current restore-attempt lease tied to that specific `{ sessionRef, createRequestId, terminalId, serverInstanceId }` may kill, replace, or recreate the PTY. The initial implementation represents this lease with `restoreRuntime`. The lease is created only for a restored OpenCode create, may survive a browser refresh only while the same server instance and live terminal handle are preserved, and is retired after the first successful `viewport_hydrate` attach for that terminal. After the lease is retired, replay gaps are non-destructive live-terminal events.
+
+**Tech Stack:** React 18, Redux Toolkit, TypeScript, Vitest, Playwright browser e2e, Freshell WebSocket terminal protocol.
+
+---
+
+## Chunk 1: Restore State Model
+
+### File Structure
+
+- Verify/no change: `/home/user/code/freshell/.worktrees/dev/src/store/types.ts`
+ - Leave the shared tab/background terminal status unchanged.
+- Modify: `/home/user/code/freshell/.worktrees/dev/src/store/paneTypes.ts`
+ - Add a pane-only queued terminal status type.
+ - Add explicit queued OpenCode restore metadata on `TerminalPaneContent`.
+- Modify: `/home/user/code/freshell/.worktrees/dev/src/store/panesSlice.ts`
+ - Use shared restore-state normalization for reducer actions.
+ - Convert stale/dead OpenCode restore handles to queued visibility-gated state when stale runtime ids are stripped.
+- Add/modify: `/home/user/code/freshell/.worktrees/dev/src/store/paneRestoreState.ts`
+ - Own the shared terminal pane restore-state sanitizers used by both `panesSlice.ts` and `persistMiddleware.ts`.
+- Modify: `/home/user/code/freshell/.worktrees/dev/src/store/persistMiddleware.ts`
+ - Apply shared boot-load sanitization for queued/error/lease fields without discarding same-server live terminal handles.
+ - Persist only valid same-server restore-attempt leases, and strip them when the server instance or terminal handle is no longer live.
+- Modify: `/home/user/code/freshell/.worktrees/dev/src/lib/terminal-status-indicator.ts`
+ - Render queued restore as pending/neutral, not running/error.
+- Modify as needed: `/home/user/code/freshell/.worktrees/dev/src/components/panes/Pane.tsx`, `/home/user/code/freshell/.worktrees/dev/src/components/panes/PaneHeader.tsx`, `/home/user/code/freshell/.worktrees/dev/src/components/TabItem.tsx`, `/home/user/code/freshell/.worktrees/dev/src/components/TabSwitcher.tsx`
+ - Accept pane-local `TerminalPaneStatus` only where status is derived from pane content.
+- Test: `/home/user/code/freshell/.worktrees/dev/test/unit/client/store/panesSlice.test.ts`
+- Test: `/home/user/code/freshell/.worktrees/dev/test/unit/client/store/panesPersistence.test.ts`
+
+### Task 1: Add Explicit Queued Restore Types
+
+- [ ] **Step 1: Write the failing type/model test**
+
+Add a test proving restored OpenCode pane hydration becomes queued instead of creating:
+
+```ts
+it('queues restored OpenCode panes until visible when stale runtime ids are stripped', () => {
+ const layout: PaneNode = {
+ type: 'leaf',
+ id: 'pane-opencode',
+ content: {
+ kind: 'terminal',
+ mode: 'opencode',
+ shell: 'system',
+ status: 'running',
+ terminalId: 'old-term',
+ createRequestId: 'old-request',
+ sessionRef: { provider: 'opencode', sessionId: 'ses_root_1' },
+ },
+ }
+
+ const result = panesReducer(
+ initialState,
+ restoreLayout({ tabId: 'tab-opencode', layout, paneTitles: {} }),
+ )
+ const restored = result.layouts['tab-opencode']
+ expect(restored.type).toBe('leaf')
+ if (restored.type !== 'leaf' || restored.content.kind !== 'terminal') {
+ throw new Error('expected terminal leaf')
+ }
+
+ expect(restored.content).toMatchObject({
+ kind: 'terminal',
+ mode: 'opencode',
+ status: 'queued',
+ sessionRef: { provider: 'opencode', sessionId: 'ses_root_1' },
+ queuedRestore: {
+ kind: 'until_visible',
+ provider: 'opencode',
+ reason: 'visible_owner_required',
+ },
+ })
+ expect(restored.content.terminalId).toBeUndefined()
+ expect(restored.content.createRequestId).not.toBe('old-request')
+})
+```
+
+Add a companion malformed-state test so queued never degrades into a valid create state. This intentionally uses `initLayout` with an `as any` corrupt payload to exercise `normalizePaneContent` directly; the normal `restoreLayout` path should repair OpenCode restores into a valid queued shape via `stripStaleIds`. This store test is necessary but not sufficient: Chunk 2 adds the mounted `TerminalView` no-create proof so an `error` terminal without a `terminalId` cannot still launch a PTY.
+
+```ts
+it('marks malformed queued terminal state as an error instead of creating', () => {
+ const result = panesReducer(
+ initialState,
+ initLayout({
+ tabId: 'tab-bad-queue',
+ paneId: 'pane-bad-queue',
+ content: {
+ kind: 'terminal',
+ mode: 'opencode',
+ shell: 'system',
+ status: 'queued' as any,
+ createRequestId: 'req-bad-queue',
+ terminalId: 'stale-term',
+ sessionRef: { provider: 'opencode', sessionId: 'ses_root_bad' },
+ },
+ }),
+ )
+ const restored = result.layouts['tab-bad-queue']
+ expect(restored.type).toBe('leaf')
+ if (restored.type !== 'leaf' || restored.content.kind !== 'terminal') {
+ throw new Error('expected terminal leaf')
+ }
+ expect(restored.content.status).toBe('error')
+ expect(restored.content.terminalId).toBeUndefined()
+ expect(restored.content.queuedRestore).toBeUndefined()
+ expect(restored.content.restoreError?.reason).toBe('provider_runtime_failed')
+})
+```
+
+- [ ] **Step 2: Run the failing test**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/store/panesSlice.test.ts --run -t "queued"
+```
+
+Expected: FAIL because `TerminalPaneContent.status` currently cannot represent `queued` and `stripStaleIds` currently returns a normal creating terminal input.
+
+- [ ] **Step 3: Add the model**
+
+Do not widen `/home/user/code/freshell/.worktrees/dev/src/store/types.ts`'s `TerminalStatus`. `TerminalStatus` is also used by `Tab.status`, `BackgroundTerminal`, `tabsSlice.normalizePersistedTerminalStatus`, `TabSwitcher`, and `TabItem`; tabs must never enter `queued`.
+
+In `/home/user/code/freshell/.worktrees/dev/src/store/paneTypes.ts`:
+
+```ts
+export type TerminalPaneStatus = TerminalStatus | 'queued'
+
+export type TerminalQueuedRestore = {
+ kind: 'until_visible'
+ provider: 'opencode'
+ /** Restore needs a visible terminal owner before Freshell may start the OpenCode PTY. */
+ reason: 'visible_owner_required'
+}
+```
+
+Change `TerminalPaneContent.status` and `TerminalPaneInput.status` from `TerminalStatus` to `TerminalPaneStatus`, then add `queuedRestore` to `TerminalPaneContent`:
+
+```ts
+ /** Current pane-local terminal status. `queued` is not valid for Tab.status. */
+ status: TerminalPaneStatus
+ /** Restore launch is intentionally delayed until this pane is visible. */
+ queuedRestore?: TerminalQueuedRestore
+```
+
+Update the input alias explicitly:
+
+```ts
+export type TerminalPaneInput = Omit & {
+ createRequestId?: string
+ status?: TerminalPaneStatus
+}
+```
+
+Keep the type narrow. Do not make this a generic provider framework until a second provider needs it.
+
+Update pane-only UI and helpers that consume `TerminalPaneContent.status` (`Pane`, `PaneHeader`, `TabItem`'s pane-derived status path, and `/home/user/code/freshell/.worktrees/dev/src/lib/terminal-status-indicator.ts`) to accept `TerminalPaneStatus`. Leave `/home/user/code/freshell/.worktrees/dev/src/store/tabsSlice.ts`, `Tab.status`, and `normalizePersistedTerminalStatus` on `TerminalStatus`; persisted tab status of `'queued'` should continue normalizing to `'creating'` because it is invalid tab state.
+
+Add an explicit tab-status mapping rule: a tab containing a queued OpenCode restore must present `Tab.status === 'creating'`, never stale `'running'` and never pane-only `'queued'`. The concrete owner is the existing tab/layout synchronization path: extend `/home/user/code/freshell/.worktrees/dev/src/lib/tab-fallback-identity.ts` so `buildTabFallbackIdentityUpdates()` accepts `status` and the tab's `activePaneId`, detects when the tab's active pane is a queued OpenCode restore, and returns `{ status: 'creating' }` when the current tab status is not already `creating`. If a layout has no active pane entry, fall back to the existing single-leaf behavior only; do not scan arbitrary inactive split panes and mark the whole tab creating because a hidden secondary pane is queued. `/home/user/code/freshell/.worktrees/dev/src/store/tabFallbackIdentityMiddleware.ts` then passes `state.panes.activePane[tab.id]` and applies that update after `restoreLayout`, `clearDeadTerminals`, and startup repair actions. For persisted boot normalization, extend `sanitizeTabsAgainstLayouts()` in the same file so restored tabs start with `Tab.status === 'creating'` before the middleware's first pass when their active/single pane is queued. Existing direct `Tab.status` consumers such as `/home/user/code/freshell/.worktrees/dev/src/components/TabSwitcher.tsx` and the no-pane-icons path in `/home/user/code/freshell/.worktrees/dev/src/components/TabItem.tsx` may continue reading `tab.status`, but tests must prove queued panes do not leave those surfaces showing `running`.
+
+Add focused UI/store tests:
+
+- In the flow that dispatches `clearDeadTerminals` after receiving the server live-terminal list, prove any tab whose active/primary OpenCode pane was changed to queued also receives `updateTab({ status: 'creating' })`. `panesSlice` itself cannot update `tabsSlice`, so this belongs in the App/startup orchestration test or a small thunk/listener test, not in a pure `panesReducer` assertion.
+- In the restored-tab/open-session path, prove adding a tab for a queued OpenCode pane initializes `Tab.status` as `creating`.
+- In the tab component tests closest to `TabSwitcher`/`TabItem`, render a tab with a queued OpenCode pane and `tab.status: 'creating'`; assert the status label/dot is pending/creating, not running. If no such focused test exists, add one in `/home/user/code/freshell/.worktrees/dev/test/unit/client/components/`.
+
+- [ ] **Step 4: Normalize queued restore metadata**
+
+In `/home/user/code/freshell/.worktrees/dev/src/store/panesSlice.ts`, add a small sanitizer near `normalizePaneContent`:
+
+```ts
+function sanitizeTerminalQueuedRestore(input: unknown): TerminalQueuedRestore | undefined {
+ const value = input as Partial | undefined
+ if (
+ value?.kind === 'until_visible'
+ && value.provider === 'opencode'
+ && value.reason === 'visible_owner_required'
+ ) {
+ return {
+ kind: 'until_visible',
+ provider: 'opencode',
+ reason: 'visible_owner_required',
+ }
+ }
+ return undefined
+}
+```
+
+Use it from `normalizePaneContent`. Keep the existing explicit terminal return object; do not switch to an implicit spread of unknown input. Import `type TerminalQueuedRestore` from `./paneTypes`, then make the terminal branch compute `sessionRef`, `queuedRestore`, and `canQueue` before the return:
+
+```ts
+const sessionRef = sanitizeSessionRef(input.sessionRef)
+const queuedRestore = sanitizeTerminalQueuedRestore((input as { queuedRestore?: unknown }).queuedRestore)
+const canQueue = mode === 'opencode' && sessionRef?.provider === 'opencode'
+const invalidQueuedRestore = input.status === 'queued' && !(queuedRestore && canQueue)
+const status = normalizeTerminalPaneStatus(input.status, Boolean(queuedRestore && canQueue))
+const stripRuntimeForQueued = status === 'queued' || invalidQueuedRestore
+```
+
+Then keep the full explicit return shape and add `queuedRestore` as one conditional field:
+
+```ts
+return {
+ kind: 'terminal',
+ terminalId: !stripRuntimeForQueued && typeof input.terminalId === 'string' ? input.terminalId : undefined,
+ createRequestId: typeof input.createRequestId === 'string' && input.createRequestId
+ ? input.createRequestId
+ : nanoid(),
+ status,
+ mode,
+ shell: typeof input.shell === 'string' ? input.shell : 'system',
+ resumeSessionId,
+ ...(sessionRef ? { sessionRef } : {}),
+ ...(codexDurability ? { codexDurability } : {}),
+ serverInstanceId: !stripRuntimeForQueued && typeof input.serverInstanceId === 'string' ? input.serverInstanceId : undefined,
+ ...(invalidQueuedRestore
+ ? { restoreError: buildRestoreError('provider_runtime_failed') }
+ : (restoreError.success ? { restoreError: restoreError.data } : {})),
+ ...(queuedRestore && canQueue ? { queuedRestore } : {}),
+ initialCwd: typeof input.initialCwd === 'string' ? input.initialCwd : undefined,
+}
+```
+
+Add `normalizeTerminalPaneStatus` in `panesSlice.ts` so arbitrary persisted strings cannot become runtime status:
+
+```ts
+function normalizeTerminalPaneStatus(status: unknown, allowQueued: boolean): TerminalPaneStatus {
+ if (status === 'queued' && allowQueued) return 'queued'
+ if (status === 'queued') return 'error'
+ if (
+ status === 'creating'
+ || status === 'running'
+ || status === 'recovering'
+ || status === 'exited'
+ || status === 'error'
+ ) return status
+ return 'creating'
+}
+```
+
+Import `buildRestoreError` from `@shared/session-contract` if it is not already available in this file. This prevents malformed persisted `status: 'queued'` from silently falling back to `creating`; the runtime launch prevention is pinned separately in Chunk 2.
+
+- [ ] **Step 5: Share restore-state normalization with persisted load**
+
+Do not maintain separate reducer and localStorage boot paths. Move the restore-state content helpers needed by both paths into `/home/user/code/freshell/.worktrees/dev/src/store/paneRestoreState.ts`:
+
+- `normalizePaneContent`
+- `stripStaleIds`
+- `normalizeRestoredTree`
+- `normalizePersistedPaneTreeForBoot`
+- `normalizePersistedTerminalContentForBoot`
+- queued-restore/session-ref/status sanitizers
+
+`paneRestoreState.ts` must not import `panesSlice.ts` or `persistMiddleware.ts`; both of those modules may import the shared helpers. This avoids a circular dependency and keeps reducer and localStorage boot sanitization consistent without pretending that same-server reload and known-dead restore are the same transition.
+
+Update `panesSlice.ts` to import and use the shared helpers instead of keeping local-only copies.
+
+Update both sanitized layout loops in `persistMiddleware.ts` so `loadPersistedPanes()` applies `normalizePersistedPaneTreeForBoot` before returning data to `panesSlice.ts` or `terminal-restore.ts`:
+
+```ts
+const sanitizedNode = stripEditorContentFromNode(normalizePersistedPaneTreeForBoot(migrateNode(node)))
+```
+
+For the post-migration loop that already operates on `layouts`, apply the same `normalizePersistedPaneTreeForBoot` call before `stripEditorContentFromNode`. This helper sanitizes queued/error fields, but it must preserve `terminalId` and `serverInstanceId` for normal same-server browser refresh. Chunk 3 extends the same helper with `restoreRuntime` lease sanitization; do not add lease behavior in Chunk 1. Do not call `normalizeRestoredTree` directly from persisted boot loading; that helper is for explicit restore/dead-handle transitions where stale runtime ids are already known.
+
+Add failing persisted-load tests in `/home/user/code/freshell/.worktrees/dev/test/unit/client/store/panesPersistence.test.ts`:
+
+```ts
+it('queues dead OpenCode handles after terminal liveness is known', () => {
+ const state = panesReducer(initialState, initLayout({
+ tabId: 'tab-opencode',
+ paneId: 'pane-opencode',
+ content: {
+ kind: 'terminal',
+ mode: 'opencode',
+ shell: 'system',
+ status: 'running',
+ terminalId: 'term-dead-opencode',
+ createRequestId: 'req-dead-opencode',
+ sessionRef: { provider: 'opencode', sessionId: 'ses_root_dead' },
+ },
+ }))
+
+ const next = panesReducer(state, clearDeadTerminals({ liveTerminalIds: [] }))
+ const layout = next.layouts['tab-opencode']
+ expect(layout.type).toBe('leaf')
+ expect(layout.content).toMatchObject({
+ kind: 'terminal',
+ mode: 'opencode',
+ status: 'queued',
+ sessionRef: { provider: 'opencode', sessionId: 'ses_root_dead' },
+ queuedRestore: {
+ kind: 'until_visible',
+ provider: 'opencode',
+ reason: 'visible_owner_required',
+ },
+ })
+ expect(layout.content.terminalId).toBeUndefined()
+ expect(layout.content.createRequestId).not.toBe('req-dead-opencode')
+ expect(layout.content.restoreRuntime).toBeUndefined()
+})
+```
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/store/panesPersistence.test.ts --run -t "OpenCode"
+npm run test:vitest -- test/unit/client/store/panesSlice.test.ts --run -t "queues dead OpenCode handles"
+```
+
+Expected: FAIL before persisted-load sanitization and dead-handle queuing exist.
+
+- [ ] **Step 6: Queue OpenCode restores when stripping stale ids**
+
+In `stripStaleIds`, replace the terminal branch with this shape:
+
+```ts
+if (content.kind === 'terminal') {
+ const {
+ terminalId: _terminalId,
+ createRequestId: _createRequestId,
+ status: _status,
+ queuedRestore: _queuedRestore,
+ ...rest
+ } = content
+ const sessionRef = sanitizeSessionRef(content.sessionRef)
+ if (content.mode === 'opencode' && sessionRef?.provider === 'opencode') {
+ const { serverInstanceId: _serverInstanceId, ...queuedRest } = rest
+ // Queued OpenCode restores have no same-server live terminal to match.
+ return {
+ ...queuedRest,
+ status: 'queued',
+ queuedRestore: {
+ kind: 'until_visible',
+ provider: 'opencode',
+ reason: 'visible_owner_required',
+ },
+ }
+ }
+ return rest
+}
+```
+
+This keeps shell/Claude/Codex behavior unchanged, including the existing preservation of `serverInstanceId` for non-OpenCode restores. `serverInstanceId` is stripped only for the OpenCode queued branch because a queued pane has no same-server live terminal to match.
+
+Use this same `stripStaleIds` + `normalizePaneContent` path from `clearDeadTerminals` when a terminal pane's `terminalId` is absent from the server's live terminal list. For dead OpenCode handles with canonical `sessionRef.provider === 'opencode'`, the reducer must transition to a visibility-gated restore intent: `status: 'queued'`, `queuedRestore.reason: 'visible_owner_required'`, and a fresh `createRequestId` instead of `status: 'creating'`.
+
+Do not make `clearDeadTerminals` branch on visibility; it does not know which panes are visible. The queued state means "do not start this OpenCode PTY until a mounted visible `TerminalView` owns it", not "this pane was definitely hidden." A currently visible pane may pass through queued for one reducer tick and then launch immediately through `TerminalView`'s visible transition. For non-OpenCode providers, keep the existing dead-handle behavior unless a focused test proves it must change.
+
+- [ ] **Step 7: Update status UI**
+
+In `/home/user/code/freshell/.worktrees/dev/src/lib/terminal-status-indicator.ts`, handle `queued` the same as `creating`:
+
+```ts
+case 'queued':
+case 'creating':
+default:
+ return 'text-muted-foreground'
+```
+
+and:
+
+```ts
+case 'queued':
+case 'creating':
+default:
+ return 'fill-muted-foreground text-muted-foreground'
+```
+
+- [ ] **Step 8: Run the model test green**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/store/panesSlice.test.ts --run -t "queued"
+```
+
+Expected: PASS.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git status --short
+git add src/store/paneTypes.ts src/store/panesSlice.ts src/store/paneRestoreState.ts src/store/persistMiddleware.ts src/lib/tab-fallback-identity.ts src/store/tabFallbackIdentityMiddleware.ts src/lib/terminal-status-indicator.ts src/components/panes/Pane.tsx src/components/panes/PaneHeader.tsx src/components/TabItem.tsx src/components/TabSwitcher.tsx test/unit/client/store/panesSlice.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/store/tabFallbackIdentityMiddleware.test.ts
+# Add these component test files only if this chunk touched them:
+# git add test/unit/client/components/TabItem.test.tsx test/unit/client/components/TabSwitcher.test.tsx
+git commit -m "feat: model queued opencode restores"
+```
+
+Stage only files changed by this chunk.
+
+---
+
+## Chunk 2: TerminalView Visible-First State Machine
+
+### File Structure
+
+- Modify: `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx`
+ - Do not create hidden queued OpenCode restores.
+ - On reveal, mark request id as a restore request and send exactly one restored create.
+ - Attach immediately after `terminal.created` for visible-started OpenCode restores, even if the pane became hidden before create completed.
+- Test: `/home/user/code/freshell/.worktrees/dev/test/unit/client/components/TerminalView.lifecycle.test.tsx`
+
+### Task 2: Prevent Hidden Queued Creates
+
+- [ ] **Step 1: Write the failing hidden-queued test**
+
+Add to the existing `v2 stream lifecycle` describe block:
+
+```ts
+it('does not create hidden queued OpenCode restores', async () => {
+ const sessionRef = { provider: 'opencode', sessionId: 'ses_queued_hidden' } as const
+ await renderTerminalHarness({
+ status: 'queued',
+ mode: 'opencode',
+ hidden: true,
+ requestId: 'req-opencode-queued-hidden',
+ sessionRef,
+ queuedRestore: {
+ kind: 'until_visible',
+ provider: 'opencode',
+ reason: 'visible_owner_required',
+ },
+ clearSends: false,
+ })
+
+ expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({
+ type: 'terminal.create',
+ }))
+ expect(restoreMocks.consumeTerminalRestoreRequestId).not.toHaveBeenCalledWith('req-opencode-queued-hidden')
+})
+```
+
+Add a mounted malformed-state guard test. This is the runtime proof that complements the reducer test from Chunk 1:
+
+```ts
+it('does not create a terminal for malformed queued state normalized to error', async () => {
+ await renderTerminalHarness({
+ status: 'error',
+ mode: 'opencode',
+ requestId: 'req-opencode-bad-queue',
+ terminalId: undefined,
+ sessionRef: { provider: 'opencode', sessionId: 'ses_bad_queue' },
+ clearSends: false,
+ })
+
+ expect(wsMocks.send).not.toHaveBeenCalledWith(expect.objectContaining({
+ type: 'terminal.create',
+ }))
+ expect(restoreMocks.consumeTerminalRestoreRequestId).not.toHaveBeenCalledWith('req-opencode-bad-queue')
+})
+```
+
+Extend `renderTerminalHarness` options with:
+
+```ts
+status?: TerminalPaneContent['status']
+queuedRestore?: TerminalPaneContent['queuedRestore']
+renderFromStore?: boolean
+```
+
+When `renderFromStore` is true, render `TerminalViewFromStore` on the initial render as well as later rerenders:
+
+```tsx
+const view = render(
+
+ {opts?.renderFromStore
+ ?
+ : }
+ ,
+)
+```
+
+Reveal tests must use the same component type before and after `rerender`. Do not render `TerminalView` first and then `TerminalViewFromStore`; React treats that as an unmount/remount and hides the production tab-activation bug.
+
+- [ ] **Step 2: Run the failing test**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run -t "does not create hidden queued OpenCode restores"
+npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run -t "does not create a terminal for malformed queued state normalized to error"
+```
+
+Expected: FAIL because current code sends `terminal.create` for any terminal content with no `terminalId`, including invalid `error` content.
+
+- [ ] **Step 3: Extract create launch into a reusable state-machine transition**
+
+In `TerminalView.tsx`, add helpers near the other lifecycle helpers:
+
+```ts
+function isQueuedVisibleFirstOpenCodeRestore(content: TerminalPaneContent | null | undefined): boolean {
+ return content?.mode === 'opencode'
+ && content.status === 'queued'
+ && !content.terminalId
+ && content.queuedRestore?.kind === 'until_visible'
+ && content.queuedRestore.provider === 'opencode'
+ && content.sessionRef?.provider === 'opencode'
+ && typeof content.sessionRef.sessionId === 'string'
+}
+```
+
+Treat `queued` plus an existing `terminalId` as invalid state. The normal restore path strips runtime ids before queuing; if a malformed persisted pane has both, normalization must drop the stale `terminalId` or surface an error instead of launching or attaching ambiguously.
+
+Add a create eligibility guard that is independent from the queued reveal transition:
+
+```ts
+function shouldCreateTerminalImmediately(content: TerminalPaneContent | null | undefined): boolean {
+ if (!content) return false
+ if (isQueuedVisibleFirstOpenCodeRestore(content)) return false
+ return content.status === 'creating' || content.status === 'recovering'
+}
+```
+
+Use this guard in the no-`terminalId` branch before sending a create:
+
+```ts
+} else {
+ deferredAttachStateRef.current = {
+ mode: 'none',
+ pendingIntent: null,
+ pendingSinceSeq: 0,
+ }
+ if (!shouldCreateTerminalImmediately(contentRef.current)) {
+ setIsAttaching(false)
+ return
+ }
+ sendCreateForCurrentContent(createRequestId)
+}
+```
+
+This is not a fallback. It is the terminal lifecycle boundary: only `creating`/`recovering` content may launch immediately, while valid queued OpenCode restores launch through the visibility transition and invalid/error/exited states do not launch.
+
+Extract the existing create sender into a `useCallback` helper outside the create/attach effect so it can be called from both the initial lifecycle effect and the visibility effect:
+
+```ts
+const sendCreateForCurrentContent = useCallback((requestId: string) => {
+ const content = contentRef.current
+ if (!content) return
+ const recoveryIntent = getFreshRecoveryIntentForRequest(requestId)
+ const restore = recoveryIntent ? false : getRestoreFlagForRequest(requestId)
+ const createSessionState = getCreateSessionStateFromRef(contentRef)
+ launchAttemptRef.current = {
+ requestId,
+ restore,
+ ...(recoveryIntent ? { recoveryIntent } : {}),
+ attachReady: false,
+ attachOnCreatedEvenIfHidden: content.mode === 'opencode' && restore && !hiddenRef.current,
+ }
+ ws.send({
+ type: 'terminal.create',
+ requestId,
+ mode: content.mode,
+ shell: content.shell || 'system',
+ cwd: content.initialCwd,
+ ...(!recoveryIntent && createSessionState.sessionRef ? { sessionRef: createSessionState.sessionRef } : {}),
+ ...(!recoveryIntent && createSessionState.codexDurability ? { codexDurability: createSessionState.codexDurability } : {}),
+ ...(!recoveryIntent && createSessionState.liveTerminal ? { liveTerminal: createSessionState.liveTerminal } : {}),
+ tabId,
+ paneId: paneIdRef.current,
+ ...(restore ? { restore: true } : {}),
+ ...(recoveryIntent ? { recoveryIntent } : {}),
+ })
+}, [getFreshRecoveryIntentForRequest, getRestoreFlagForRequest, tabId, ws])
+```
+
+Read `hiddenRef.current` at create-send time for `attachOnCreatedEvenIfHidden`; do not use the render-time `hidden` prop in this helper. The helper is intentionally stable across visibility changes, so a stale prop capture would silently turn a visible-started restore into a hidden detached PTY.
+
+Move the current `getRestoreFlag` and `getFreshRecoveryIntent` side-channel reads into helpers outside the effect, so rate-limit retry and queued-reveal launch use the same sender:
+
+```ts
+const getRestoreFlagForRequest = useCallback((requestId: string) => {
+ if (restoreRequestIdRef.current !== requestId) {
+ restoreRequestIdRef.current = requestId
+ restoreFlagRef.current = consumeTerminalRestoreRequestId(requestId)
+ }
+ return restoreFlagRef.current
+}, [])
+
+const getFreshRecoveryIntentForRequest = useCallback((requestId: string) => {
+ if (freshRecoveryRequestIdRef.current !== requestId) {
+ freshRecoveryRequestIdRef.current = requestId
+ freshRecoveryIntentRef.current = consumeTerminalFreshRecoveryRequest(requestId)
+ }
+ return freshRecoveryIntentRef.current
+}, [])
+```
+
+Define these helper callbacks before `sendCreateForCurrentContent` in the file. This preserves the existing "consume when create is actually sent" behavior; hidden queued panes must not consume restore request ids while they are still queued. Rate-limit retry should reuse `sendCreateForCurrentContent(requestId)` so it does not create a second restore-consumption path. If `pendingDurableReplacementRef` or other recovery branches add a restore request id, they should continue doing so before updating `requestIdRef.current`, and the new sender should consume it on the subsequent create.
+
+Add a second helper:
+
+```ts
+const launchedQueuedRestoreRequestIdsRef = useRef>(new Set())
+
+const launchQueuedOpenCodeRestoreIfVisible = useCallback(() => {
+ const content = contentRef.current
+ if (!isQueuedVisibleFirstOpenCodeRestore(content)) return false
+ if (hiddenRef.current) return false
+ const createRequestId = content.createRequestId
+ if (launchedQueuedRestoreRequestIdsRef.current.has(createRequestId)) return true
+ launchedQueuedRestoreRequestIdsRef.current.add(createRequestId)
+ addTerminalRestoreRequestId(createRequestId)
+ updateContent({
+ status: 'creating',
+ queuedRestore: undefined,
+ restoreError: undefined,
+ })
+ const currentTab = tabRef.current
+ if (currentTab) {
+ dispatch(updateTab({ id: currentTab.id, updates: { status: 'creating' } }))
+ }
+ sendCreateForCurrentContent(createRequestId)
+ return true
+}, [dispatch, sendCreateForCurrentContent, updateContent])
+```
+
+Inside the create/attach effect before the `currentTerminalId` decision, add:
+
+```ts
+if (isQueuedVisibleFirstOpenCodeRestore(contentRef.current)) {
+ if (!launchQueuedOpenCodeRestoreIfVisible()) {
+ deferredAttachStateRef.current = {
+ mode: 'none',
+ pendingIntent: null,
+ pendingSinceSeq: 0,
+ }
+ setIsAttaching(false)
+ }
+ return
+}
+```
+
+Do not consume the restore request id while hidden.
+
+In the existing `hidden`-dependent "When becoming visible" effect, call the same transition before existing attach/layout logic:
+
+```ts
+if (!hidden && launchQueuedOpenCodeRestoreIfVisible()) {
+ return
+}
+```
+
+This `hidden`-dependent call is the production path for tab activation, because `/home/user/code/freshell/.worktrees/dev/src/App.tsx` mounts each `TabContent` once and toggles `hidden` instead of remounting tabs.
+
+- [ ] **Step 4: Run the hidden-queued test green**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run -t "does not create hidden queued OpenCode restores"
+```
+
+Expected: PASS.
+
+### Task 3: Reveal Queued Restores Exactly Once
+
+- [ ] **Step 1: Write the failing reveal test**
+
+```ts
+it('revealing a queued OpenCode restore sends one restored create with the canonical sessionRef', async () => {
+ const sessionRef = { provider: 'opencode', sessionId: 'ses_queued_visible' } as const
+ const { store, tabId, paneId, rerender, requestId } = await renderTerminalHarness({
+ status: 'queued',
+ mode: 'opencode',
+ hidden: true,
+ requestId: 'req-opencode-queued-visible',
+ sessionRef,
+ renderFromStore: true,
+ queuedRestore: {
+ kind: 'until_visible',
+ provider: 'opencode',
+ reason: 'visible_owner_required',
+ },
+ clearSends: false,
+ })
+
+ wsMocks.send.mockClear()
+
+ const addedRestoreIds = new Set()
+ restoreMocks.addTerminalRestoreRequestId.mockImplementation((id: string) => {
+ addedRestoreIds.add(id)
+ })
+ restoreMocks.consumeTerminalRestoreRequestId.mockImplementation((id: string) => {
+ if (!addedRestoreIds.has(id)) return false
+ addedRestoreIds.delete(id)
+ return true
+ })
+
+ // Keep the component type identical across rerenders. This simulates the
+ // production tab path where App.tsx toggles hidden without remounting.
+ rerender(
+
+
+ ,
+ )
+
+ await waitFor(() => {
+ expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'terminal.create',
+ requestId,
+ mode: 'opencode',
+ sessionRef,
+ restore: true,
+ }))
+ })
+
+ const creates = wsMocks.send.mock.calls
+ .map(([msg]) => msg)
+ .filter((msg) => msg?.type === 'terminal.create' && msg?.requestId === requestId)
+ expect(creates).toHaveLength(1)
+ const layout = store.getState().panes.layouts[tabId]
+ expect(layout?.type).toBe('leaf')
+ if (layout?.type === 'leaf' && layout.content.kind === 'terminal') {
+ expect(layout.content.status).toBe('creating')
+ expect(layout.content.queuedRestore).toBeUndefined()
+ }
+ expect(store.getState().tabs.tabs.find((tab) => tab.id === tabId)?.status).toBe('creating')
+})
+```
+
+- [ ] **Step 2: Run the reveal test**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run -t "revealing a queued OpenCode restore"
+```
+
+Expected: FAIL until the `hidden`-dependent reveal effect launches queued restores without relying on remount.
+
+- [ ] **Step 3: Fix duplicate create risk**
+
+If the test sends twice, use the ref added in Task 2:
+
+```ts
+const launchedQueuedRestoreRequestIdsRef = useRef>(new Set())
+```
+
+Guard the reveal branch:
+
+```ts
+if (launchedQueuedRestoreRequestIdsRef.current.has(createRequestId)) return
+launchedQueuedRestoreRequestIdsRef.current.add(createRequestId)
+```
+
+Clear this set only on unmount by letting the component instance go away; do not persist it. Do not clear the old id when a recovery path mints a new `createRequestId`: the old id should remain suppressed, and the new id is intentionally allowed to launch once. The guard is same-pane dedupe, not same-session dedupe across multiple panes.
+
+- [ ] **Step 4: Run the reveal test green**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run -t "revealing a queued OpenCode restore"
+```
+
+Expected: PASS.
+
+### Task 4: Close the Visible-Create-Then-Hidden Race
+
+- [ ] **Step 1: Write the failing race test**
+
+```ts
+it('attaches an OpenCode restore created while visible even if hidden before terminal.created', async () => {
+ const sessionRef = { provider: 'opencode', sessionId: 'ses_visible_then_hidden' } as const
+ const { store, tabId, paneId, rerender, requestId } = await renderTerminalHarness({
+ status: 'queued',
+ mode: 'opencode',
+ hidden: false,
+ requestId: 'req-opencode-visible-race',
+ sessionRef,
+ renderFromStore: true,
+ queuedRestore: {
+ kind: 'until_visible',
+ provider: 'opencode',
+ reason: 'visible_owner_required',
+ },
+ clearSends: false,
+ })
+
+ await waitFor(() => {
+ expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'terminal.create',
+ requestId,
+ restore: true,
+ }))
+ })
+
+ wsMocks.send.mockClear()
+
+ rerender(
+
+
+ ,
+ )
+
+ act(() => {
+ messageHandler!({
+ type: 'terminal.created',
+ requestId,
+ terminalId: 'term-visible-started-opencode',
+ createdAt: Date.now(),
+ })
+ })
+
+ await waitFor(() => {
+ expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'terminal.attach',
+ terminalId: 'term-visible-started-opencode',
+ intent: 'viewport_hydrate',
+ sinceSeq: 0,
+ }))
+ })
+
+ const attach = wsMocks.send.mock.calls
+ .map(([msg]) => msg)
+ .find((msg) => msg?.type === 'terminal.attach' && msg?.terminalId === 'term-visible-started-opencode')
+ wsMocks.send.mockClear()
+
+ act(() => {
+ messageHandler!({
+ type: 'terminal.attach.ready',
+ terminalId: 'term-visible-started-opencode',
+ headSeq: 0,
+ replayFromSeq: 1,
+ replayToSeq: 0,
+ attachRequestId: attach.attachRequestId,
+ })
+ })
+
+ // Seed the cached viewport to the same geometry the fit will produce. The
+ // reveal resize must still send because hidden attach used fallback geometry.
+ seedLastSentViewportForTest('term-visible-started-opencode', { cols: 80, rows: 24 })
+
+ rerender(
+
+
+ ,
+ )
+
+ await waitFor(() => {
+ expect(wsMocks.send).toHaveBeenCalledWith(expect.objectContaining({
+ type: 'terminal.resize',
+ terminalId: 'term-visible-started-opencode',
+ }))
+ })
+})
+```
+
+Add the `seedLastSentViewportForTest` hook through `renderTerminalHarness` or another test-only harness seam; do not expose it in production UI props.
+
+- [ ] **Step 2: Run the race test red**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run -t "attaches an OpenCode restore created while visible"
+```
+
+Expected: FAIL because `terminal.created` currently branches on `hiddenRef.current` and defers attach.
+
+- [ ] **Step 3: Add attach obligation to launch attempts**
+
+Extend `LaunchAttemptState` in `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx`:
+
+```ts
+ attachOnCreatedEvenIfHidden?: boolean
+```
+
+If this was not already done while extracting `sendCreateForCurrentContent` in Task 2, set the attach obligation after computing `restore`:
+
+```ts
+const attachOnCreatedEvenIfHidden = mode === 'opencode'
+ && restore
+ && !hiddenRef.current
+```
+
+Store it in `launchAttemptRef.current`.
+
+In `terminal.created`, compute the attach obligation from the pre-overwrite launch snapshot before assigning a fresh `launchAttemptRef.current`:
+
+```ts
+const mustAttachNow = pendingLaunch?.requestId === reqId
+ && pendingLaunch.attachOnCreatedEvenIfHidden
+
+launchAttemptRef.current = {
+ requestId: reqId,
+ restore: pendingLaunch?.restore ?? false,
+ attachReady: false,
+ ...(mustAttachNow ? { attachOnCreatedEvenIfHidden: true } : {}),
+}
+```
+
+Then replace:
+
+```ts
+if (hiddenRef.current) {
+```
+
+with:
+
+```ts
+if (hiddenRef.current && !mustAttachNow) {
+```
+
+For `mustAttachNow`, call:
+
+```ts
+attachTerminal(newId, 'viewport_hydrate', {
+ clearViewportFirst: true,
+ skipPreAttachFit: true,
+})
+```
+
+Because this attach happens while the pane may be hidden, record that the terminal needs a real geometry resize on reveal:
+
+```ts
+const resizeAfterHiddenOpenCodeRestoreAttachRef = useRef>(new Set())
+
+if (mustAttachNow && hiddenRef.current) {
+ resizeAfterHiddenOpenCodeRestoreAttachRef.current.add(newId)
+}
+```
+
+Extend `requestTerminalLayout`/`pendingLayoutWorkRef` with a one-shot `forceResize?: boolean` flag. `forceResize` is sticky across coalesced layout requests: a later non-forced call may add `fit`, `resize`, `scrollToBottom`, or `focus`, but it must not clear an already pending forced resize. When `forceResize` is set, `flushScheduledLayout` must send `terminal.resize` after `runtime.fit()` even if the new geometry matches `lastSentViewportRef`; clear the flag only after a visible flush has attempted the forced resize. If the pane becomes hidden again before the flush runs, leave `fit`, `resize`, and `forceResize` pending so the next reveal still sends the resize. This is the only place in this plan that may bypass the cached viewport suppression.
+
+In the `hidden`-dependent visibility effect, after queued-launch handling and before the generic layout return, send a forced fit+resize when the pane becomes visible:
+
+```ts
+if (!hidden) {
+ const tid = terminalIdRef.current
+ if (tid && resizeAfterHiddenOpenCodeRestoreAttachRef.current.delete(tid)) {
+ requestTerminalLayout({ fit: true, resize: true, forceResize: true })
+ return
+ }
+}
+```
+
+This keeps the immediate attach obligation deterministic without freezing OpenCode at the fallback hidden geometry. A test must assert an actual `terminal.resize` message is sent on reveal even when `lastSentViewportRef` already matches the fitted geometry.
+
+Clean up `resizeAfterHiddenOpenCodeRestoreAttachRef` whenever the terminal id is replaced, killed/exited, or the component unmounts:
+
+```ts
+resizeAfterHiddenOpenCodeRestoreAttachRef.current.delete(oldTerminalId)
+resizeAfterHiddenOpenCodeRestoreAttachRef.current.clear() // unmount cleanup
+```
+
+Only visible-started OpenCode restores get `attachOnCreatedEvenIfHidden`. Recovery-driven creates sent while already hidden should keep the existing deferred-attach behavior unless a separate test proves they need the same obligation.
+
+- [ ] **Step 4: Run race test green**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run -t "attaches an OpenCode restore created while visible"
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Run full lifecycle test file**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/components/TerminalView.tsx test/unit/client/components/TerminalView.lifecycle.test.tsx
+git commit -m "fix: launch opencode restores only when visible"
+```
+
+---
+
+## Chunk 3: Constrain Replay-Gap Safety Net
+
+### File Structure
+
+- Modify: `/home/user/code/freshell/.worktrees/dev/src/store/paneTypes.ts`
+ - Add a narrow restore-attempt lease for OpenCode restored PTYs that may be replaced if their initial viewport hydrate is unrecoverable.
+- Modify: `/home/user/code/freshell/.worktrees/dev/src/store/paneRestoreState.ts`
+ - Sanitize restore-attempt leases without widening the durable pane contract.
+- Modify: `/home/user/code/freshell/.worktrees/dev/src/store/persistMiddleware.ts`
+ - Persist only validated same-server restore-attempt lease candidates; dead-handle cleanup strips them before queued repair.
+- Modify: `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx`
+ - Set the lease only for restored OpenCode creates that can still be safely replaced.
+ - Require the lease before the kill/recreate safety net runs.
+- Test: `/home/user/code/freshell/.worktrees/dev/test/unit/client/components/TerminalView.lifecycle.test.tsx`
+- Test: `/home/user/code/freshell/.worktrees/dev/test/unit/client/store/panesPersistence.test.ts`
+
+### Task 5: Mark Replaceable Restored OpenCode PTYs
+
+- [ ] **Step 1: Write failing non-kill test**
+
+```ts
+it('does not kill an unowned OpenCode terminal on viewport replay gap', async () => {
+ const sessionRef = { provider: 'opencode', sessionId: 'ses_live_busy' } as const
+ const { store, tabId, paneId, terminalId } = await renderTerminalHarness({
+ status: 'running',
+ terminalId: 'term-opencode-live-gap',
+ mode: 'opencode',
+ sessionRef,
+ clearSends: false,
+ })
+
+ const attach = wsMocks.send.mock.calls
+ .map(([msg]) => msg)
+ .find((msg) => msg?.type === 'terminal.attach' && msg?.terminalId === terminalId)
+
+ wsMocks.send.mockClear()
+ act(() => {
+ messageHandler!({
+ type: 'terminal.output.gap',
+ terminalId,
+ fromSeq: 1,
+ toSeq: 100,
+ reason: 'replay_window_exceeded',
+ attachRequestId: attach.attachRequestId,
+ } as any)
+ })
+
+ expect(wsMocks.send).not.toHaveBeenCalledWith({
+ type: 'terminal.kill',
+ terminalId,
+ })
+ const layout = store.getState().panes.layouts[tabId]
+ expect(layout?.type).toBe('leaf')
+ if (layout?.type === 'leaf') {
+ expect(layout.id).toBe(paneId)
+ expect(layout.content.kind).toBe('terminal')
+ if (layout.content.kind === 'terminal') {
+ expect(layout.content.status).toBe('running')
+ expect(layout.content.restoreRuntime).toBeUndefined()
+ }
+ }
+})
+```
+
+Expected red today if the current PR #346 guard kills any OpenCode pane with `sessionRef`.
+
+- [ ] **Step 2: Add restore-attempt lease type**
+
+In `/home/user/code/freshell/.worktrees/dev/src/store/paneTypes.ts`:
+
+```ts
+export type TerminalRestoreRuntime = {
+ replaceOnViewportReplayGap: true
+ createRequestId: string
+ terminalId: string
+ serverInstanceId: string
+}
+```
+
+Add to `TerminalPaneContent`:
+
+```ts
+ /** Restore-attempt lease for a restored PTY that is still safe to replace if initial replay is unrecoverable. */
+ restoreRuntime?: TerminalRestoreRuntime
+```
+
+Keep this lease narrow. It does not need a `provider` field because it is only valid on `mode: 'opencode'` pane content with `sessionRef.provider === 'opencode'`. It does need `serverInstanceId` so a browser refresh can preserve an in-flight same-server lease without letting that lease survive a server restart. Do not treat `sessionRef` or `terminalId` alone as proof of ownership.
+
+- [ ] **Step 3: Normalize restore-attempt lease**
+
+In `panesSlice.ts`, add a sanitizer near the queued-restore sanitizer and call it from `normalizePaneContent`:
+
+```ts
+function sanitizeTerminalRestoreRuntime(input: unknown): TerminalRestoreRuntime | undefined {
+ const value = input as Partial | undefined
+ if (
+ value?.replaceOnViewportReplayGap === true
+ && typeof value.createRequestId === 'string'
+ && value.createRequestId.length > 0
+ && typeof value.terminalId === 'string'
+ && value.terminalId.length > 0
+ && typeof value.serverInstanceId === 'string'
+ && value.serverInstanceId.length > 0
+ ) {
+ return {
+ replaceOnViewportReplayGap: true,
+ createRequestId: value.createRequestId,
+ terminalId: value.terminalId,
+ serverInstanceId: value.serverInstanceId,
+ }
+ }
+ return undefined
+}
+```
+
+Only preserve `restoreRuntime` when:
+
+```ts
+mode === 'opencode'
+&& sessionRef?.provider === 'opencode'
+&& restoreRuntime.replaceOnViewportReplayGap === true
+&& typeof restoreRuntime.createRequestId === 'string'
+&& typeof restoreRuntime.terminalId === 'string'
+&& typeof restoreRuntime.serverInstanceId === 'string'
+&& restoreRuntime.createRequestId === createRequestId
+&& restoreRuntime.terminalId === terminalId
+&& restoreRuntime.serverInstanceId === serverInstanceId
+```
+
+Add it explicitly to `normalizePaneContent`'s terminal return object, just like `queuedRestore`. This is the Chunk 3 extension point for the shared helper created in Chunk 1; until this chunk lands, the helper is allowed to normalize only queued/error state and must not contain a half-implemented lease path.
+
+```ts
+const restoreRuntime = sanitizeTerminalRestoreRuntime((input as { restoreRuntime?: unknown }).restoreRuntime)
+const canKeepRestoreRuntime = mode === 'opencode'
+ && sessionRef?.provider === 'opencode'
+ && status !== 'queued'
+ && !invalidQueuedRestore
+ && Boolean(restoreRuntime)
+ && restoreRuntime?.createRequestId === createRequestId
+ && restoreRuntime?.terminalId === terminalId
+ && restoreRuntime?.serverInstanceId === serverInstanceId
+
+return {
+ // existing explicit fields...
+ ...(queuedRestore && canQueue ? { queuedRestore } : {}),
+ ...(canKeepRestoreRuntime ? { restoreRuntime } : {}),
+ initialCwd: typeof input.initialCwd === 'string' ? input.initialCwd : undefined,
+}
+```
+
+Do not let a restore-attempt lease survive a server restart or dead terminal handle. Do allow it to survive a same-server browser refresh while the restored PTY is still live and before the first successful `viewport_hydrate`; otherwise a refresh during initial OpenCode startup would remove the only ownership proof allowed to repair an unrecoverable replay gap.
+
+In this chunk, update the shared terminal restore-state helper introduced in Chunk 1 so every stale-runtime stripping path drops `restoreRuntime` before constructing either return path:
+
+```ts
+const {
+ terminalId: _terminalId,
+ createRequestId: _createRequestId,
+ status: _status,
+ queuedRestore: _queuedRestore,
+ restoreRuntime: _restoreRuntime,
+ ...rest
+} = content
+```
+
+Keep the Chunk 1 behavior that preserves `serverInstanceId` for non-OpenCode restores; only the OpenCode queued branch should strip `serverInstanceId`. A fresh `restoreRuntime` lease is authored in `terminal.created` for the new restored create.
+
+Update `/home/user/code/freshell/.worktrees/dev/src/store/persistMiddleware.ts` so `stripTransientSessionFields` preserves only a structurally valid same-server lease candidate and strips malformed lease payloads:
+
+```ts
+if (content.kind === 'terminal') {
+ const normalized = normalizePersistedTerminalContentForBoot(content)
+ const sessionRef = sanitizeSessionRef(normalized.sessionRef)
+ const { resumeSessionId: _resumeSessionId, sessionRef: _legacySessionRef, sessionId: _sessionId, ...rest } = normalized
+ return {
+ ...rest,
+ ...(sessionRef ? { sessionRef } : {}),
+ }
+}
+```
+
+The read path must be sanitized too, because users may already have persisted layouts containing malformed or stale-looking `restoreRuntime`. Add the lease persistence test in `/home/user/code/freshell/.worktrees/dev/test/unit/client/store/panesPersistence.test.ts` in this chunk, after `TerminalRestoreRuntime` and `sanitizeTerminalRestoreRuntime` exist:
+
+```ts
+it('preserves a valid same-server OpenCode restore lease candidate with its live handle', () => {
+ localStorage.setItem('freshell.layout.v3', JSON.stringify({
+ version: 3,
+ tabs: { tabs: [{ id: 'tab-opencode', title: 'OpenCode' }], activeTabId: 'tab-opencode' },
+ panes: {
+ version: PANES_SCHEMA_VERSION,
+ layouts: {
+ 'tab-opencode': {
+ type: 'leaf',
+ id: 'pane-opencode',
+ content: {
+ kind: 'terminal',
+ mode: 'opencode',
+ shell: 'system',
+ status: 'running',
+ terminalId: 'term-live-refresh',
+ createRequestId: 'req-live-refresh',
+ serverInstanceId: 'server-same',
+ sessionRef: { provider: 'opencode', sessionId: 'ses_root_1' },
+ restoreRuntime: {
+ replaceOnViewportReplayGap: true,
+ createRequestId: 'req-live-refresh',
+ terminalId: 'term-live-refresh',
+ serverInstanceId: 'server-same',
+ },
+ },
+ },
+ },
+ activePane: { 'tab-opencode': 'pane-opencode' },
+ paneTitles: {},
+ paneTitleSetByUser: {},
+ },
+ tombstones: [],
+ }))
+
+ const loaded = loadPersistedPanes()
+ const layout = loaded!.layouts['tab-opencode']
+ expect(layout.type).toBe('leaf')
+ expect(layout.content).toMatchObject({
+ kind: 'terminal',
+ mode: 'opencode',
+ status: 'running',
+ terminalId: 'term-live-refresh',
+ createRequestId: 'req-live-refresh',
+ serverInstanceId: 'server-same',
+ sessionRef: { provider: 'opencode', sessionId: 'ses_root_1' },
+ restoreRuntime: {
+ replaceOnViewportReplayGap: true,
+ createRequestId: 'req-live-refresh',
+ terminalId: 'term-live-refresh',
+ serverInstanceId: 'server-same',
+ },
+ })
+})
+```
+
+Add companion tests proving malformed candidates are stripped and `clearDeadTerminals` strips even valid candidates when the terminal is not live.
+
+- [ ] **Step 4: Set lease on restored OpenCode create**
+
+Do not rely only on Redux propagation for the server instance id. On cold boot, `WsClient` can receive `ready`, populate its own `serverInstanceId`, flush queued `terminal.create`, and receive a fast `terminal.created` before `App.tsx` has pushed `connection.serverInstanceId` through React into `TerminalView`. Add a local helper that reads the synchronous WebSocket client value first:
+
+```ts
+const getCurrentServerInstanceId = useCallback(() => {
+ const fromWs = typeof ws.serverInstanceId === 'string' && ws.serverInstanceId.trim()
+ ? ws.serverInstanceId
+ : undefined
+ return fromWs ?? serverInstanceIdRef.current
+}, [ws])
+```
+
+In `terminal.created`, when `pendingLaunch?.restore === true`, `mode === 'opencode'`, and `getCurrentServerInstanceId()` returns a non-empty string, include:
+
+```ts
+const restoreServerInstanceId = getCurrentServerInstanceId()
+restoreRuntime: {
+ replaceOnViewportReplayGap: true,
+ createRequestId: reqId,
+ terminalId: newId,
+ serverInstanceId: restoreServerInstanceId,
+},
+```
+
+Add a lifecycle test that simulates `ws.serverInstanceId` being set while Redux `connection.serverInstanceId` is still undefined and proves the resulting `terminal.created` handler writes `restoreRuntime.serverInstanceId`. This test should fail if the implementation reads only `serverInstanceIdRef.current`.
+
+Also snapshot the pane's current `createRequestId` on the attach generation so attach-completion code never compares create ids to attach ids and never needs a second terminal-id ownership map:
+
+```ts
+type CurrentAttach = {
+ // existing fields...
+ createRequestId?: string
+}
+
+currentAttachRef.current = {
+ requestId: attachRequestId,
+ intent,
+ terminalId: tid,
+ sinceSeq,
+ cols,
+ rows,
+ createRequestId: contentRef.current?.createRequestId,
+}
+```
+
+Clear the lease only inside `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx`'s existing `markAttachComplete()` helper, after `terminal.attach.ready`, `completedAttachOnFrame`, or `completedAttachOnGap` has determined that replay is complete and before hydration-queue notifications run. Do not duplicate this block in individual message handlers. The lease is cleared only after the first successful `viewport_hydrate` attach completes for the same terminal and the same create request:
+
+```ts
+const currentAttach = currentAttachRef.current
+const restoreRuntime = contentRef.current?.restoreRuntime
+if (
+ currentAttach?.intent === 'viewport_hydrate'
+ && restoreRuntime?.replaceOnViewportReplayGap === true
+ && restoreRuntime.terminalId === currentAttach.terminalId
+ && restoreRuntime.serverInstanceId === getCurrentServerInstanceId()
+ && currentAttach.createRequestId === restoreRuntime.createRequestId
+) {
+ updateContent({ restoreRuntime: undefined })
+}
+```
+
+Do not clear the lease on `terminal.output.gap`; the lease must remain available for the replacement decision.
+
+- [ ] **Step 5: Require restore-attempt ownership before replay-gap replacement**
+
+Change `beginOpenCodeReplacementAfterExit` or its caller so the kill/recreate path only runs when:
+
+```ts
+contentRef.current?.restoreRuntime?.replaceOnViewportReplayGap === true
+&& contentRef.current.restoreRuntime.terminalId === terminalId
+&& contentRef.current.restoreRuntime.createRequestId === contentRef.current.createRequestId
+&& contentRef.current.restoreRuntime.serverInstanceId === contentRef.current.serverInstanceId
+&& contentRef.current.restoreRuntime.serverInstanceId === getCurrentServerInstanceId()
+```
+
+If the lease is absent, do not kill and do not mark the pane errored. Leave live OpenCode terminals in the existing soft gap behavior: write the ordinary replay-gap notice, apply sequence state, and let fresh frames continue. This is important because an unowned OpenCode terminal may still be live and usable.
+
+```ts
+if (!canReplaceForReplayGap) {
+ // Existing non-destructive output-gap path continues here.
+ term.writeln('\r\n[Terminal output gap detected; some earlier output is no longer available]\r\n')
+ applySeqState(/* existing gap handling state */)
+ return
+}
+```
+
+Do not silently start a fresh terminal for an unowned gap. Also do not set `status: 'error'` unless the terminal actually exits or another existing unrecoverable-terminal path proves the PTY is gone.
+
+- [ ] **Step 6: Preserve migration repair for the current bad release window**
+
+Do not keep PR #346's broad `lastSeq === 0 && !terminalFirstOutputMarkedRef.current` compatibility guard. That runtime state is indistinguishable from a live user-owned OpenCode terminal that simply missed replay, so using it would either kill real work or make the non-kill test impossible to satisfy.
+
+Preserve migration repair only through explicit state:
+
+```ts
+const canReplaceForReplayGap = contentRef.current?.mode === 'opencode'
+ && contentRef.current?.sessionRef?.provider === 'opencode'
+ && contentRef.current?.restoreRuntime?.replaceOnViewportReplayGap === true
+ && contentRef.current.restoreRuntime.terminalId === terminalId
+ && contentRef.current.restoreRuntime.createRequestId === contentRef.current.createRequestId
+ && contentRef.current.restoreRuntime.serverInstanceId === contentRef.current.serverInstanceId
+ && contentRef.current.restoreRuntime.serverInstanceId === getCurrentServerInstanceId()
+```
+
+The deterministic migration is: Chunk 1 turns stale/dead OpenCode restore handles into queued visibility-gated restore intents, and Chunk 3 authors `restoreRuntime` at the point the restored `terminal.create` succeeds. Persisted pre-lease panes therefore do not need a stored lease; their next visible restore create gets one before any replay-gap replacement can run. Already-live, unowned PTYs from the current release window are treated conservatively as live terminals: they receive the soft output-gap notice and keep running, with no kill/recreate.
+
+Do not invent alternate ownership proofs from `seqStateRef`, `lastSeq`, first-output flags, or the mere presence of `sessionRef`.
+
+Update the existing repair test `recreates a restored OpenCode pane when visible viewport hydration cannot replay startup output` so its harness state includes:
+
+```ts
+restoreRuntime: {
+ replaceOnViewportReplayGap: true,
+ createRequestId: requestId,
+ terminalId,
+ serverInstanceId: 'server-test',
+}
+```
+
+Extend `renderTerminalHarness` with `restoreRuntime?: TerminalPaneContent['restoreRuntime']`. This makes the repair test and the new non-kill test intentionally different: the repair case has the restore-attempt lease; the live case does not.
+
+- [ ] **Step 7: Run safety-net tests**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run -t "viewport replay gap"
+```
+
+Expected: both the existing leased repair test and the new unowned non-kill test pass.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/store/paneTypes.ts src/store/paneRestoreState.ts src/store/persistMiddleware.ts src/components/TerminalView.tsx test/unit/client/components/TerminalView.lifecycle.test.tsx test/unit/client/store/panesPersistence.test.ts
+git commit -m "fix: constrain opencode replay-gap replacement"
+```
+
+---
+
+## Chunk 4: Restart And Sidebar Contract
+
+### File Structure
+
+- Modify: `/home/user/code/freshell/.worktrees/dev/test/e2e-browser/specs/opencode-restart-recovery.spec.ts`
+ - Change restart expectations: active visible OpenCode restores immediately; hidden OpenCode panes remain queued until clicked.
+ - Assert left/sidebar history still lists old OpenCode sessions.
+- Possibly modify: `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx`
+ - User-visible copy for queued visible-first restore, if a visible queued pane renders before transition.
+- Possibly modify: `/home/user/code/freshell/.worktrees/dev/src/components/Sidebar.tsx` or selectors under `/home/user/code/freshell/.worktrees/dev/src/store/selectors/`
+ - Only if tests prove queued no-PTY sessions disappear from the left pane.
+
+### Task 6: Update Browser Restart Expectations
+
+- [ ] **Step 1: Write failing browser e2e for queued hidden restart**
+
+In `/home/user/code/freshell/.worktrees/dev/test/e2e-browser/specs/opencode-restart-recovery.spec.ts`, replace the current post-restart block that starts with:
+
+```ts
+const afterRestart = await waitForRunningTerminals(input.page, survivingTabIds, previousTerminalIdsByTab)
+const afterByTab = new Map(afterRestart.map((snapshot) => [snapshot.tabId, snapshot]))
+```
+
+Do not wait for every surviving tab to be running before clicking hidden OpenCode tabs. The graceful mixed scenario adds the shell tab last, so the active tab after restart may be shell; assertions must not assume `activeTabId` is an OpenCode tab.
+
+Use this shape instead:
+
+```ts
+const activeTabId = await harness.getActiveTabId()
+const activeOpenCodeTabIds = survivingOpenCodeTabIds.includes(activeTabId) ? [activeTabId] : []
+const queuedOpenCodeTabIds = survivingOpenCodeTabIds.filter((tabId) => tabId !== activeTabId)
+
+const visibleOpenCodeAfterRestart = activeOpenCodeTabIds.length > 0
+ ? await waitForRunningTerminals(input.page, activeOpenCodeTabIds, previousTerminalIdsByTab)
+ : []
+const visibleOpenCodeByTab = new Map(visibleOpenCodeAfterRestart.map((snapshot) => [snapshot.tabId, snapshot]))
+
+for (const tabId of activeOpenCodeTabIds) {
+ const before = beforeByTab.get(tabId)
+ const after = visibleOpenCodeByTab.get(tabId)
+ expect(after?.mode).toBe('opencode')
+ expect(after?.sessionRef).toEqual(before?.sessionRef)
+ expect(after?.terminalId).toBeTruthy()
+ expect(after?.terminalId).not.toBe(before?.terminalId)
+ expect(after?.sessionRef?.sessionId).toMatch(/^ses_root_/)
+}
+
+const queuedSnapshots = await getPaneSnapshots(input.page, queuedOpenCodeTabIds)
+for (const snapshot of queuedSnapshots) {
+ expect(snapshot.status).toBe('queued')
+ expect(snapshot.terminalId).toBeFalsy()
+ expect(snapshot.sessionRef?.sessionId).toMatch(/^ses_root_/)
+}
+
+if (input.includeShellPane) {
+ const [shellAfterRestart] = await waitForRunningTerminals(input.page, [shellTab.tabId], previousTerminalIdsByTab)
+ const before = beforeByTab.get(shellTab.tabId)
+ expect(shellAfterRestart?.terminalId).toBeTruthy()
+ expect(shellAfterRestart?.terminalId).not.toBe(before?.terminalId)
+ expect(shellAfterRestart?.sessionRef).toBeUndefined()
+}
+```
+
+Then click each queued OpenCode tab and assert it restores. Build the OpenCode snapshot list used later in the scenario from the active visible restore plus these clicked restores:
+
+```ts
+const restoredAfterClick: PaneSnapshot[] = []
+for (const tabId of queuedOpenCodeTabIds) {
+ await selectTab(input.page, tabId)
+ const [snapshot] = await waitForOpenCodeSessions(input.page, [tabId])
+ expect(snapshot.terminalId).toBeTruthy()
+ expect(snapshot.sessionRef).toEqual(beforeByTab.get(tabId)?.sessionRef)
+ expect(snapshot.terminalId).not.toBe(beforeByTab.get(tabId)?.terminalId)
+ restoredAfterClick.push(snapshot)
+}
+
+const postRestartOpenCode = [
+ ...visibleOpenCodeAfterRestart,
+ ...restoredAfterClick,
+]
+expect(new Set(postRestartOpenCode.map((snapshot) => snapshot.tabId))).toEqual(new Set(survivingOpenCodeTabIds))
+```
+
+Use existing helper names where available; add `harness.getActiveTabId` or equivalent only if no helper exists.
+
+After this rewrite, update the rest of `runRestartScenario` to consume `postRestartOpenCode` instead of the old `afterRestart`/`afterByTab` OpenCode assumptions:
+
+- Remove the loop that requires every `survivingOpenCodeTabIds` entry to already have a post-restart `terminalId` before queued tabs are clicked.
+- Remove `const postRestartOpenCode = afterRestart.filter((snapshot) => snapshot.mode === 'opencode')`; it is now built after clicking queued OpenCode tabs.
+- Keep the closed-tab assertion unchanged.
+- Keep `sendInputToTerminals(input.page, postRestartOpenCode, 'after-restart')` and `waitForStdinAudit(...)`, but run them only after `postRestartOpenCode` contains every surviving OpenCode session.
+- Keep the `restoreCreates` session-id assertion after all queued tabs have been clicked. It should still equal `expectedSessionIds`, because all surviving OpenCode sessions have then been launched exactly once.
+- Keep shell recovery assertions scoped to the shell tab only; do not include hidden queued OpenCode tabs in `waitForRunningTerminals` before click.
+
+- [ ] **Step 2: Run browser e2e red**
+
+Run:
+
+```bash
+npm run test:e2e:chromium -- test/e2e-browser/specs/opencode-restart-recovery.spec.ts
+```
+
+Expected: FAIL before visible-first implementation is complete because hidden OpenCode panes still restore immediately or because helpers do not yet expose queued state.
+
+- [ ] **Step 3: Fix helper/state exposure if needed**
+
+If `getPaneSnapshots` omits `status` or `queuedRestore`, extend the browser harness snapshot shape in `/home/user/code/freshell/.worktrees/dev/test/e2e-browser/helpers/test-harness.ts` only for test visibility.
+
+- [ ] **Step 4: Assert sidebar/history rows remain present**
+
+Add an assertion after restart and before clicking queued OpenCode tabs:
+
+```ts
+const expectedHistoryRows = survivingOpenCodeTabIds.map((tabId) => ({
+ tabId,
+ sessionId: beforeByTab.get(tabId)?.sessionRef?.sessionId,
+}))
+
+for (const row of expectedHistoryRows) {
+ expect(row.sessionId, `missing sessionRef for ${row.tabId}`).toBeTruthy()
+ const fakeOpenCodeHistoryTitle = `Root ${row.sessionId}`
+ await expect(input.page.getByRole('button', {
+ name: new RegExp(`^${escapeRegExp(fakeOpenCodeHistoryTitle)}\\b`, 'i'),
+ })).toBeVisible()
+}
+```
+
+Use the visible title rendered by the fake OpenCode session metadata (`/home/user/code/freshell/.worktrees/dev/test/e2e-browser/fixtures/fake-opencode.cjs` currently seeds `Root ${rootSessionId}`), not the tab title. Add a local `escapeRegExp` helper if the spec does not already have one. If the fixture title shape changes, compute the title from that fixture metadata and keep the assertion role-based and session-specific. Do not weaken this to checking Redux state only; the requirement is left-pane visibility.
+
+- [ ] **Step 5: Run browser e2e green**
+
+Run:
+
+```bash
+npm run test:e2e:chromium -- test/e2e-browser/specs/opencode-restart-recovery.spec.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git status --short
+git add test/e2e-browser/specs/opencode-restart-recovery.spec.ts
+# Add these only if this chunk actually changed them:
+# git add test/e2e-browser/helpers/test-harness.ts src/components/TerminalView.tsx src/components/Sidebar.tsx src/store/selectors/.ts
+git commit -m "test: cover visible-first opencode restart restores"
+```
+
+Only add `src/components/Sidebar.tsx` or selector files if the implementation actually required them.
+
+---
+
+## Chunk 5: Multi-Client And Server Reuse Proofs
+
+### File Structure
+
+- Modify: `/home/user/code/freshell/.worktrees/dev/test/server/ws-terminal-create-session-repair.test.ts`
+ - Prove killed OpenCode terminal binding is released before restored create.
+ - Prove the existing canonical-session reuse path handles both sequential and simultaneous duplicate restored creates for the same OpenCode `sessionRef`.
+- Add or modify: `/home/user/code/freshell/.worktrees/dev/test/e2e/opencode-visible-first-restore-flow.test.tsx`
+ - Mock-WebSocket client test for same-pane duplicate create prevention.
+- Possibly modify: `/home/user/code/freshell/.worktrees/dev/src/components/TerminalView.tsx`
+ - Add same-pane dedupe guard if tests show duplicate creates.
+- Possibly modify: `/home/user/code/freshell/.worktrees/dev/server/ws-handler.ts` and `/home/user/code/freshell/.worktrees/dev/server/terminal-registry.ts`
+ - Extend the existing canonical-session reuse/binding path if the simultaneous-create characterization proves a real race; do not add a second session ownership table.
+
+### Task 7: Prove Replacement Cannot Reuse Killed Terminal
+
+- [ ] **Step 1: Write server test**
+
+Add a server-side test that:
+
+1. Creates an OpenCode terminal with `sessionRef`.
+2. Kills it with `terminal.kill`.
+3. Sends restored `terminal.create` for the same `sessionRef`.
+4. Asserts the new `terminal.created.terminalId` differs from the killed id.
+
+Use existing fake registry/test harness in `ws-terminal-create-session-repair.test.ts`.
+
+- [ ] **Step 2: Run server test**
+
+Run:
+
+```bash
+npm run test:vitest -- test/server/ws-terminal-create-session-repair.test.ts --run -t "opencode"
+```
+
+Expected: PASS if current `TerminalRegistry.kill()` release binding behavior is correct; FAIL if the create reuses stale binding.
+
+### Task 8: Prove Restore Launches Are Idempotent At The Right Layer
+
+- [ ] **Step 1: Write same-pane client flow test**
+
+Create `/home/user/code/freshell/.worktrees/dev/test/e2e/opencode-visible-first-restore-flow.test.tsx` or extend an existing terminal lifecycle flow test. Simulate one queued OpenCode pane that becomes visible, rerenders, hides, and becomes visible again before `terminal.created`.
+
+Expected:
+
+```ts
+const restoreCreatesForPaneRequestId = wsMocks.send.mock.calls
+ .map(([msg]) => msg)
+ .filter((msg) => msg?.type === 'terminal.create'
+ && msg.requestId === requestId
+ && msg.restore === true)
+expect(restoreCreatesForPaneRequestId).toHaveLength(1)
+```
+
+This test pins the `launchedQueuedRestoreRequestIdsRef` contract: a single pane instance sends at most one restored create for its current persisted `createRequestId`. Do not filter by a fresh `nanoid`; the matcher must compare against the pane's `requestId` returned by the harness.
+
+- [ ] **Step 2: Write server same-session reuse and race tests**
+
+First add a characterization for the existing sequential reuse path in `/home/user/code/freshell/.worktrees/dev/test/server/ws-terminal-create-session-repair.test.ts`. This may already pass because `/home/user/code/freshell/.worktrees/dev/server/ws-handler.ts` checks `getCanonicalRunningTerminalBySession` before and after async config loading:
+
+```ts
+const sessionRef = { provider: 'opencode', sessionId: 'ses_same_session' } as const
+sendCreate({ requestId: 'req-a', mode: 'opencode', restore: true, sessionRef })
+const first = await waitForCreated('req-a')
+sendCreate({ requestId: 'req-b', mode: 'opencode', restore: true, sessionRef })
+const second = await waitForCreated('req-b')
+expect(second.terminalId).toBe(first.terminalId)
+expect(fakePtySpawnCountForSession(sessionRef.sessionId)).toBe(1)
+```
+
+Then add the actual race characterization: hold the fake spawn/config path open, send `req-a` and `req-b` for the same canonical OpenCode `sessionRef` before either request can emit `terminal.created`, release the held create, and assert both request ids receive the same terminal id with one PTY spawn.
+
+```ts
+const sessionRef = { provider: 'opencode', sessionId: 'ses_same_session_race' } as const
+const hold = holdNextPtyCreateForSession(sessionRef.sessionId)
+sendCreate({ requestId: 'req-a', mode: 'opencode', restore: true, sessionRef })
+sendCreate({ requestId: 'req-b', mode: 'opencode', restore: true, sessionRef })
+hold.release()
+const first = await waitForCreated('req-a')
+const second = await waitForCreated('req-b')
+expect(second.terminalId).toBe(first.terminalId)
+expect(fakePtySpawnCountForSession(sessionRef.sessionId)).toBe(1)
+```
+
+If `holdNextPtyCreateForSession` or `fakePtySpawnCountForSession` do not already exist, add them in this chunk to the fake registry/harness used by `/home/user/code/freshell/.worktrees/dev/test/server/ws-terminal-create-session-repair.test.ts`. Keep the instrumentation test-only: for example, extend the local `FakeRegistry` with per-session create counts and a hold/release promise keyed by `opts.resumeSessionId`; do not add production-only counters. If the existing fake registry cannot hold at spawn time, hold the deterministic async path immediately before spawn, such as `FakeSessionRepairService.waitForSession`, and still assert `registry.createCallCount` or a per-session count proves one PTY create.
+
+If the sequential characterization passes but the simultaneous race fails, extend the existing canonical-session binding/reuse mechanism already used by `getCanonicalRunningTerminalBySession`, `repairLegacySessionOwners`, and `SessionBindingAuthority`. The fix should reserve an in-flight canonical create for `{ mode: 'opencode', sessionRef.sessionId }` inside the existing registry/binding architecture, then resolve all waiters to the created terminal id. Do not create a parallel OpenCode-only ownership table with separate kill/exit semantics.
+
+Server contract if the race is real:
+
+- For `terminal.create` with `restore: true`, `mode: 'opencode'`, and canonical `sessionRef.provider === 'opencode'`, canonicalize the session id through the existing session-binding validation before considering reuse.
+- If `getCanonicalRunningTerminalBySession` returns a live non-exited terminal, return that terminal id and do not spawn.
+- If an in-flight canonical create already exists in the existing binding/reuse mechanism, await it and return that terminal id for the later request id. This closes simultaneous-create races.
+- If the in-flight create rejects, clear the reservation and surface the original create error to all waiters; do not leave a permanently poisoned reservation.
+- If the prior PTY exited or was killed, existing binding release must happen before a later restored create can reuse anything, so Task 7's kill-then-restore path creates a new terminal id.
+- If the live PTY exists but has no attached client yet, still reuse it; each client/pane will attach after receiving `terminal.created`.
+- Existing ws-handler repair paths that recover or migrate an OpenCode terminal must either flow through the same canonical binding mechanism or deliberately bypass it with a test explaining why. The Task 8 server race test should cover the main restored-create path; add a second assertion if implementation discovery shows a separate repair path can create an equivalent terminal.
+
+The WebSocket handler remains responsible for emitting one `terminal.created` message per incoming request id, even when the terminal id is reused:
+
+```ts
+ws.send({
+ type: 'terminal.created',
+ requestId: incoming.requestId,
+ terminalId: reusedOrCreatedTerminalId,
+ createdAt,
+})
+```
+
+- [ ] **Step 3: Run flow tests red/green**
+
+Run:
+
+```bash
+npm run test:vitest -- test/e2e/opencode-visible-first-restore-flow.test.tsx --run
+npm run test:vitest -- test/server/ws-terminal-create-session-repair.test.ts --run -t "same OpenCode session"
+```
+
+Expected: the same-pane client test fails before the `TerminalView` guard and passes after it. The sequential server characterization may already pass; the simultaneous server race test is the proof that determines whether server code needs to change. If both server tests are already green, record them as characterization coverage and do not change server code.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git status --short
+git add test/server/ws-terminal-create-session-repair.test.ts test/e2e/opencode-visible-first-restore-flow.test.tsx src/components/TerminalView.tsx
+# Add server files only if the simultaneous-create race was red and the fix changed them:
+# git add server/ws-handler.ts server/terminal-registry.ts
+git commit -m "test: prove opencode restore reuse and replacement binding"
+```
+
+---
+
+## Chunk 6: Documentation, Verification, And Landing
+
+### File Structure
+
+- Modify: `/home/user/code/freshell/.worktrees/dev/docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md`
+ - Record visible-first restore as the chosen architecture and note the race/safety-net constraints.
+- Possibly modify: `/home/user/code/freshell/.worktrees/dev/docs/index.html`
+ - Only if visible queued restore becomes a user-facing UI mode worth documenting in the mock docs.
+
+### Task 9: Update Research Note
+
+- [ ] **Step 1: Add the decision record**
+
+In the OpenCode section, add:
+
+```md
+### 2026-05-18 visible-first restore decision
+
+Freshell now treats stale/dead OpenCode restores as visibility-gated restore intents, not background PTYs. This is required because OpenCode's TUI screen is terminal-rendered and cannot be reconstructed from HTTP metadata if startup terminal frames are missed. A visible-started OpenCode restore carries an immediate attach obligation: if the user switches away before `terminal.created`, Freshell still attaches once so the PTY has a terminal owner from startup.
+
+The core invariant is that durable session identity does not grant runtime ownership. `sessionRef` identifies what to restore; `terminalId` identifies a current PTY; only a current restore-attempt lease tied to `{ sessionRef, createRequestId, terminalId, serverInstanceId }` authorizes replacement. The lease may survive a browser refresh only if the same server instance and live terminal handle are preserved; it is invalidated by server restart or dead-handle repair. The replay-gap replacement path remains only as a constrained stale-restore repair while that lease is active. In-memory attach-generation state may snapshot the pane `createRequestId` only to decide when to retire `restoreRuntime`; it is not a separate durable contract or replacement authority.
+```
+
+- [ ] **Step 2: Update machine-readable contract**
+
+Add fields under `providers.opencode`:
+
+```json
+"hiddenRestorePolicy": "queue_until_visible",
+"visibleStartedRestoreAttachObligation": true,
+"queuedRestoreStatus": "queued",
+"queuedRestoreReason": "visible_owner_required",
+"durableSessionIdentityGrantsRuntimeOwnership": false,
+"runtimeReplacementAuthority": "restore_attempt_lease",
+"replayGapReplacementRequiresRestoreAttemptLease": true,
+"restoreAttemptLeaseFields": ["replaceOnViewportReplayGap", "createRequestId", "terminalId", "serverInstanceId"],
+"restoreAttemptLeaseSurvivesSameServerRefresh": true,
+"restoreAttemptLeaseInvalidatedByServerRestart": true,
+"restoredCreateReuseKey": "opencode.sessionRef.sessionId"
+```
+
+- [ ] **Step 3: Validate JSON block**
+
+Run:
+
+```bash
+node - <<'NODE'
+const fs = require('fs')
+const path = 'docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md'
+const text = fs.readFileSync(path, 'utf8')
+const match = text.match(/## Machine-readable contract\n```json\n([\s\S]*?)\n```/)
+if (!match) throw new Error('machine-readable contract block not found')
+JSON.parse(match[1])
+console.log('machine-readable contract JSON OK')
+NODE
+```
+
+Expected: `machine-readable contract JSON OK`.
+
+- [ ] **Step 4: Run focused verification**
+
+Run:
+
+```bash
+npm run typecheck
+npm run lint
+npm run test:vitest -- test/unit/client/store/panesSlice.test.ts --run
+npm run test:vitest -- test/unit/client/store/panesPersistence.test.ts --run
+npm run test:vitest -- test/unit/client/components/TerminalView.lifecycle.test.tsx --run
+npm run test:vitest -- test/unit/client/components --run -t "queued OpenCode|TabSwitcher|TabItem"
+npm run test:vitest -- test/e2e/opencode-startup-probes.test.tsx --run
+npm run test:vitest -- test/server/ws-terminal-create-session-repair.test.ts --run -t "opencode"
+npm run test:e2e:chromium -- test/e2e-browser/specs/opencode-restart-recovery.spec.ts
+```
+
+Expected:
+
+- `npm run typecheck`: exit 0.
+- `npm run lint`: exit 0; existing warnings are acceptable only if already present on dev before this branch.
+- All focused tests pass.
+
+- [ ] **Step 5: Check broad suite status**
+
+Run:
+
+```bash
+npm run test:status
+```
+
+If the coordinator is idle and no reusable green baseline is available, run the repo's coordinated check gate:
+
+```bash
+FRESHELL_TEST_SUMMARY="visible-first opencode restore" npm run check
+```
+
+Expected: typecheck and tests pass, or document pre-existing failures with proof.
+
+- [ ] **Step 6: Commit docs**
+
+```bash
+git add docs/lab-notes/2026-05-13-coding-cli-session-restore-research.md docs/index.html
+git commit -m "docs: record visible-first opencode restore contract"
+```
+
+Only include `docs/index.html` if it changed.
+
+- [ ] **Step 7: Open PR**
+
+Follow the repository branch model. Authored behavior changes start from `origin/main` and the PR targets `origin/main`; do not branch from `/home/user/code/freshell/.worktrees/dev` and do not open a PR with `--base dev`. If this plan depends on an OpenCode resilience prerequisite that is not yet on `origin/main`, pause and either land that prerequisite first or create an explicitly stacked PR against the prerequisite branch with user approval.
+
+```bash
+git push -u origin
+gh pr create --repo danshapiro/freshell --base main --head --title "Implement visible-first OpenCode restores" --body ""
+```
+
+- [ ] **Step 8: Land on dev without restarting self-hosted server**
+
+After the PR branch is pushed and verified, apply the PR head to `/home/user/code/freshell/.worktrees/dev` as integration-only consumption of the reviewed branch. Do not hide behavior changes in local-only `dev` commits. If applying the PR head needs semantic conflict resolution, stop and fix the PR branch or create a replacement PR.
+
+```bash
+cd /home/user/code/freshell/.worktrees/dev
+git status --short
+git fetch origin
+# Apply the fetched PR head using the repo's current dev integration practice.
+```
+
+Do not restart the self-hosted dev server unless the user explicitly says `APPROVED`.
+
+---
+
+## Implementation Notes And Guardrails
+
+- Do not implement this as a local `if (hidden) return` hack in `TerminalView`. The queued state must be visible in the pane model.
+- Do not start visibility-gated OpenCode restore PTYs unless an immediate visible terminal owner exists.
+- Do not generalize this to Codex or Claude Code.
+- Do not use redraw nudges, Ctrl-L, delayed resize, or larger replay buffers as restore contracts.
+- Preserve the runtime ownership invariant: `sessionRef` is durable identity, not authority to kill or replace a PTY.
+- Preserve a restore-attempt lease across browser refresh only when `createRequestId`, `terminalId`, and `serverInstanceId` all still match; strip it on dead-handle repair or server-instance change.
+- Do not remove or broaden the replay-gap safety net; it is valid only while the current restore-attempt lease is active.
+- Do not test reveal by remounting `TerminalView`; production toggles `hidden` on an already-mounted component.
+- Do not confuse server-side same-session reuse with destructive authority; reuse uses canonical `sessionRef`, replacement uses the restore-attempt lease.
+- When an OpenCode restore attaches while hidden because it was launched visible, send a real fit+resize on reveal.
+- Be explicit in tests about three states: queued restore, creating restore, running attached restore.
+- Preserve old sessions in the left pane even when no live terminal exists.
+- If a queued pane lacks canonical `sessionRef.provider === "opencode"`, it must not silently start a fresh terminal. Surface a restore-unavailable error path.
diff --git a/package.json b/package.json
index c1e1b8172..5dd83d695 100644
--- a/package.json
+++ b/package.json
@@ -51,7 +51,7 @@
"test:unit": "tsx scripts/testing/test-coordinator.ts run test:unit",
"test:integration": "tsx scripts/testing/test-coordinator.ts run test:integration",
"test:client": "tsx scripts/testing/test-coordinator.ts run test:client",
- "test:real:coding-cli-contracts": "cross-env FRESHELL_REAL_PROVIDER_CONTRACTS=1 npm run test:vitest -- test/integration/real/coding-cli-session-contract.test.ts",
+ "test:real:coding-cli-contracts": "cross-env FRESHELL_REAL_PROVIDER_CONTRACTS=1 npm run test:vitest -- --config vitest.server.config.ts test/integration/real/coding-cli-session-contract.test.ts --run --pool=forks --poolOptions.forks.singleFork=true",
"test:visible-first:contract": "vitest run test/unit/visible-first/acceptance-contract.test.ts test/unit/visible-first/protocol-harness.test.ts test/unit/lib/visible-first-acceptance-report.test.ts",
"test:all": "tsx scripts/testing/test-coordinator.ts run test:all",
"test:status": "tsx scripts/testing/test-coordinator.ts status",
diff --git a/scripts/audit-codex-app-server-schema.ts b/scripts/audit-codex-app-server-schema.ts
new file mode 100644
index 000000000..c55800d11
--- /dev/null
+++ b/scripts/audit-codex-app-server-schema.ts
@@ -0,0 +1,72 @@
+import { execFileSync } from 'node:child_process'
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import path from 'node:path'
+
+import {
+ CODEX_CLIENT_REQUEST_METHODS,
+ CODEX_RUNTIME_LEAF_VALUES,
+ CODEX_SCHEMA_VERSION,
+ CODEX_SERVER_NOTIFICATION_METHODS,
+ CODEX_SERVER_REQUEST_METHODS,
+ CODEX_THREAD_ITEM_VARIANTS,
+} from '../test/fixtures/coding-cli/codex-app-server/schema-inventory.js'
+
+type JsonSchema = {
+ oneOf?: Array<{ properties?: { method?: { enum?: string[] }; type?: { const?: string; enum?: string[] } } }>
+ definitions?: Record
+ enum?: string[]
+}
+
+function readSchema(filePath: string): JsonSchema {
+ return JSON.parse(readFileSync(filePath, 'utf8')) as JsonSchema
+}
+
+function methods(filePath: string): string[] {
+ return (readSchema(filePath).oneOf ?? [])
+ .map((entry) => entry.properties?.method?.enum?.[0])
+ .filter((value): value is string => Boolean(value))
+}
+
+function threadItemVariants(filePath: string): string[] {
+ const schema = readSchema(filePath).definitions?.ThreadItem
+ return (schema?.oneOf ?? [])
+ .map((entry) => entry.properties?.type?.const ?? entry.properties?.type?.enum?.[0])
+ .filter((value): value is string => Boolean(value))
+}
+
+function compare(label: string, expected: readonly string[], actual: readonly string[]): string[] {
+ const missing = expected.filter((value) => !actual.includes(value))
+ const added = actual.filter((value) => !expected.includes(value))
+ const messages: string[] = []
+ if (missing.length > 0) messages.push(`${label} missing from generated schema: ${missing.join(', ')}`)
+ if (added.length > 0) messages.push(`${label} added by generated schema: ${added.join(', ')}`)
+ return messages
+}
+
+const workDir = mkdtempSync(path.join(tmpdir(), 'freshell-codex-schema-audit-'))
+try {
+ const jsonDir = path.join(workDir, 'json')
+ execFileSync('codex', ['app-server', 'generate-json-schema', '--out', jsonDir], { stdio: 'inherit' })
+
+ const failures = [
+ ...compare('client request methods', CODEX_CLIENT_REQUEST_METHODS, methods(path.join(jsonDir, 'ClientRequest.json'))),
+ ...compare('server request methods', CODEX_SERVER_REQUEST_METHODS, methods(path.join(jsonDir, 'ServerRequest.json'))),
+ ...compare('server notification methods', CODEX_SERVER_NOTIFICATION_METHODS, methods(path.join(jsonDir, 'ServerNotification.json'))),
+ ...compare('thread item variants', CODEX_THREAD_ITEM_VARIANTS, threadItemVariants(path.join(jsonDir, 'codex_app_server_protocol.v2.schemas.json'))),
+ ]
+
+ const v2Schema = readSchema(path.join(jsonDir, 'codex_app_server_protocol.v2.schemas.json'))
+ const generatedReasoningEffort = v2Schema.definitions?.ReasoningEffort?.enum ?? []
+ failures.push(...compare('reasoning effort values', CODEX_RUNTIME_LEAF_VALUES.reasoningEffort, generatedReasoningEffort))
+
+ if (failures.length > 0) {
+ console.error(`Codex app-server schema inventory is stale. Checked-in inventory version: ${CODEX_SCHEMA_VERSION}`)
+ for (const failure of failures) console.error(`- ${failure}`)
+ process.exit(1)
+ }
+
+ console.log(`Codex app-server schema inventory matches checked-in ${CODEX_SCHEMA_VERSION} fixture.`)
+} finally {
+ rmSync(workDir, { recursive: true, force: true })
+}
diff --git a/scripts/run-standard-tests.ts b/scripts/run-standard-tests.ts
index db3b31969..fd8af348a 100644
--- a/scripts/run-standard-tests.ts
+++ b/scripts/run-standard-tests.ts
@@ -108,9 +108,11 @@ function classifySuitePath(token: string): SuiteName | null {
normalizedToken.startsWith('test/server/')
|| normalizedToken.startsWith('test/unit/server/')
|| normalizedToken.startsWith('test/integration/server/')
+ || normalizedToken.startsWith('test/integration/real/')
|| normalizedToken.includes('/test/server/')
|| normalizedToken.includes('/test/unit/server/')
|| normalizedToken.includes('/test/integration/server/')
+ || normalizedToken.includes('/test/integration/real/')
|| normalizedToken.endsWith('/test/integration/session-repair.test.ts')
|| normalizedToken.endsWith('/test/integration/session-search-e2e.test.ts')
|| normalizedToken.endsWith('/test/integration/extension-system.test.ts')
diff --git a/server/agent-api/layout-schema.ts b/server/agent-api/layout-schema.ts
index 696b2048f..5c72e7477 100644
--- a/server/agent-api/layout-schema.ts
+++ b/server/agent-api/layout-schema.ts
@@ -1,8 +1,88 @@
import { z } from 'zod'
import { SessionLocatorSchema } from '../../shared/ws-protocol.js'
+const FreshAgentContentSchema = z.object({
+ kind: z.literal('fresh-agent'),
+ sessionType: z.string().min(1),
+ provider: z.string().min(1),
+ createRequestId: z.string().min(1),
+ status: z.string().min(1),
+ sessionId: z.string().optional(),
+ resumeSessionId: z.string().optional(),
+ sessionRef: z.object({ provider: z.string().min(1), sessionId: z.string().min(1) }).optional(),
+ restoreError: z.object({ code: z.string().min(1), reason: z.string().min(1) }).optional(),
+ initialCwd: z.string().optional(),
+ model: z.string().optional(),
+ modelSelection: z.object({ kind: z.string().min(1), modelId: z.string().min(1) }).optional().or(z.null()),
+ permissionMode: z.string().optional(),
+ sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(),
+ effort: z.string().optional(),
+ plugins: z.array(z.string()).optional(),
+ settingsDismissed: z.boolean().optional(),
+}).passthrough().refine(
+ (v) => !(v.sessionRef && v.restoreError),
+ { message: 'sessionRef and restoreError are mutually exclusive' },
+)
+
const PaneNodeSchema: z.ZodType = z.lazy(() => z.union([
- z.object({ type: z.literal('leaf'), id: z.string(), content: z.record(z.string(), z.any()) }),
+ z.object({
+ type: z.literal('leaf'),
+ id: z.string(),
+ content: z.union([
+ z.object({
+ kind: z.literal('terminal'),
+ createRequestId: z.string(),
+ status: z.string(),
+ mode: z.string(),
+ terminalId: z.string().optional(),
+ shell: z.string().optional(),
+ resumeSessionId: z.string().optional(),
+ sessionRef: SessionLocatorSchema.optional(),
+ restoreError: z.object({ code: z.string(), reason: z.string() }).optional(),
+ initialCwd: z.string().optional(),
+ }).passthrough(),
+ z.object({
+ kind: z.literal('browser'),
+ browserInstanceId: z.string(),
+ url: z.string(),
+ devToolsOpen: z.boolean(),
+ }).passthrough(),
+ z.object({
+ kind: z.literal('editor'),
+ filePath: z.string().nullable(),
+ language: z.string().nullable(),
+ readOnly: z.boolean(),
+ content: z.string(),
+ viewMode: z.enum(['source', 'preview']),
+ }).passthrough(),
+ z.object({
+ kind: z.literal('picker'),
+ }).passthrough(),
+ FreshAgentContentSchema,
+ z.object({
+ kind: z.literal('agent-chat'),
+ provider: z.string(),
+ createRequestId: z.string(),
+ status: z.string(),
+ sessionId: z.string().optional(),
+ resumeSessionId: z.string().optional(),
+ sessionRef: SessionLocatorSchema.optional(),
+ restoreError: z.object({ code: z.string(), reason: z.string() }).optional(),
+ initialCwd: z.string().optional(),
+ modelSelection: z.object({ kind: z.string(), modelId: z.string() }).optional().or(z.null()),
+ permissionMode: z.string().optional(),
+ effort: z.string().optional(),
+ plugins: z.array(z.string()).optional(),
+ settingsDismissed: z.boolean().optional(),
+ }).passthrough(),
+ z.object({
+ kind: z.literal('extension'),
+ extensionName: z.string(),
+ props: z.record(z.string(), z.any()),
+ }).passthrough(),
+ z.object({ kind: z.string() }).passthrough(),
+ ]),
+ }),
z.object({
type: z.literal('split'),
id: z.string(),
diff --git a/server/agent-api/layout-store.ts b/server/agent-api/layout-store.ts
index a66570df0..0c02e69c7 100644
--- a/server/agent-api/layout-store.ts
+++ b/server/agent-api/layout-store.ts
@@ -100,6 +100,19 @@ export class LayoutStore {
}
}
+ if (content.kind === 'fresh-agent') {
+ switch (content.sessionType) {
+ case 'freshclaude':
+ return 'Freshclaude'
+ case 'freshcodex':
+ return 'Freshcodex'
+ case 'kilroy':
+ return 'Kilroy'
+ default:
+ return 'Fresh Agent'
+ }
+ }
+
if (content.kind === 'extension') {
return typeof content.extensionName === 'string' && content.extensionName
? content.extensionName
@@ -145,6 +158,12 @@ export class LayoutStore {
updateFromUi(snapshot: UiSnapshot, connectionId: string) {
this.snapshot = snapshot
this.sourceConnectionId = connectionId
+ for (const tab of snapshot.tabs) {
+ const leaves = this.collectLeaves(snapshot.layouts?.[tab.id], [])
+ for (const leaf of leaves) {
+ this.seedPaneTitle(tab.id, leaf.id, leaf.content)
+ }
+ }
}
getSourceConnectionId() {
@@ -265,7 +284,7 @@ export class LayoutStore {
return { kind: 'browser', url: opts.browser, devToolsOpen: false }
}
if (opts.editor) {
- return { kind: 'editor', filePath: opts.editor, language: null, readOnly: false, content: '', viewMode: 'source' }
+ return { kind: 'editor', filePath: opts.editor, language: null, readOnly: false, content: '', viewMode: 'source', wordWrap: true }
}
return { kind: 'terminal', terminalId: opts.terminalId }
}
diff --git a/server/agent-api/router.ts b/server/agent-api/router.ts
index 9330b1704..8eeb40ee8 100644
--- a/server/agent-api/router.ts
+++ b/server/agent-api/router.ts
@@ -3,54 +3,50 @@ import fs from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { nanoid } from 'nanoid'
import { allocateLocalhostPort } from '../local-port.js'
+import type { CodexLaunchPlan, CodexLaunchPlanner } from '../coding-cli/codex-app-server/launch-planner.js'
import {
- runCodexLaunchWithRetry,
- type CodexLaunchFactory,
- type CodexLaunchPlan,
- type CodexLaunchPlanner,
-} from '../coding-cli/codex-app-server/launch-planner.js'
+ planCodexLaunchWithRetry,
+} from '../coding-cli/codex-app-server/launch-retry.js'
import {
CodexLaunchConfigError,
getCodexSessionBindingReason,
normalizeCodexSandboxSetting,
} from '../coding-cli/codex-launch-config.js'
+import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../coding-cli/codex-app-server/restore-decision.js'
import { makeSessionKey } from '../coding-cli/types.js'
-import { buildFreshellTerminalEnv, type ProviderSettings, type TerminalEnvContext } from '../terminal-registry.js'
+import { terminalIdFromCreateError, type ProviderSettings, type TerminalInputResult } from '../terminal-registry.js'
import { MAX_TERMINAL_TITLE_OVERRIDE_LENGTH } from '../terminals-router.js'
-import { sanitizeSessionRef, type SessionRef } from '../../shared/session-contract.js'
+import { logger } from '../logger.js'
import { ok, approx, fail } from './response.js'
import { renderCapture } from './capture.js'
import { waitForMatch } from './wait-for.js'
import { resolveScreenshotOutputPath } from './screenshot-path.js'
+import { sanitizeSessionRef } from '../../shared/session-contract.js'
const truthy = (value: unknown) => value === true || value === 'true' || value === '1' || value === 'yes'
const SYNCABLE_TERMINAL_MODES = new Set(['claude', 'codex', 'opencode', 'gemini', 'kimi'])
+const log = logger.child({ component: 'agent-api' })
+const CODEX_INPUT_READY_WAIT_TIMEOUT_MS = 60_000
-function buildSessionRef(provider: unknown, sessionId: unknown): SessionRef | undefined {
- if (typeof provider !== 'string' || provider === 'shell') return undefined
- if (typeof sessionId !== 'string' || !sessionId) return undefined
- return sanitizeSessionRef({ provider, sessionId })
+class AgentRouteInputError extends Error {
+ constructor(message: string) {
+ super(message)
+ this.name = 'AgentRouteInputError'
+ }
}
-function resolveTerminalSessionRef({
- sessionRef,
- mode,
- resumeSessionId,
-}: {
- sessionRef?: unknown
- mode?: unknown
- resumeSessionId?: unknown
-}): SessionRef | undefined {
- return sanitizeSessionRef(sessionRef) ?? buildSessionRef(mode, resumeSessionId)
+function agentRouteErrorStatus(error: unknown): number {
+ return error instanceof CodexLaunchConfigError || error instanceof AgentRouteInputError ? 400 : 500
}
-function agentRouteErrorStatus(error: unknown): number {
- return error instanceof CodexLaunchConfigError ? 400 : 500
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
}
-async function shutdownUnownedCodexSidecar(sidecar: CodexLaunchPlan['sidecar'] | undefined): Promise {
- if (!sidecar) return
- await sidecar.shutdown().catch(() => undefined)
+function combineWithCleanupError(primary: unknown, cleanupError: unknown): Error {
+ const primaryMessage = errorMessage(primary)
+ const cleanupMessage = errorMessage(cleanupError)
+ return new Error(`${primaryMessage}; cleanup failed: ${cleanupMessage}`)
}
/**
@@ -79,80 +75,57 @@ async function resolveSpawnProviderSettings(
overrides: { permissionMode?: string; model?: string; sandbox?: string },
opts: {
cwd?: string
- terminalId?: string
- envContext?: TerminalEnvContext
- sessionRef?: SessionRef
+ resumeSessionId?: string
codexLaunchPlanner?: CodexLaunchPlanner
assertTerminalCreateAccepted?: () => void
} = {},
-): Promise<{
- sessionRef?: SessionRef
- providerSettings?: ProviderSettings
- codexLaunchBaseProviderSettings?: {
- model?: string
- sandbox?: string
- permissionMode?: string
- }
- codexSidecar?: CodexLaunchPlan['sidecar']
- codexLaunchFactory?: CodexLaunchFactory
-}> {
+): Promise<{ resumeSessionId?: string; providerSettings?: ProviderSettings; codexPlan?: CodexLaunchPlan }> {
const providerSettings = await resolveProviderSettings(mode, configStore, overrides)
- opts.assertTerminalCreateAccepted?.()
- const sessionRef = opts.sessionRef?.provider === mode ? opts.sessionRef : undefined
if (mode === 'codex') {
if (!opts.codexLaunchPlanner) {
- throw new Error('Codex terminal launch requires the shared app-server planner.')
+ throw new Error('Codex terminal launch requires the app-server launch planner.')
}
- if (!opts.terminalId) {
- throw new Error('Codex terminal launch requires a preallocated terminal id.')
- }
- const terminalId = opts.terminalId
- const codexLaunchFactory: CodexLaunchFactory = async (input) => opts.codexLaunchPlanner!.planCreate({
- cwd: input.cwd,
- terminalId: input.terminalId,
- env: buildFreshellTerminalEnv(input.terminalId, input.envContext),
- resumeSessionId: input.resumeSessionId,
- model: input.providerSettings?.model ?? providerSettings?.model,
- sandbox: normalizeCodexSandboxSetting(input.providerSettings?.sandbox ?? providerSettings?.sandbox),
- approvalPolicy: input.providerSettings?.permissionMode ?? providerSettings?.permissionMode,
- })
- const plan = await runCodexLaunchWithRetry(
- () => opts.codexLaunchPlanner!.planCreate({
+ opts.assertTerminalCreateAccepted?.()
+ const plan = await planCodexLaunchWithRetry({
+ planner: opts.codexLaunchPlanner,
+ logger: log,
+ input: {
cwd: opts.cwd,
- terminalId,
- env: buildFreshellTerminalEnv(terminalId, opts.envContext),
- resumeSessionId: sessionRef?.sessionId,
+ resumeSessionId: opts.resumeSessionId,
model: providerSettings?.model,
sandbox: normalizeCodexSandboxSetting(providerSettings?.sandbox),
approvalPolicy: providerSettings?.permissionMode,
- }),
- {
- shouldRetry: (error) => !(error instanceof CodexLaunchConfigError),
},
- )
+ })
return {
- ...(plan.sessionId ? {
- sessionRef: {
- provider: mode,
- sessionId: plan.sessionId,
- },
- } : {}),
- codexSidecar: plan.sidecar,
- codexLaunchFactory,
- codexLaunchBaseProviderSettings: providerSettings,
+ resumeSessionId: opts.resumeSessionId ? (plan.sessionId ?? opts.resumeSessionId) : undefined,
providerSettings: {
- codexAppServer: plan.remote,
+ codexAppServer: {
+ ...plan.remote,
+ sidecar: plan.sidecar,
+ deferLifecycleUntilPublished: true,
+ recovery: {
+ planCreate: (input) => opts.codexLaunchPlanner!.planCreate({
+ cwd: input.cwd ?? opts.cwd,
+ resumeSessionId: input.resumeSessionId,
+ model: providerSettings?.model,
+ sandbox: normalizeCodexSandboxSetting(providerSettings?.sandbox),
+ approvalPolicy: providerSettings?.permissionMode,
+ }),
+ },
+ },
},
+ codexPlan: plan,
}
}
if (mode !== 'opencode') {
return {
- ...(sessionRef ? { sessionRef } : {}),
+ resumeSessionId: opts.resumeSessionId,
providerSettings,
}
}
return {
- ...(sessionRef ? { sessionRef } : {}),
+ resumeSessionId: opts.resumeSessionId,
providerSettings: {
...(providerSettings ?? {}),
opencodeServer: await allocateLocalhostPort(),
@@ -160,10 +133,163 @@ async function resolveSpawnProviderSettings(
}
}
-function resolveRequestedSessionRef(mode: string, value: unknown): SessionRef | undefined {
- const sessionRef = sanitizeSessionRef(value)
- if (!sessionRef) return undefined
- return sessionRef.provider === mode ? sessionRef : undefined
+type ResolvedSpawnProviderSettings = Awaited>
+
+function requestedResumeSessionIdForMode(
+ sessionRef: ReturnType,
+ mode: string,
+ legacyResumeSessionId: unknown,
+): string | undefined {
+ const acceptedSessionRef = acceptedSessionRefForMode(sessionRef, mode)
+ if (acceptedSessionRef) return acceptedSessionRef.sessionId
+ if (mode === 'codex') {
+ if (isNonEmptyString(legacyResumeSessionId)) {
+ throw new AgentRouteInputError(INVALID_RAW_CODEX_RESUME_MESSAGE)
+ }
+ return undefined
+ }
+ return typeof legacyResumeSessionId === 'string' ? legacyResumeSessionId : undefined
+}
+
+function acceptedSessionRefForMode(
+ sessionRef: ReturnType,
+ mode: string,
+): ReturnType {
+ if (!sessionRef || sessionRef.provider !== mode) return undefined
+ return sessionRef
+}
+
+function isNonEmptyString(value: unknown): value is string {
+ return typeof value === 'string' && value.length > 0
+}
+
+async function adoptCodexLaunch(
+ launch: ResolvedSpawnProviderSettings | undefined,
+ terminalId: string,
+): Promise {
+ await launch?.codexPlan?.sidecar.adopt({ terminalId, generation: 0 })
+}
+
+async function cleanupUnadoptedCodexLaunch(launch: ResolvedSpawnProviderSettings | undefined): Promise {
+ await launch?.codexPlan?.sidecar.shutdown()
+}
+
+function publishCodexLaunch(registry: any, launch: ResolvedSpawnProviderSettings | undefined, terminalId: string): void {
+ if (!launch?.codexPlan) return
+ registry.publishCodexSidecar?.(terminalId)
+}
+
+function assertCodexCreateTerminalRunning(terminal: { status?: unknown }): void {
+ if (terminal.status === 'exited') {
+ throw new Error('Codex terminal PTY exited before create completed.')
+ }
+}
+
+function terminalInputFailureMessage(result: Exclude): string {
+ if (result.status === 'blocked_codex_identity_pending') {
+ return 'Codex restore identity is not ready yet.'
+ }
+ if (result.status === 'blocked_codex_identity_capture_timeout') {
+ return 'Codex restore identity timed out before input could be accepted.'
+ }
+ if (result.status === 'blocked_codex_identity_unavailable') {
+ return 'Codex restore identity could not be captured before input could be accepted.'
+ }
+ if (result.status === 'blocked_codex_recovery_pending') {
+ return 'Codex durable recovery is still in progress.'
+ }
+ return 'Terminal is not running.'
+}
+
+function shouldWaitForCodexIdentity(payload: Record): boolean {
+ return truthy(payload.waitForCodexIdentity)
+}
+
+function registrySupportsEvents(registry: any): registry is {
+ input: (terminalId: string, data: string) => TerminalInputResult
+ on: (event: string, handler: (...args: any[]) => void) => void
+ off: (event: string, handler: (...args: any[]) => void) => void
+} {
+ return typeof registry?.on === 'function' && typeof registry?.off === 'function'
+}
+
+async function sendTerminalInput(
+ registry: any,
+ terminalId: string,
+ data: string,
+ options: { waitForCodexIdentity?: boolean } = {},
+): Promise {
+ const first = registry.input(terminalId, data) as TerminalInputResult
+ if (first.status !== 'blocked_codex_identity_pending' || !options.waitForCodexIdentity) {
+ return first
+ }
+ if (!registrySupportsEvents(registry)) return first
+
+ return new Promise((resolve) => {
+ let settled = false
+ let timeout: ReturnType | undefined
+
+ const cleanup = () => {
+ if (timeout) clearTimeout(timeout)
+ registry.off('terminal.codex.durability.updated', onDurabilityUpdated)
+ registry.off('terminal.exit', onTerminalExit)
+ }
+ const finish = (result: TerminalInputResult) => {
+ if (settled) return
+ settled = true
+ cleanup()
+ resolve(result)
+ }
+ const retry = () => {
+ if (settled) return
+ const next = registry.input(terminalId, data) as TerminalInputResult
+ if (next.status === 'blocked_codex_identity_pending') return
+ finish(next)
+ }
+ const onDurabilityUpdated = (event: { terminalId?: string }) => {
+ if (event?.terminalId !== terminalId) return
+ retry()
+ }
+ const onTerminalExit = (event: { terminalId?: string }) => {
+ if (event?.terminalId !== terminalId) return
+ finish({ status: 'not_running' })
+ }
+
+ registry.on('terminal.codex.durability.updated', onDurabilityUpdated)
+ registry.on('terminal.exit', onTerminalExit)
+ timeout = setTimeout(() => {
+ finish({ status: 'blocked_codex_identity_capture_timeout', terminalId })
+ }, CODEX_INPUT_READY_WAIT_TIMEOUT_MS)
+ queueMicrotask(retry)
+ })
+}
+
+async function cleanupCreatedTerminal(registry: any, terminalId: string | undefined): Promise {
+ if (!terminalId) return
+ if (typeof registry?.killAndWait === 'function') {
+ await registry.killAndWait(terminalId)
+ return
+ }
+ if (typeof registry?.kill === 'function') {
+ registry.kill(terminalId)
+ }
+}
+
+async function cleanupFailedCodexCreate(
+ registry: any,
+ terminalId: string | undefined,
+ launch: ResolvedSpawnProviderSettings | undefined,
+): Promise {
+ const cleanupErrors: string[] = []
+ await cleanupCreatedTerminal(registry, terminalId).catch((error) => {
+ cleanupErrors.push(`created terminal cleanup failed: ${errorMessage(error)}`)
+ })
+ await cleanupUnadoptedCodexLaunch(launch).catch((error) => {
+ cleanupErrors.push(`Codex sidecar cleanup failed: ${errorMessage(error)}`)
+ })
+ if (cleanupErrors.length > 0) {
+ throw new Error(cleanupErrors.join('; '))
+ }
}
type ResizeLayoutStore = {
@@ -230,14 +356,8 @@ export function createAgentApiRouter({
assertTerminalCreateAccepted?: () => void
}) {
const router = Router()
- const assertTerminalAdmission = assertTerminalCreateAccepted ?? (() => undefined)
-
- const broadcastReplayableUiCommand = (command: { command: string; payload?: any }) => {
- if (typeof wsHandler?.broadcastUiCommandWithReplay === 'function') {
- wsHandler.broadcastUiCommandWithReplay(command)
- return
- }
- wsHandler?.broadcastUiCommand?.(command)
+ const assertTerminalAdmission = () => {
+ assertTerminalCreateAccepted?.()
}
const resolvePaneTarget = (raw: string) => {
@@ -314,10 +434,9 @@ export function createAgentApiRouter({
const meta = terminalId
? terminalMetadata?.list?.().find((entry) => entry.terminalId === terminalId)
: undefined
- const paneSessionRef = sanitizeSessionRef(paneContent?.sessionRef)
- const resumeSessionId = paneSessionRef?.sessionId ?? (typeof paneContent?.resumeSessionId === 'string'
+ const resumeSessionId = typeof paneContent?.resumeSessionId === 'string'
? paneContent.resumeSessionId
- : undefined)
+ : undefined
const modeCandidates = [
typeof paneContent?.mode === 'string' ? paneContent.mode : undefined,
terminalId ? registry.get?.(terminalId)?.mode : undefined,
@@ -335,7 +454,7 @@ export function createAgentApiRouter({
await configStore.patchTerminalOverride?.(terminalId, { titleOverride: title })
registry.updateTitle?.(terminalId, title)
- const sessionProvider = paneSessionRef?.provider ?? (typeof meta?.provider === 'string' ? meta.provider : mode)
+ const sessionProvider = typeof meta?.provider === 'string' ? meta.provider : mode
const sessionId = typeof meta?.sessionId === 'string' ? meta.sessionId : resumeSessionId
if (sessionProvider && sessionId) {
try {
@@ -356,11 +475,13 @@ export function createAgentApiRouter({
}
router.post('/tabs', async (req, res) => {
- const { name, mode, shell, cwd, browser, editor, resumeSessionId, sessionRef, permissionMode, model, sandbox } = req.body || {}
+ const { name, mode, shell, cwd, browser, editor, resumeSessionId, permissionMode, model, sandbox } = req.body || {}
+ const requestedSessionRef = sanitizeSessionRef(req.body?.sessionRef)
const wantsBrowser = !!browser
const wantsEditor = !!editor
- let rollbackTabId: string | undefined
- let unownedCodexSidecar: CodexLaunchPlan['sidecar'] | undefined
+ let launch: ResolvedSpawnProviderSettings | undefined
+ let createdTerminalId: string | undefined
+ let createdTabId: string | undefined
try {
let paneContent: any
@@ -369,91 +490,57 @@ export function createAgentApiRouter({
if (wantsBrowser) {
paneContent = { kind: 'browser', url: browser, devToolsOpen: false }
} else if (wantsEditor) {
- paneContent = { kind: 'editor', filePath: editor, language: null, readOnly: false, content: '', viewMode: 'source' }
+ paneContent = { kind: 'editor', filePath: editor, language: null, readOnly: false, content: '', viewMode: 'source', wordWrap: true }
} else {
const effectiveMode = mode || 'shell'
- const requestedSessionRef = resolveTerminalSessionRef({ sessionRef, mode: effectiveMode, resumeSessionId })
+ const acceptedSessionRef = acceptedSessionRefForMode(requestedSessionRef, effectiveMode)
+ const requestedResumeSessionId = requestedResumeSessionIdForMode(
+ requestedSessionRef,
+ effectiveMode,
+ resumeSessionId,
+ )
assertTerminalAdmission()
- const isCodexMode = effectiveMode === 'codex'
- const preallocatedTerminalId = isCodexMode ? nanoid() : undefined
- let tabId: string
- let paneId: string
- let launch: Awaited>
- if (isCodexMode) {
- tabId = nanoid()
- paneId = nanoid()
- launch = await resolveSpawnProviderSettings(
- effectiveMode,
- configStore,
- { permissionMode, model, sandbox },
- {
- cwd,
- terminalId: preallocatedTerminalId,
- envContext: { tabId, paneId },
- sessionRef: requestedSessionRef,
- codexLaunchPlanner,
- assertTerminalCreateAccepted: assertTerminalAdmission,
- },
- )
- unownedCodexSidecar = launch.codexSidecar
- layoutStore.createTab({
- title: name,
- terminalId: preallocatedTerminalId,
- tabId,
- paneId,
- })
- rollbackTabId = tabId
- } else {
- const created = layoutStore.createTab({ title: name })
- tabId = created.tabId
- paneId = created.paneId
- rollbackTabId = tabId
- launch = await resolveSpawnProviderSettings(
- effectiveMode,
- configStore,
- { permissionMode, model, sandbox },
- {
- cwd,
- envContext: { tabId, paneId },
- sessionRef: requestedSessionRef,
- codexLaunchPlanner,
- assertTerminalCreateAccepted: assertTerminalAdmission,
- },
- )
- }
- unownedCodexSidecar = launch.codexSidecar
- const launchSessionRef = launch.sessionRef ?? requestedSessionRef
- const sessionBindingReason = getCodexSessionBindingReason(effectiveMode, launchSessionRef?.sessionId)
+ launch = await resolveSpawnProviderSettings(
+ effectiveMode,
+ configStore,
+ { permissionMode, model, sandbox },
+ { cwd, resumeSessionId: requestedResumeSessionId, codexLaunchPlanner, assertTerminalCreateAccepted: assertTerminalAdmission },
+ )
+ assertTerminalAdmission()
+ const { tabId, paneId } = layoutStore.createTab({ title: name, browser, editor })
+ createdTabId = tabId
+ const sessionBindingReason = getCodexSessionBindingReason(effectiveMode, requestedResumeSessionId)
assertTerminalAdmission()
const terminal = registry.create({
- ...(preallocatedTerminalId ? { terminalId: preallocatedTerminalId } : {}),
mode: effectiveMode,
shell,
cwd,
- resumeSessionId: launchSessionRef?.sessionId,
+ resumeSessionId: launch.resumeSessionId,
...(sessionBindingReason ? { sessionBindingReason } : {}),
- ...(launch.codexSidecar ? { codexSidecar: launch.codexSidecar } : {}),
- ...(launch.codexLaunchFactory ? { codexLaunchFactory: launch.codexLaunchFactory } : {}),
- ...(launch.codexLaunchBaseProviderSettings
- ? { codexLaunchBaseProviderSettings: launch.codexLaunchBaseProviderSettings }
- : {}),
providerSettings: launch.providerSettings,
envContext: { tabId, paneId },
})
- unownedCodexSidecar = undefined
+ createdTerminalId = terminal.terminalId
+ const launchResumeSessionId = launch.resumeSessionId
+ assertTerminalAdmission()
+ await adoptCodexLaunch(launch, terminal.terminalId)
+ assertCodexCreateTerminalRunning(terminal)
+ assertTerminalAdmission()
+ publishCodexLaunch(registry, launch, terminal.terminalId)
+ launch = undefined
terminalId = terminal.terminalId
paneContent = {
kind: 'terminal',
terminalId,
status: 'running',
- mode: effectiveMode,
+ mode: mode || 'shell',
shell: shell || 'system',
- ...(launchSessionRef ? { sessionRef: launchSessionRef } : {}),
+ ...(acceptedSessionRef ? { sessionRef: acceptedSessionRef } : {}),
+ ...(launchResumeSessionId && !acceptedSessionRef ? { resumeSessionId: launchResumeSessionId } : {}),
initialCwd: cwd,
}
layoutStore.attachPaneContent(tabId, paneId, paneContent)
- rollbackTabId = undefined
wsHandler?.broadcastUiCommand({
command: 'tab.create',
@@ -464,6 +551,7 @@ export function createAgentApiRouter({
shell,
terminalId,
initialCwd: cwd,
+ ...(paneContent?.resumeSessionId ? { resumeSessionId: paneContent.resumeSessionId } : {}),
...(paneContent?.sessionRef ? { sessionRef: paneContent.sessionRef } : {}),
paneId,
paneContent,
@@ -471,10 +559,12 @@ export function createAgentApiRouter({
})
res.json(ok({ tabId, paneId, terminalId }, 'tab created'))
+ createdTerminalId = undefined
return
}
const { tabId, paneId } = layoutStore.createTab({ title: name, browser, editor })
+ createdTabId = tabId
layoutStore.attachPaneContent(tabId, paneId, paneContent)
wsHandler?.broadcastUiCommand({
@@ -486,6 +576,7 @@ export function createAgentApiRouter({
shell,
terminalId,
initialCwd: cwd,
+ resumeSessionId: paneContent?.resumeSessionId,
sessionRef: paneContent?.sessionRef,
paneId,
paneContent,
@@ -494,12 +585,19 @@ export function createAgentApiRouter({
res.json(ok({ tabId, paneId, terminalId }, 'tab created'))
} catch (err: any) {
- await shutdownUnownedCodexSidecar(unownedCodexSidecar)
- if (rollbackTabId) {
- layoutStore.closeTab?.(rollbackTabId)
+ let responseError = err
+ await cleanupFailedCodexCreate(registry, createdTerminalId ?? terminalIdFromCreateError(err), launch).catch((cleanupError) => {
+ responseError = combineWithCleanupError(err, cleanupError)
+ })
+ if (createdTabId && typeof layoutStore.closeTab === 'function') {
+ try {
+ layoutStore.closeTab(createdTabId)
+ } catch {
+ // best-effort cleanup; terminal/sidecar cleanup errors above remain authoritative
+ }
}
- const status = agentRouteErrorStatus(err)
- res.status(status).json(fail(err?.message || 'Failed to create tab'))
+ const status = agentRouteErrorStatus(responseError)
+ res.status(status).json(fail(responseError?.message || 'Failed to create tab'))
}
})
@@ -554,67 +652,6 @@ export function createAgentApiRouter({
res.json(ok({ tabs, activeTabId }))
})
- router.post('/terminals/:id/open', (req, res) => {
- const terminalId = typeof req.params.id === 'string' ? req.params.id.trim() : ''
- if (!terminalId) return res.status(400).json(fail('terminal id required'))
- const term = registry.get?.(terminalId)
- if (!term) return res.status(404).json(fail('terminal not found'))
-
- const existing = layoutStore.findPaneByTerminalId?.(terminalId)
- if (existing?.tabId && existing?.paneId) {
- const result = layoutStore.selectPane?.(existing.tabId, existing.paneId) || existing
- const tabId = result?.tabId || existing.tabId
- const paneId = result?.paneId || existing.paneId
- if (tabId && paneId) {
- broadcastReplayableUiCommand({
- command: 'tab.select',
- payload: { id: tabId },
- })
- broadcastReplayableUiCommand({
- command: 'pane.select',
- payload: { tabId, paneId },
- })
- }
- return res.json(ok({ tabId, paneId, terminalId, reused: true }, result?.message || 'terminal selected'))
- }
-
- if (!layoutStore.createTab || !layoutStore.attachPaneContent) {
- return res.status(503).json(fail('layout store does not support terminal attach'))
- }
-
- const title = parseRequiredName(req.body?.name) || term.title || terminalId
- const { tabId, paneId } = layoutStore.createTab({ title })
- const sessionRef = resolveTerminalSessionRef({
- sessionRef: term.sessionRef,
- mode: term.mode,
- resumeSessionId: term.resumeSessionId,
- })
- const paneContent = {
- kind: 'terminal',
- terminalId,
- status: term.status || 'running',
- mode: term.mode || 'shell',
- initialCwd: term.cwd,
- ...(sessionRef ? { sessionRef } : {}),
- }
- layoutStore.attachPaneContent(tabId, paneId, paneContent)
- broadcastReplayableUiCommand({
- command: 'tab.create',
- payload: {
- id: tabId,
- title,
- mode: term.mode || 'shell',
- terminalId,
- initialCwd: term.cwd,
- ...(sessionRef ? { sessionRef } : {}),
- paneId,
- paneContent,
- status: term.status || 'running',
- },
- })
- res.json(ok({ tabId, paneId, terminalId, reused: false }, 'terminal opened'))
- })
-
router.get('/panes', (req, res) => {
const tabId = req.query.tabId as string | undefined
const panes = layoutStore.listPanes?.(tabId) || []
@@ -843,61 +880,39 @@ export function createAgentApiRouter({
const rawTimeout = payload.timeout || payload.T
const timeoutSeconds = typeof rawTimeout === 'number' ? rawTimeout : Number(rawTimeout)
const timeoutMs = Number.isFinite(timeoutSeconds) ? timeoutSeconds * 1000 : 30000
- let rollbackTabId: string | undefined
- let unownedCodexSidecar: CodexLaunchPlan['sidecar'] | undefined
+ let launch: ResolvedSpawnProviderSettings | undefined
+ let createdTerminalId: string | undefined
+ let createdTabId: string | undefined
try {
assertTerminalAdmission()
- const isCodexMode = mode === 'codex'
- const preallocatedTerminalId = isCodexMode ? nanoid() : undefined
- let tabId: string
- let paneId: string
- let launch: Awaited>
- if (isCodexMode) {
- tabId = nanoid()
- paneId = nanoid()
- launch = await resolveSpawnProviderSettings(mode, configStore, {}, {
- cwd,
- terminalId: preallocatedTerminalId,
- envContext: { tabId, paneId },
- codexLaunchPlanner,
- assertTerminalCreateAccepted: assertTerminalAdmission,
- })
- unownedCodexSidecar = launch.codexSidecar
- const created = layoutStore.createTab?.({ title, terminalId: preallocatedTerminalId, tabId, paneId })
- rollbackTabId = created?.tabId
- } else {
- const created = layoutStore.createTab?.({ title })
- tabId = created?.tabId || nanoid()
- paneId = created?.paneId || nanoid()
- rollbackTabId = created?.tabId
- launch = await resolveSpawnProviderSettings(mode, configStore, {}, {
- cwd,
- envContext: { tabId, paneId },
- codexLaunchPlanner,
- assertTerminalCreateAccepted: assertTerminalAdmission,
- })
- }
- unownedCodexSidecar = launch.codexSidecar
- const sessionBindingReason = getCodexSessionBindingReason(mode, launch.sessionRef?.sessionId)
+ launch = await resolveSpawnProviderSettings(mode, configStore, {}, {
+ cwd,
+ codexLaunchPlanner,
+ assertTerminalCreateAccepted: assertTerminalAdmission,
+ })
+ assertTerminalAdmission()
+ const created = layoutStore.createTab?.({ title })
+ const tabId = created?.tabId || nanoid()
+ const paneId = created?.paneId || nanoid()
+ createdTabId = created?.tabId
+ const sessionBindingReason = getCodexSessionBindingReason(mode)
assertTerminalAdmission()
const terminal = registry.create({
- ...(preallocatedTerminalId ? { terminalId: preallocatedTerminalId } : {}),
mode,
shell,
cwd,
- resumeSessionId: launch.sessionRef?.sessionId,
+ resumeSessionId: launch.resumeSessionId,
...(sessionBindingReason ? { sessionBindingReason } : {}),
- ...(launch.codexSidecar ? { codexSidecar: launch.codexSidecar } : {}),
- ...(launch.codexLaunchFactory ? { codexLaunchFactory: launch.codexLaunchFactory } : {}),
- ...(launch.codexLaunchBaseProviderSettings
- ? { codexLaunchBaseProviderSettings: launch.codexLaunchBaseProviderSettings }
- : {}),
providerSettings: launch.providerSettings,
envContext: { tabId, paneId },
})
- unownedCodexSidecar = undefined
+ createdTerminalId = terminal.terminalId
+ assertTerminalAdmission()
+ await adoptCodexLaunch(launch, terminal.terminalId)
+ assertTerminalAdmission()
+ publishCodexLaunch(registry, launch, terminal.terminalId)
+ launch = undefined
layoutStore.attachPaneContent?.(tabId, paneId, { kind: 'terminal', terminalId: terminal.terminalId })
- rollbackTabId = undefined
wsHandler?.broadcastUiCommand({
command: 'tab.create',
payload: { id: tabId, title, mode, shell, terminalId: terminal.terminalId, initialCwd: cwd },
@@ -905,10 +920,16 @@ export function createAgentApiRouter({
const sentinel = `__FRESHELL_DONE_${nanoid()}__`
const input = capture ? `${command}; echo ${sentinel}\r` : `${command}\r`
- registry.input(terminal.terminalId, input)
+ const inputResult = await sendTerminalInput(registry, terminal.terminalId, input, {
+ waitForCodexIdentity: mode === 'codex',
+ })
+ if (inputResult.status !== 'written') {
+ throw new Error(terminalInputFailureMessage(inputResult))
+ }
if (!capture || detached) {
const message = detached ? 'command started (detached)' : 'command sent'
+ createdTerminalId = undefined
return res.json(ok({ terminalId: terminal.terminalId, tabId, paneId }, message))
}
@@ -922,28 +943,51 @@ export function createAgentApiRouter({
const output = rawOutput.split(sentinel).join('').trim()
const responder = result.matched ? ok : approx
const message = result.matched ? 'run complete' : 'timeout waiting for command'
+ createdTerminalId = undefined
return res.json(responder({ terminalId: terminal.terminalId, tabId, paneId, output }, message))
} catch (err: any) {
- await shutdownUnownedCodexSidecar(unownedCodexSidecar)
- if (rollbackTabId) {
- layoutStore.closeTab?.(rollbackTabId)
+ let responseError = err
+ await cleanupFailedCodexCreate(registry, createdTerminalId ?? terminalIdFromCreateError(err), launch).catch((cleanupError) => {
+ responseError = combineWithCleanupError(err, cleanupError)
+ })
+ if (createdTabId && typeof layoutStore.closeTab === 'function') {
+ try {
+ layoutStore.closeTab(createdTabId)
+ } catch {
+ // best-effort cleanup; terminal/sidecar cleanup errors above remain authoritative
+ }
}
- const status = agentRouteErrorStatus(err)
- return res.status(status).json(fail(err?.message || 'Failed to run command'))
+ const status = agentRouteErrorStatus(responseError)
+ return res.status(status).json(fail(responseError?.message || 'Failed to run command'))
}
})
router.post('/panes/:id/split', async (req, res) => {
- let unownedCodexSidecar: CodexLaunchPlan['sidecar'] | undefined
- let rollbackPaneId: string | undefined
+ let launch: ResolvedSpawnProviderSettings | undefined
+ let createdTerminalId: string | undefined
try {
const rawPaneId = req.params.id
const resolved = resolvePaneTarget(rawPaneId)
if (rejectPaneTargetError(res, resolved)) return
const paneId = resolved.paneId || rawPaneId
- const direction = req.body?.direction || 'vertical'
+ const direction = req.body?.direction || 'horizontal'
const wantsBrowser = !!req.body?.browser
const wantsEditor = !!req.body?.editor
+ const splitMode = !wantsBrowser && !wantsEditor ? req.body?.mode || 'shell' : undefined
+ const requestedSessionRef = splitMode ? sanitizeSessionRef(req.body?.sessionRef) : undefined
+ const acceptedSessionRef = splitMode
+ ? acceptedSessionRefForMode(requestedSessionRef, splitMode)
+ : undefined
+ const requestedResumeSessionId = splitMode
+ ? requestedResumeSessionIdForMode(
+ requestedSessionRef,
+ splitMode,
+ req.body?.resumeSessionId,
+ )
+ : undefined
+ if (!wantsBrowser && !wantsEditor) {
+ assertTerminalAdmission()
+ }
const result = layoutStore.splitPane({
paneId,
@@ -959,50 +1003,46 @@ export function createAgentApiRouter({
const tabId = result.tabId
const newPaneId = result.newPaneId
- rollbackPaneId = newPaneId
let content: any
let terminalId: string | undefined
if (wantsBrowser) {
content = { kind: 'browser', url: req.body.browser, devToolsOpen: false }
} else if (wantsEditor) {
- content = { kind: 'editor', filePath: req.body.editor, language: null, readOnly: false, content: '', viewMode: 'source' }
+ content = { kind: 'editor', filePath: req.body.editor, language: null, readOnly: false, content: '', viewMode: 'source', wordWrap: true }
} else {
- assertTerminalAdmission()
- const splitMode = req.body?.mode || 'shell'
- const preallocatedTerminalId = nanoid()
- const launch = await resolveSpawnProviderSettings(
- splitMode,
+ const terminalMode = splitMode ?? 'shell'
+ launch = await resolveSpawnProviderSettings(
+ terminalMode,
configStore,
{},
{
cwd: req.body?.cwd,
- terminalId: preallocatedTerminalId,
- envContext: { tabId, paneId: newPaneId },
- sessionRef: resolveRequestedSessionRef(splitMode, req.body?.sessionRef),
+ resumeSessionId: requestedResumeSessionId,
codexLaunchPlanner,
assertTerminalCreateAccepted: assertTerminalAdmission,
},
)
- unownedCodexSidecar = launch.codexSidecar
- const sessionBindingReason = getCodexSessionBindingReason(splitMode, launch.sessionRef?.sessionId)
+ assertTerminalAdmission()
+ const sessionBindingReason = getCodexSessionBindingReason(terminalMode, requestedResumeSessionId)
assertTerminalAdmission()
const terminal = registry.create({
- terminalId: preallocatedTerminalId,
- mode: splitMode,
+ mode: terminalMode,
shell: req.body?.shell,
cwd: req.body?.cwd,
- resumeSessionId: launch.sessionRef?.sessionId,
+ resumeSessionId: launch.resumeSessionId,
...(sessionBindingReason ? { sessionBindingReason } : {}),
- ...(launch.codexSidecar ? { codexSidecar: launch.codexSidecar } : {}),
- ...(launch.codexLaunchFactory ? { codexLaunchFactory: launch.codexLaunchFactory } : {}),
- ...(launch.codexLaunchBaseProviderSettings
- ? { codexLaunchBaseProviderSettings: launch.codexLaunchBaseProviderSettings }
- : {}),
providerSettings: launch.providerSettings,
envContext: { tabId, paneId: newPaneId },
})
- unownedCodexSidecar = undefined
+ createdTerminalId = terminal.terminalId
+ const launchResumeSessionId = launch.resumeSessionId
+ assertTerminalAdmission()
+ await adoptCodexLaunch(launch, terminal.terminalId)
+ assertCodexCreateTerminalRunning(terminal)
+ assertTerminalAdmission()
+ publishCodexLaunch(registry, launch, terminal.terminalId)
+ launch = undefined
terminalId = terminal.terminalId
content = {
kind: 'terminal',
@@ -1010,12 +1050,12 @@ export function createAgentApiRouter({
status: 'running',
mode: req.body?.mode || 'shell',
shell: req.body?.shell || 'system',
- ...(launch.sessionRef ? { sessionRef: launch.sessionRef } : {}),
+ ...(acceptedSessionRef ? { sessionRef: acceptedSessionRef } : {}),
+ ...(launchResumeSessionId && !acceptedSessionRef ? { resumeSessionId: launchResumeSessionId } : {}),
}
}
layoutStore.attachPaneContent(tabId, newPaneId, content)
- rollbackPaneId = undefined
wsHandler?.broadcastUiCommand({
command: 'pane.split',
@@ -1029,13 +1069,14 @@ export function createAgentApiRouter({
})
const message = wantsBrowser || wantsEditor ? 'pane split (non-terminal)' : 'pane split'
+ createdTerminalId = undefined
res.json(ok({ paneId: newPaneId, terminalId }, message))
} catch (err: any) {
- await shutdownUnownedCodexSidecar(unownedCodexSidecar)
- if (rollbackPaneId) {
- layoutStore.closePane?.(rollbackPaneId)
- }
- res.status(agentRouteErrorStatus(err)).json(fail(err?.message || 'Failed to split pane'))
+ let responseError = err
+ await cleanupFailedCodexCreate(registry, createdTerminalId ?? terminalIdFromCreateError(err), launch).catch((cleanupError) => {
+ responseError = combineWithCleanupError(err, cleanupError)
+ })
+ res.status(agentRouteErrorStatus(responseError)).json(fail(responseError?.message || 'Failed to split pane'))
}
})
@@ -1190,7 +1231,8 @@ export function createAgentApiRouter({
})
router.post('/panes/:id/respawn', async (req, res) => {
- let unownedCodexSidecar: CodexLaunchPlan['sidecar'] | undefined
+ let launch: ResolvedSpawnProviderSettings | undefined
+ let createdTerminalId: string | undefined
try {
const resolved = resolvePaneTarget(req.params.id)
if (rejectPaneTargetError(res, resolved)) return
@@ -1199,40 +1241,45 @@ export function createAgentApiRouter({
const tabId = target?.tabId
if (!tabId) return res.status(404).json(fail('pane not found'))
const effectiveMode = req.body?.mode || 'shell'
+ const requestedSessionRef = sanitizeSessionRef(req.body?.sessionRef)
+ const acceptedSessionRef = acceptedSessionRefForMode(requestedSessionRef, effectiveMode)
+ const requestedResumeSessionId = requestedResumeSessionIdForMode(
+ requestedSessionRef,
+ effectiveMode,
+ req.body?.resumeSessionId,
+ )
assertTerminalAdmission()
- const preallocatedTerminalId = nanoid()
- const launch = await resolveSpawnProviderSettings(
+ launch = await resolveSpawnProviderSettings(
effectiveMode,
configStore,
{},
{
cwd: req.body?.cwd,
- terminalId: preallocatedTerminalId,
- envContext: { tabId, paneId },
- sessionRef: resolveRequestedSessionRef(effectiveMode, req.body?.sessionRef),
+ resumeSessionId: requestedResumeSessionId,
codexLaunchPlanner,
assertTerminalCreateAccepted: assertTerminalAdmission,
},
)
- unownedCodexSidecar = launch.codexSidecar
- const sessionBindingReason = getCodexSessionBindingReason(effectiveMode, launch.sessionRef?.sessionId)
+ assertTerminalAdmission()
+ const sessionBindingReason = getCodexSessionBindingReason(effectiveMode, requestedResumeSessionId)
assertTerminalAdmission()
const terminal = registry.create({
- terminalId: preallocatedTerminalId,
mode: effectiveMode,
shell: req.body?.shell,
cwd: req.body?.cwd,
- resumeSessionId: launch.sessionRef?.sessionId,
+ resumeSessionId: launch.resumeSessionId,
...(sessionBindingReason ? { sessionBindingReason } : {}),
- ...(launch.codexSidecar ? { codexSidecar: launch.codexSidecar } : {}),
- ...(launch.codexLaunchFactory ? { codexLaunchFactory: launch.codexLaunchFactory } : {}),
- ...(launch.codexLaunchBaseProviderSettings
- ? { codexLaunchBaseProviderSettings: launch.codexLaunchBaseProviderSettings }
- : {}),
providerSettings: launch.providerSettings,
envContext: { tabId, paneId },
})
- unownedCodexSidecar = undefined
+ createdTerminalId = terminal.terminalId
+ const launchResumeSessionId = launch.resumeSessionId
+ assertTerminalAdmission()
+ await adoptCodexLaunch(launch, terminal.terminalId)
+ assertCodexCreateTerminalRunning(terminal)
+ assertTerminalAdmission()
+ publishCodexLaunch(registry, launch, terminal.terminalId)
+ launch = undefined
const content = {
kind: 'terminal',
terminalId: terminal.terminalId,
@@ -1240,14 +1287,19 @@ export function createAgentApiRouter({
mode: req.body?.mode || 'shell',
shell: req.body?.shell || 'system',
createRequestId: nanoid(),
- ...(launch.sessionRef ? { sessionRef: launch.sessionRef } : {}),
+ ...(acceptedSessionRef ? { sessionRef: acceptedSessionRef } : {}),
+ ...(launchResumeSessionId && !acceptedSessionRef ? { resumeSessionId: launchResumeSessionId } : {}),
}
layoutStore.attachPaneContent(tabId, paneId, content)
wsHandler?.broadcastUiCommand({ command: 'pane.attach', payload: { tabId, paneId, content } })
+ createdTerminalId = undefined
res.json(ok({ terminalId: terminal.terminalId }, 'pane respawned'))
} catch (err: any) {
- await shutdownUnownedCodexSidecar(unownedCodexSidecar)
- res.status(agentRouteErrorStatus(err)).json(fail(err?.message || 'Failed to respawn pane'))
+ let responseError = err
+ await cleanupFailedCodexCreate(registry, createdTerminalId ?? terminalIdFromCreateError(err), launch).catch((cleanupError) => {
+ responseError = combineWithCleanupError(err, cleanupError)
+ })
+ res.status(agentRouteErrorStatus(responseError)).json(fail(responseError?.message || 'Failed to respawn pane'))
}
})
@@ -1289,7 +1341,7 @@ export function createAgentApiRouter({
res.json(ok(undefined, 'navigate requested'))
})
- router.post('/panes/:id/send-keys', (req, res) => {
+ router.post('/panes/:id/send-keys', async (req, res) => {
const resolved = resolvePaneTarget(req.params.id)
if (rejectPaneTargetError(res, resolved)) return
const paneId = resolved.paneId || req.params.id
@@ -1301,8 +1353,13 @@ export function createAgentApiRouter({
if (target?.paneId) terminalId = layoutStore.resolvePaneToTerminal?.(target.paneId)
}
if (!terminalId) return res.status(404).json(fail('terminal not found'))
- const okInput = registry.input(terminalId, data)
- res.json(ok({ terminalId }, okInput ? 'input sent' : 'terminal not running'))
+ const inputResult = await sendTerminalInput(registry, terminalId, data, {
+ waitForCodexIdentity: shouldWaitForCodexIdentity(payload),
+ })
+ if (inputResult.status !== 'written') {
+ return res.status(409).json(fail(terminalInputFailureMessage(inputResult)))
+ }
+ res.json(ok({ terminalId }, 'input sent'))
})
return router
diff --git a/server/agent-timeline/service.ts b/server/agent-timeline/service.ts
index fcb849b81..d670f88ae 100644
--- a/server/agent-timeline/service.ts
+++ b/server/agent-timeline/service.ts
@@ -20,7 +20,15 @@ type TimelineCursorPayload = {
type TimelineMessageRecord = CanonicalTurn & { sessionId: string }
+export type AgentTimelineSnapshot = {
+ sessionId: string
+ latestTurnId: string | null
+ revision: number
+ turns: CanonicalTurn[]
+}
+
export type AgentTimelineService = {
+ getSnapshot: (query: { sessionId: string; revision?: number; signal?: AbortSignal }) => Promise
getTimelinePage: (query: AgentTimelinePageQuery & { sessionId: string; signal?: AbortSignal }) => Promise
getTurnBody: (query: AgentTimelineTurnBodyQuery & { sessionId: string; turnId: string; signal?: AbortSignal }) => Promise
}
@@ -134,6 +142,30 @@ export function createAgentTimelineService(deps: AgentTimelineServiceDeps): Agen
}
return {
+ async getSnapshot({ sessionId, revision, signal }) {
+ throwIfAborted(signal)
+ const timeline = await loadTimeline(sessionId)
+ throwIfAborted(signal)
+ if (revision != null && revision !== timeline.revision) {
+ throw new RestoreStaleRevisionError(revision, timeline.revision)
+ }
+ return {
+ sessionId: timeline.sessionId,
+ latestTurnId: timeline.latestTurnId,
+ revision: timeline.revision,
+ turns: timeline.records
+ .slice()
+ .reverse()
+ .map((record) => ({
+ turnId: record.turnId,
+ messageId: record.messageId,
+ ordinal: record.ordinal,
+ source: record.source,
+ message: record.message,
+ })),
+ }
+ },
+
async getTimelinePage(query) {
throwIfAborted(query.signal)
if (query.revision == null) {
diff --git a/server/claude-session-id.ts b/server/claude-session-id.ts
index 5cb6efb6a..b6616834b 100644
--- a/server/claude-session-id.ts
+++ b/server/claude-session-id.ts
@@ -1,5 +1,5 @@
-const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+import { isCanonicalClaudeSessionId } from '../shared/session-contract.js'
export function isValidClaudeSessionId(value?: string): value is string {
- return typeof value === 'string' && UUID_REGEX.test(value)
+ return isCanonicalClaudeSessionId(value)
}
diff --git a/server/cli/index.ts b/server/cli/index.ts
index 6a0a8b346..248caf75a 100644
--- a/server/cli/index.ts
+++ b/server/cli/index.ts
@@ -7,6 +7,7 @@ import { resolveConfig } from './config.js'
import { resolveTarget } from './targets.js'
import { runCommand as sendKeysCommand } from './commands/sendKeys.js'
import { partitionSendKeysArgs } from './send-keys-args.js'
+import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../coding-cli/codex-app-server/restore-decision.js'
type Flags = Record
@@ -106,6 +107,45 @@ const isTruthy = (value: unknown) => value === true || value === 'true' || value
const unwrap = (response: any) => (response && typeof response === 'object' && 'data' in response ? response.data : response)
+function rejectRawCodexResume(mode: unknown, resumeSessionId: unknown): boolean {
+ if (mode === 'codex' && typeof resumeSessionId === 'string' && resumeSessionId.length > 0) {
+ writeError(INVALID_RAW_CODEX_RESUME_MESSAGE)
+ process.exitCode = 1
+ return true
+ }
+ return false
+}
+
+type CliSessionRef = { provider: string; sessionId: string }
+
+function resolveSessionRefFlag(mode: unknown, raw: unknown): { rejected: boolean; sessionRef?: CliSessionRef } {
+ if (raw === undefined) return { rejected: false }
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
+ writeError('--session-ref must use provider:sessionId syntax.')
+ process.exitCode = 1
+ return { rejected: true }
+ }
+ const separator = raw.indexOf(':')
+ if (separator <= 0 || separator === raw.length - 1) {
+ writeError('--session-ref must use provider:sessionId syntax.')
+ process.exitCode = 1
+ return { rejected: true }
+ }
+ const provider = raw.slice(0, separator).trim()
+ const sessionId = raw.slice(separator + 1).trim()
+ if (!provider || !sessionId) {
+ writeError('--session-ref must use provider:sessionId syntax.')
+ process.exitCode = 1
+ return { rejected: true }
+ }
+ if (typeof mode !== 'string' || mode !== provider) {
+ writeError('--session-ref provider must match --mode.')
+ process.exitCode = 1
+ return { rejected: true }
+ }
+ return { rejected: false, sessionRef: { provider, sessionId } }
+}
+
async function fetchTabs(client: ReturnType): Promise<{ tabs: TabSummary[]; activeTabId?: string | null }> {
const res = await client.get('/api/tabs')
const data = unwrap(res)
@@ -311,12 +351,27 @@ async function main() {
const browser = getFlag(flags, 'browser') as string | undefined
const editor = getFlag(flags, 'editor') as string | undefined
const resumeSessionId = getFlag(flags, 'resume') as string | undefined
+ const sessionRefResult = resolveSessionRefFlag(mode, getFlag(flags, 'session-ref'))
const prompt = getFlag(flags, 'prompt') as string | undefined
+ if (rejectRawCodexResume(mode, resumeSessionId)) return
+ if (sessionRefResult.rejected) return
- const res = await client.post('/api/tabs', { name, mode, shell, cwd, browser, editor, resumeSessionId })
+ const res = await client.post('/api/tabs', {
+ name,
+ mode,
+ shell,
+ cwd,
+ browser,
+ editor,
+ resumeSessionId,
+ ...(sessionRefResult.sessionRef ? { sessionRef: sessionRefResult.sessionRef } : {}),
+ })
const data = unwrap(res)
if (prompt && data?.paneId) {
- await client.post(`/api/panes/${encodeURIComponent(data.paneId)}/send-keys`, { data: `${prompt}\r` })
+ await client.post(`/api/panes/${encodeURIComponent(data.paneId)}/send-keys`, {
+ data: `${prompt}\r`,
+ ...(mode === 'codex' ? { waitForCodexIdentity: true } : {}),
+ })
}
writeJson(res)
return
@@ -405,6 +460,10 @@ async function main() {
const mode = getFlag(flags, 'mode') as string | undefined
const shell = getFlag(flags, 'shell') as string | undefined
const cwd = getFlag(flags, 'cwd') as string | undefined
+ const resumeSessionId = getFlag(flags, 'resume') as string | undefined
+ const sessionRefResult = resolveSessionRefFlag(mode, getFlag(flags, 'session-ref'))
+ if (rejectRawCodexResume(mode, resumeSessionId)) return
+ if (sessionRefResult.rejected) return
const resolved = await resolvePaneTarget(client, target)
if (!resolved.pane?.id) {
@@ -420,6 +479,8 @@ async function main() {
mode,
shell,
cwd,
+ resumeSessionId,
+ ...(sessionRefResult.sessionRef ? { sessionRef: sessionRefResult.sessionRef } : {}),
})
writeJson(res)
return
@@ -536,13 +597,23 @@ async function main() {
const mode = getFlag(flags, 'mode') as string | undefined
const shell = getFlag(flags, 'shell') as string | undefined
const cwd = getFlag(flags, 'cwd') as string | undefined
+ const resumeSessionId = getFlag(flags, 'resume') as string | undefined
+ const sessionRefResult = resolveSessionRefFlag(mode, getFlag(flags, 'session-ref'))
+ if (rejectRawCodexResume(mode, resumeSessionId)) return
+ if (sessionRefResult.rejected) return
const resolved = await resolvePaneTarget(client, target)
if (!resolved.pane?.id) {
writeError(resolved.message || 'pane not found')
process.exitCode = 1
return
}
- const res = await client.post(`/api/panes/${encodeURIComponent(resolved.pane.id)}/respawn`, { mode, shell, cwd })
+ const res = await client.post(`/api/panes/${encodeURIComponent(resolved.pane.id)}/respawn`, {
+ mode,
+ shell,
+ cwd,
+ resumeSessionId,
+ ...(sessionRefResult.sessionRef ? { sessionRef: sessionRefResult.sessionRef } : {}),
+ })
writeJson(res)
return
}
diff --git a/server/coding-cli/codex-app-server/client.ts b/server/coding-cli/codex-app-server/client.ts
index 72612d460..e081cd003 100644
--- a/server/coding-cli/codex-app-server/client.ts
+++ b/server/coding-cli/codex-app-server/client.ts
@@ -6,18 +6,44 @@ import {
CodexFsWatchResultSchema,
CodexInitializeParamsSchema,
CodexInitializeResultSchema,
+ CodexLoadedThreadListResultSchema,
CodexRpcErrorEnvelopeSchema,
CodexRpcNotificationEnvelopeSchema,
CodexRpcSuccessEnvelopeSchema,
CodexThreadLifecycleNotificationSchema,
CodexThreadStartedNotificationSchema,
CodexThreadOperationResultSchema,
+ CodexThreadPageParamsSchema,
+ CodexThreadForkParamsSchema,
+ CodexThreadReadParamsSchema,
+ CodexThreadReadResultSchema,
+ CodexThreadResumeParamsSchema,
+ CodexThreadSchema,
+ CodexThreadStartParamsSchema,
+ CodexThreadTurnReadResultSchema,
+ CodexThreadTurnsListResultSchema,
+ CodexTurnInterruptParamsSchema,
+ CodexTurnInterruptResultSchema,
+ CodexTurnStartParamsSchema,
+ CodexTurnStartResultSchema,
+ CodexTurnCompletedNotificationSchema,
+ CodexTurnStartedNotificationSchema,
type CodexInitializeResult,
+ type CodexRequestId,
type CodexRpcError,
type CodexThreadHandle,
type CodexThreadOperationResult,
+ type CodexThreadReadParams,
+ type CodexThreadReadResult,
+ type CodexThreadForkParams,
type CodexThreadResumeParams,
type CodexThreadStartParams,
+ type CodexThreadTurnReadParams,
+ type CodexThreadTurnReadResult,
+ type CodexThreadTurnsListParams,
+ type CodexThreadTurnsListResult,
+ type CodexTurnInterruptParams,
+ type CodexTurnStartParams,
} from './protocol.js'
type CodexAppServerClientOptions = {
@@ -36,6 +62,22 @@ type PendingRequest = {
}
const DEFAULT_REQUEST_TIMEOUT_MS = 5_000
+const LOSS_STATUSES = new Set(['notLoaded', 'systemError'])
+
+type CodexThreadStartInput =
+ Omit & {
+ richClient?: boolean
+ }
+
+type CodexThreadResumeInput = Omit
+
+type CodexThreadOperationClientResult = CodexThreadOperationResult & {
+ threadId: string
+}
+
+export type CodexThreadLifecycleLossEvent =
+ | { method: 'thread/closed'; threadId?: string }
+ | { method: 'thread/status/changed'; threadId?: string; status: 'notLoaded' | 'systemError' }
export type CodexThreadLifecycleEvent = {
kind: 'thread_started'
@@ -54,12 +96,14 @@ export type CodexAppServerDisconnectEvent = {
error?: Error
}
-function normalizeThread(thread: CodexThreadHandle): CodexThreadHandle {
- return {
- ...thread,
- path: thread.path ?? null,
- ephemeral: thread.ephemeral ?? false,
- }
+export type CodexTurnEvent = {
+ threadId: string
+ turnId?: string
+ params: Record
+}
+
+function normalizeThread(thread: CodexThreadHandle): CodexThreadOperationResult['thread'] {
+ return CodexThreadSchema.parse(thread)
}
export class CodexAppServerClient {
@@ -68,11 +112,14 @@ export class CodexAppServerClient {
private connectPromise: Promise | null = null
private initializePromise: Promise | null = null
private nextRequestId = 1
- private pendingRequests = new Map()
+ private pendingRequests = new Map()
private readonly threadStartedHandlers = new Set<(thread: CodexThreadHandle) => void>()
private readonly threadLifecycleHandlers = new Set<(event: CodexThreadLifecycleEvent) => void>()
private readonly disconnectHandlers = new Set<(event: CodexAppServerDisconnectEvent) => void>()
private readonly fsChangedHandlers = new Set<(event: { watchId: string; changedPaths: string[] }) => void>()
+ private readonly turnStartedHandlers = new Set<(event: CodexTurnEvent) => void>()
+ private readonly turnCompletedHandlers = new Set<(event: CodexTurnEvent) => void>()
+ private lifecycleLossHandlers = new Set<(event: CodexThreadLifecycleLossEvent) => void>()
constructor(
private readonly endpoint: CodexAppServerEndpoint,
@@ -88,12 +135,14 @@ export class CodexAppServerClient {
clientInfo: { name: 'freshell', version: '1.0.0' },
capabilities: {
experimentalApi: true,
+ optOutNotificationMethods: ['thread/started'],
},
- })).then((result) => {
+ })).then(async (result) => {
const parsed = CodexInitializeResultSchema.safeParse(result)
if (!parsed.success) {
throw new Error('Codex app-server returned an invalid initialize payload.')
}
+ await this.notify('initialized')
return parsed.data
}).catch((error) => {
this.initializePromise = null
@@ -103,39 +152,42 @@ export class CodexAppServerClient {
return this.initializePromise
}
- async startThread(
- params: Omit,
- ): Promise {
- const result = await this.request('thread/start', {
- ...params,
- // Freshell attaches the visible TUI over `codex --remote`, so it does not
- // need the app-server's raw event stream for fresh threads.
- experimentalRawEvents: false,
+ async startThread(params: CodexThreadStartInput): Promise {
+ const { richClient, ...appServerParams } = params
+ const result = await this.request('thread/start', CodexThreadStartParamsSchema.parse({
+ ...appServerParams,
+ experimentalRawEvents: richClient === true,
persistExtendedHistory: true,
- })
+ }))
const parsed = CodexThreadOperationResultSchema.safeParse(result)
if (!parsed.success) {
throw new Error('Codex app-server returned an invalid thread/start payload.')
}
+ const thread = normalizeThread(parsed.data.thread)
+ this.emitThreadStartedEvidence(thread)
return {
- thread: normalizeThread(parsed.data.thread),
+ ...parsed.data,
+ thread,
+ threadId: thread.id,
}
}
- async resumeThread(
- params: Omit,
- ): Promise {
+ async resumeThread(params: CodexThreadResumeInput): Promise {
// Intentionally preserve Codex's default raw-event behavior for resume calls.
- const result = await this.request('thread/resume', {
+ const result = await this.request('thread/resume', CodexThreadResumeParamsSchema.parse({
...params,
persistExtendedHistory: true,
- })
+ }))
const parsed = CodexThreadOperationResultSchema.safeParse(result)
if (!parsed.success) {
throw new Error('Codex app-server returned an invalid thread/resume payload.')
}
+ const thread = normalizeThread(parsed.data.thread)
+ this.emitThreadStartedEvidence(thread)
return {
- thread: normalizeThread(parsed.data.thread),
+ ...parsed.data,
+ thread,
+ threadId: thread.id,
}
}
@@ -157,6 +209,93 @@ export class CodexAppServerClient {
}))
}
+ async listLoadedThreads(): Promise {
+ const result = await this.request('thread/loaded/list', {})
+ const parsed = CodexLoadedThreadListResultSchema.safeParse(result)
+ if (!parsed.success) {
+ throw new Error('Codex app-server returned an invalid thread/loaded/list payload.')
+ }
+ return parsed.data.data
+ }
+
+ async forkThread(params: CodexThreadForkParams): Promise<{ threadId: string }> {
+ const result = await this.request('thread/fork', CodexThreadForkParamsSchema.parse(params))
+ const parsed = CodexThreadOperationResultSchema.safeParse(result)
+ if (!parsed.success) {
+ throw new Error('Codex app-server returned an invalid thread/fork payload.')
+ }
+ return { threadId: parsed.data.thread.id }
+ }
+
+ async readThread(params: CodexThreadReadParams): Promise {
+ const result = await this.request('thread/read', CodexThreadReadParamsSchema.parse(params))
+ const parsed = CodexThreadReadResultSchema.safeParse(result)
+ if (!parsed.success) {
+ throw new Error('Codex app-server returned an invalid thread/read payload.')
+ }
+ return parsed.data
+ }
+
+ async listThreadTurns(params: CodexThreadTurnsListParams): Promise {
+ const parsedParams = CodexThreadPageParamsSchema.parse(params)
+ const result = await this.request('thread/read', {
+ threadId: parsedParams.threadId,
+ includeTurns: true,
+ })
+ const parsedThread = CodexThreadReadResultSchema.safeParse(result)
+ if (!parsedThread.success) {
+ throw new Error('Codex app-server returned an invalid thread/read payload.')
+ }
+ const turns = parsedThread.data.thread.turns.slice(0, parsedParams.limit)
+ const parsed = CodexThreadTurnsListResultSchema.safeParse({
+ revision: Math.max(0, Math.trunc(parsedThread.data.thread.updatedAt)),
+ nextCursor: null,
+ backwardsCursor: null,
+ turns,
+ bodies: Object.fromEntries(turns.map((turn) => [turn.id, turn])),
+ })
+ if (!parsed.success) {
+ throw new Error('Codex app-server returned an invalid synthesized thread turn page.')
+ }
+ return parsed.data
+ }
+
+ async readThreadTurn(params: CodexThreadTurnReadParams): Promise {
+ const page = await this.listThreadTurns({
+ threadId: params.threadId,
+ })
+ const turn = page.turns.find((candidate) => candidate.id === params.turnId)
+ if (!turn) {
+ throw new Error(`Codex app-server thread ${params.threadId} does not contain turn ${params.turnId}.`)
+ }
+ const parsedTurn = CodexThreadTurnReadResultSchema.safeParse({
+ ...turn,
+ turnId: turn.id,
+ revision: params.revision ?? page.revision,
+ })
+ if (!parsedTurn.success) {
+ throw new Error('Codex app-server returned an invalid synthesized thread turn body.')
+ }
+ return parsedTurn.data
+ }
+
+ async startTurn(params: CodexTurnStartParams): Promise<{ turnId: string }> {
+ const result = await this.request('turn/start', CodexTurnStartParamsSchema.parse(params))
+ const parsed = CodexTurnStartResultSchema.safeParse(result)
+ if (!parsed.success) {
+ throw new Error('Codex app-server returned an invalid turn/start payload.')
+ }
+ return { turnId: parsed.data.turn.id }
+ }
+
+ async interruptTurn(params: CodexTurnInterruptParams): Promise {
+ const result = await this.request('turn/interrupt', CodexTurnInterruptParamsSchema.parse(params))
+ const parsed = CodexTurnInterruptResultSchema.safeParse(result)
+ if (!parsed.success) {
+ throw new Error('Codex app-server returned an invalid turn/interrupt payload.')
+ }
+ }
+
async close(): Promise {
const socket = this.socket
this.socket = null
@@ -216,6 +355,27 @@ export class CodexAppServerClient {
}
}
+ onTurnStarted(handler: (event: CodexTurnEvent) => void): () => void {
+ this.turnStartedHandlers.add(handler)
+ return () => {
+ this.turnStartedHandlers.delete(handler)
+ }
+ }
+
+ onTurnCompleted(handler: (event: CodexTurnEvent) => void): () => void {
+ this.turnCompletedHandlers.add(handler)
+ return () => {
+ this.turnCompletedHandlers.delete(handler)
+ }
+ }
+
+ onThreadLifecycleLoss(handler: (event: CodexThreadLifecycleLossEvent) => void): () => void {
+ this.lifecycleLossHandlers.add(handler)
+ return () => {
+ this.lifecycleLossHandlers.delete(handler)
+ }
+ }
+
private async ensureSocket(): Promise {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
return this.socket
@@ -276,6 +436,7 @@ export class CodexAppServerClient {
const lifecycle = CodexThreadLifecycleNotificationSchema.safeParse(notification.data)
if (lifecycle.success) {
this.emitThreadLifecycle(lifecycle.data)
+ this.handleNotification(notification.data)
return
}
@@ -292,7 +453,22 @@ export class CodexAppServerClient {
for (const handler of this.fsChangedHandlers) {
handler(fsChanged.data.params)
}
+ return
}
+
+ const turnStarted = CodexTurnStartedNotificationSchema.safeParse(notification.data)
+ if (turnStarted.success) {
+ this.emitTurnEvent(this.turnStartedHandlers, turnStarted.data.params)
+ return
+ }
+
+ const turnCompleted = CodexTurnCompletedNotificationSchema.safeParse(notification.data)
+ if (turnCompleted.success) {
+ this.emitTurnEvent(this.turnCompletedHandlers, turnCompleted.data.params)
+ return
+ }
+
+ this.handleNotification(notification.data)
return
}
}
@@ -322,6 +498,79 @@ export class CodexAppServerClient {
pending.reject(new Error(this.formatRpcError(pending.method, failure.data.error)))
}
+ private handleNotification(notification: { method: string; params?: unknown }): void {
+ if (notification.method === 'thread/closed') {
+ this.emitLifecycleLoss({
+ method: 'thread/closed',
+ threadId: this.extractThreadId(notification.params),
+ })
+ return
+ }
+
+ if (notification.method !== 'thread/status/changed') return
+ const status = this.extractThreadStatus(notification.params)
+ if (status !== 'notLoaded' && status !== 'systemError') return
+
+ this.emitLifecycleLoss({
+ method: 'thread/status/changed',
+ threadId: this.extractThreadId(notification.params),
+ status,
+ })
+ }
+
+ private emitLifecycleLoss(event: CodexThreadLifecycleLossEvent): void {
+ for (const handler of this.lifecycleLossHandlers) {
+ handler(event)
+ }
+ }
+
+ private emitTurnEvent(handlers: Set<(event: CodexTurnEvent) => void>, params: { threadId: string; turnId?: string } & Record): void {
+ const event: CodexTurnEvent = {
+ threadId: params.threadId,
+ ...(typeof params.turnId === 'string' ? { turnId: params.turnId } : {}),
+ params,
+ }
+ for (const handler of handlers) {
+ handler(event)
+ }
+ }
+
+ private extractThreadId(params: unknown): string | undefined {
+ if (!params || typeof params !== 'object') return undefined
+ const object = params as Record
+ if (typeof object.threadId === 'string') return object.threadId
+ const thread = object.thread
+ if (thread && typeof thread === 'object' && typeof (thread as Record).id === 'string') {
+ return (thread as Record).id
+ }
+ return undefined
+ }
+
+ private extractThreadStatus(params: unknown): 'notLoaded' | 'systemError' | undefined {
+ if (!params || typeof params !== 'object') return undefined
+ const object = params as Record
+ const status = this.extractLossStatus(object.status)
+ if (status) return status
+ const thread = object.thread
+ if (thread && typeof thread === 'object') {
+ return this.extractLossStatus((thread as Record).status)
+ }
+ return undefined
+ }
+
+ private extractLossStatus(status: unknown): 'notLoaded' | 'systemError' | undefined {
+ if (typeof status === 'string' && LOSS_STATUSES.has(status)) {
+ return status as 'notLoaded' | 'systemError'
+ }
+ if (status && typeof status === 'object') {
+ const statusType = (status as Record).type
+ if (typeof statusType === 'string' && LOSS_STATUSES.has(statusType)) {
+ return statusType as 'notLoaded' | 'systemError'
+ }
+ }
+ return undefined
+ }
+
private handleSocketClose(socket: WebSocket, event: CodexAppServerDisconnectEvent): void {
if (this.socket !== socket) {
return
@@ -344,16 +593,7 @@ export class CodexAppServerClient {
private emitThreadLifecycle(notification: import('./protocol.js').CodexThreadLifecycleNotification): void {
if (notification.method === 'thread/started') {
const thread = normalizeThread(notification.params.thread)
- const event: CodexThreadLifecycleEvent = {
- kind: 'thread_started',
- thread,
- }
- for (const handler of this.threadLifecycleHandlers) {
- handler(event)
- }
- for (const handler of this.threadStartedHandlers) {
- handler(thread)
- }
+ this.emitThreadStartedEvidence(thread)
return
}
@@ -378,6 +618,19 @@ export class CodexAppServerClient {
}
}
+ private emitThreadStartedEvidence(thread: CodexThreadOperationResult['thread']): void {
+ const event: CodexThreadLifecycleEvent = {
+ kind: 'thread_started',
+ thread,
+ }
+ for (const handler of this.threadLifecycleHandlers) {
+ handler(event)
+ }
+ for (const handler of this.threadStartedHandlers) {
+ handler(thread)
+ }
+ }
+
private async request(method: string, params: TParams): Promise {
if (method !== 'initialize') {
await (this.initializePromise ?? this.initialize())
@@ -398,7 +651,7 @@ export class CodexAppServerClient {
timeout,
})
- socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }), (error) => {
+ socket.send(JSON.stringify({ id, method, params }), (error) => {
if (!error) return
clearTimeout(timeout)
this.pendingRequests.delete(id)
@@ -407,6 +660,12 @@ export class CodexAppServerClient {
})
}
+ private async notify(method: string, params?: unknown): Promise {
+ const socket = await this.ensureSocket()
+ const payload = params === undefined ? { method } : { method, params }
+ socket.send(JSON.stringify(payload))
+ }
+
private formatRpcError(method: string, error: CodexRpcError): string {
return `Codex app-server ${method} failed: ${error.message}`
}
diff --git a/server/coding-cli/codex-app-server/durability-proof.ts b/server/coding-cli/codex-app-server/durability-proof.ts
new file mode 100644
index 000000000..001986e20
--- /dev/null
+++ b/server/coding-cli/codex-app-server/durability-proof.ts
@@ -0,0 +1,142 @@
+import fsp from 'node:fs/promises'
+import path from 'node:path'
+import type { CodexRolloutProofFailureReason } from '../../../shared/codex-durability.js'
+
+type ProofFs = Pick
+
+const FIRST_RECORD_CHUNK_BYTES = 8192
+const MAX_FIRST_RECORD_BYTES = 1024 * 1024
+
+export type CodexRolloutProofSuccess = {
+ ok: true
+ candidateThreadId: string
+ rolloutPath: string
+ rolloutProofId: string
+}
+
+export type CodexRolloutProofFailure = {
+ ok: false
+ reason: CodexRolloutProofFailureReason
+ message: string
+ candidateThreadId: string
+ rolloutPath: string
+}
+
+export type CodexRolloutProofResult = CodexRolloutProofSuccess | CodexRolloutProofFailure
+
+export async function proofCodexRollout(input: {
+ rolloutPath: string
+ candidateThreadId: string
+ fsImpl?: ProofFs
+}): Promise {
+ const fsImpl = input.fsImpl ?? fsp
+ const rolloutPath = input.rolloutPath
+ const candidateThreadId = input.candidateThreadId
+
+ const fail = (reason: CodexRolloutProofFailureReason, message: string): CodexRolloutProofFailure => ({
+ ok: false,
+ reason,
+ message,
+ candidateThreadId,
+ rolloutPath,
+ })
+
+ if (!path.isAbsolute(rolloutPath)) {
+ return fail('invalid_path', 'Codex rollout proof path must be absolute.')
+ }
+ if (!candidateThreadId) {
+ return fail('mismatched_thread_id', 'Codex candidate thread id is empty.')
+ }
+
+ let stat: Awaited>
+ try {
+ stat = await fsImpl.stat(rolloutPath)
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return fail('missing', 'Codex rollout proof file does not exist.')
+ }
+ return fail('read_error', `Could not stat Codex rollout proof file: ${errorMessage(error)}`)
+ }
+
+ if (!stat.isFile()) {
+ return fail('not_regular_file', 'Codex rollout proof path is not a regular file.')
+ }
+
+ let firstLine: string
+ try {
+ firstLine = (await readFirstLine(fsImpl, rolloutPath)).trim()
+ } catch (error) {
+ return fail('read_error', `Could not read Codex rollout proof file: ${errorMessage(error)}`)
+ }
+
+ if (!firstLine) {
+ return fail('empty', 'Codex rollout proof file does not start with a JSONL record.')
+ }
+
+ let firstRecord: unknown
+ try {
+ firstRecord = JSON.parse(firstLine)
+ } catch {
+ return fail('malformed_json', 'Codex rollout proof first JSONL record is malformed.')
+ }
+
+ if (!firstRecord || typeof firstRecord !== 'object') {
+ return fail('malformed_json', 'Codex rollout proof first JSONL record is not an object.')
+ }
+
+ const record = firstRecord as Record
+ if (record.type !== 'session_meta') {
+ return fail('wrong_record_type', 'Codex rollout proof first JSONL record is not session_meta.')
+ }
+
+ const payload = record.payload
+ const rolloutProofId = payload && typeof payload === 'object'
+ ? (payload as Record).id
+ : undefined
+ if (typeof rolloutProofId !== 'string' || rolloutProofId.length === 0) {
+ return fail('missing_payload_id', 'Codex rollout proof session_meta payload.id is missing.')
+ }
+
+ if (rolloutProofId !== candidateThreadId) {
+ return fail('mismatched_thread_id', 'Codex rollout proof id does not match candidate thread id.')
+ }
+
+ return {
+ ok: true,
+ candidateThreadId,
+ rolloutPath,
+ rolloutProofId,
+ }
+}
+
+async function readFirstLine(fsImpl: ProofFs, filePath: string): Promise {
+ const handle = await fsImpl.open(filePath, 'r')
+ const chunks: Buffer[] = []
+ let bytesSeen = 0
+
+ try {
+ while (bytesSeen < MAX_FIRST_RECORD_BYTES) {
+ const buffer = Buffer.alloc(Math.min(FIRST_RECORD_CHUNK_BYTES, MAX_FIRST_RECORD_BYTES - bytesSeen))
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, bytesSeen)
+ if (bytesRead === 0) break
+
+ const slice = buffer.subarray(0, bytesRead)
+ const newlineIndex = slice.indexOf(10)
+ if (newlineIndex >= 0) {
+ chunks.push(slice.subarray(0, newlineIndex))
+ return Buffer.concat(chunks).toString('utf8').replace(/\r$/, '')
+ }
+
+ chunks.push(slice)
+ bytesSeen += bytesRead
+ }
+ } finally {
+ await handle.close()
+ }
+
+ return Buffer.concat(chunks).toString('utf8').replace(/\r$/, '')
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
+}
diff --git a/server/coding-cli/codex-app-server/durability-store.ts b/server/coding-cli/codex-app-server/durability-store.ts
new file mode 100644
index 000000000..f7fe8c03b
--- /dev/null
+++ b/server/coding-cli/codex-app-server/durability-store.ts
@@ -0,0 +1,123 @@
+import fsp from 'node:fs/promises'
+import os from 'node:os'
+import path from 'node:path'
+import {
+ CodexDurabilityStoreRecordSchema,
+ type CodexDurabilityStoreRecord,
+} from '../../../shared/codex-durability.js'
+
+type StoreFs = Pick
+
+export type CodexDurabilityRestoreLocator = {
+ terminalId?: string
+ tabId?: string
+ paneId?: string
+ serverInstanceId?: string
+}
+
+export class CodexDurabilityRestoreAmbiguousError extends Error {
+ constructor(locator: CodexDurabilityRestoreLocator, readonly matches: string[]) {
+ super(`Multiple Codex durability records match restore locator ${JSON.stringify(locator)}.`)
+ this.name = 'CodexDurabilityRestoreAmbiguousError'
+ }
+}
+
+export function defaultCodexDurabilityStoreDir(): string {
+ return process.env.FRESHELL_CODEX_DURABILITY_DIR
+ || path.join(os.homedir(), '.freshell', 'codex-durability')
+}
+
+export class CodexDurabilityStore {
+ private readonly dir: string
+ private readonly fsImpl: StoreFs
+
+ constructor(options: { dir?: string; fsImpl?: StoreFs } = {}) {
+ this.dir = options.dir ?? defaultCodexDurabilityStoreDir()
+ this.fsImpl = options.fsImpl ?? fsp
+ }
+
+ async read(terminalId: string): Promise {
+ const filePath = this.recordPath(terminalId)
+ let raw: string
+ try {
+ raw = await this.fsImpl.readFile(filePath, 'utf8')
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
+ throw error
+ }
+ const parsed = CodexDurabilityStoreRecordSchema.safeParse(JSON.parse(raw))
+ if (!parsed.success) {
+ throw new Error(`Codex durability store record is invalid for terminal ${terminalId}.`)
+ }
+ return parsed.data
+ }
+
+ async readForRestoreLocator(locator: CodexDurabilityRestoreLocator): Promise {
+ if (locator.terminalId) {
+ return this.read(locator.terminalId)
+ }
+ if (!locator.tabId || !locator.paneId) return undefined
+
+ let entries: string[]
+ try {
+ entries = await this.fsImpl.readdir(this.dir)
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
+ throw error
+ }
+
+ const matches: CodexDurabilityStoreRecord[] = []
+ for (const entry of entries) {
+ if (!entry.endsWith('.json')) continue
+ let terminalId: string
+ let record: CodexDurabilityStoreRecord | undefined
+ try {
+ terminalId = decodeURIComponent(entry.slice(0, -'.json'.length))
+ record = await this.read(terminalId)
+ } catch {
+ continue
+ }
+ if (!record) continue
+ if (record.tabId !== locator.tabId || record.paneId !== locator.paneId) continue
+ if (locator.serverInstanceId && record.serverInstanceId !== locator.serverInstanceId) continue
+ matches.push(record)
+ }
+
+ if (matches.length > 1) {
+ throw new CodexDurabilityRestoreAmbiguousError(locator, matches.map((match) => match.terminalId))
+ }
+ return matches[0]
+ }
+
+ async write(record: CodexDurabilityStoreRecord): Promise {
+ const parsed = CodexDurabilityStoreRecordSchema.parse(record)
+ const existing = await this.read(parsed.terminalId)
+ if (existing?.candidate && parsed.candidate) {
+ if (
+ existing.candidate.candidateThreadId !== parsed.candidate.candidateThreadId
+ || existing.candidate.rolloutPath !== parsed.candidate.rolloutPath
+ ) {
+ throw new Error(`Codex durability candidate mismatch for terminal ${parsed.terminalId}.`)
+ }
+ }
+
+ await this.fsImpl.mkdir(this.dir, { recursive: true })
+ const filePath = this.recordPath(parsed.terminalId)
+ const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
+ await this.fsImpl.writeFile(tmpPath, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 })
+ await this.fsImpl.rename(tmpPath, filePath)
+ return parsed
+ }
+
+ async delete(terminalId: string): Promise {
+ try {
+ await this.fsImpl.unlink(this.recordPath(terminalId))
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+ }
+ }
+
+ recordPath(terminalId: string): string {
+ return path.join(this.dir, `${encodeURIComponent(terminalId)}.json`)
+ }
+}
diff --git a/server/coding-cli/codex-app-server/launch-planner.ts b/server/coding-cli/codex-app-server/launch-planner.ts
index f2040ef12..f1d03d7d8 100644
--- a/server/coding-cli/codex-app-server/launch-planner.ts
+++ b/server/coding-cli/codex-app-server/launch-planner.ts
@@ -1,132 +1,268 @@
-import { CodexTerminalSidecar } from './sidecar.js'
-import { generateMcpInjection } from '../../mcp/config-writer.js'
-import type { TerminalEnvContext } from '../../terminal-registry.js'
+import type { CodexAppServerRuntime } from './runtime.js'
+import type { CodexThreadLifecycleEvent, CodexThreadLifecycleLossEvent, CodexTurnEvent } from './client.js'
+import { waitForAllSettledOrThrow } from '../../shutdown-join.js'
+import {
+ CodexRemoteProxy,
+ type CodexRemoteProxyCandidate,
+ type CodexRemoteProxyRepairTrigger,
+} from './remote-proxy.js'
+
+type CodexRuntimeLike = Pick<
+ CodexAppServerRuntime,
+ | 'ensureReady'
+ | 'shutdown'
+ | 'updateOwnershipMetadata'
+ | 'onThreadLifecycleLoss'
+ | 'onFsChanged'
+ | 'watchPath'
+ | 'unwatchPath'
+>
+
+export type CodexLaunchSidecar = {
+ adopt(input: { terminalId: string; generation: number }): Promise
+ markCandidatePersisted?(): void
+ onCandidate?(handler: (candidate: CodexRemoteProxyCandidate) => void): () => void
+ onTurnStarted?(handler: (event: CodexTurnEvent) => void): () => void
+ onTurnCompleted?(handler: (event: CodexTurnEvent) => void): () => void
+ onRepairTrigger?(handler: (event: CodexRemoteProxyRepairTrigger) => void): () => void
+ onFsChanged?(handler: (event: { watchId: string; changedPaths: string[] }) => void): () => void
+ onThreadLifecycle?(handler: (event: CodexThreadLifecycleEvent) => void): () => void
+ onLifecycleLoss?(handler: (event: CodexThreadLifecycleLossEvent) => void): () => void
+ watchPath?(targetPath: string, watchId: string): Promise<{ path: string }>
+ unwatchPath?(watchId: string): Promise
+ shutdown(): Promise
+}
export type CodexLaunchPlan = {
sessionId?: string
remote: {
wsUrl: string
- processPid?: number
}
- sidecar: Pick
+ sidecar: CodexLaunchSidecar
+}
+
+export type CodexSidecarTeardownError = Error & {
+ codexSidecarTeardownFailed: true
}
type PlanCreateInput = {
cwd?: string
- terminalId: string
- env: NodeJS.ProcessEnv
resumeSessionId?: string
model?: string
sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access'
approvalPolicy?: string
}
-export type CodexLaunchFactoryInput = {
- terminalId: string
- cwd?: string
- envContext?: TerminalEnvContext
- resumeSessionId?: string
- providerSettings?: {
- model?: string
- sandbox?: string
- permissionMode?: string
- }
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
}
-export type CodexLaunchFactory = (input: CodexLaunchFactoryInput) => Promise
+function codexSidecarTeardownError(message: string, cause: unknown): CodexSidecarTeardownError {
+ const error = new Error(message) as CodexSidecarTeardownError
+ error.codexSidecarTeardownFailed = true
+ error.cause = cause
+ return error
+}
-type CodexLaunchRetryOptions = {
- onFailedAttempt?: (input: { attempt: number; delayMs: number; error: Error }) => void
- shouldRetry?: (error: Error) => boolean
+export function isCodexSidecarTeardownError(error: unknown): error is CodexSidecarTeardownError {
+ return (error as { codexSidecarTeardownFailed?: boolean } | null | undefined)?.codexSidecarTeardownFailed === true
}
-const INITIAL_LAUNCH_RETRY_DELAYS_MS = [0, 250, 1000, 2000, 5000] as const
+export class CodexLaunchPlanner {
+ private readonly activeSidecars = new Set()
+ private readonly failedSidecarShutdowns = new Set()
+ private readonly runtimeFactory: () => CodexRuntimeLike
+ private shutdownStarted = false
+ private shutdownPromise: Promise | null = null
-function sleep(ms: number): Promise {
- return new Promise((resolve) => setTimeout(resolve, ms))
-}
+ constructor(runtimeOrFactory: CodexRuntimeLike | (() => CodexRuntimeLike)) {
+ this.runtimeFactory = typeof runtimeOrFactory === 'function'
+ ? runtimeOrFactory
+ : () => runtimeOrFactory
+ }
+
+ async planCreate(input: PlanCreateInput): Promise {
+ this.assertAcceptingPlans()
+ await this.retryFailedSidecarShutdownsBeforePlan()
+ this.assertAcceptingPlans()
+
+ const runtime = this.runtimeFactory()
+ let proxy: CodexRemoteProxy | undefined
+ const sidecar = this.createSidecar(runtime, () => proxy)
+ this.activeSidecars.add(sidecar)
-export async function runCodexLaunchWithRetry(
- launch: (attempt: number) => Promise,
- options: CodexLaunchRetryOptions = {},
-): Promise {
- let lastError: Error | undefined
-
- for (let index = 0; index < INITIAL_LAUNCH_RETRY_DELAYS_MS.length; index += 1) {
- const attempt = index + 1
- const delayMs = INITIAL_LAUNCH_RETRY_DELAYS_MS[index]
- if (delayMs > 0) {
- await sleep(delayMs)
- }
try {
- return await launch(attempt)
- } catch (error) {
- lastError = error instanceof Error ? error : new Error(String(error))
- if (options.shouldRetry && !options.shouldRetry(lastError)) {
- throw lastError
+ if (input.resumeSessionId) {
+ const ready = await runtime.ensureReady(input.cwd)
+ proxy = new CodexRemoteProxy({
+ upstreamWsUrl: ready.wsUrl,
+ requireCandidatePersistence: false,
+ })
+ const proxyReady = await proxy.start()
+ this.assertAcceptingPlans()
+ return {
+ sessionId: input.resumeSessionId,
+ remote: {
+ wsUrl: proxyReady.wsUrl,
+ },
+ sidecar,
+ }
+ }
+
+ const ready = await runtime.ensureReady(input.cwd)
+ proxy = new CodexRemoteProxy({ upstreamWsUrl: ready.wsUrl })
+ const proxyReady = await proxy.start()
+ this.assertAcceptingPlans()
+
+ return {
+ remote: {
+ wsUrl: proxyReady.wsUrl,
+ },
+ sidecar,
}
- if (attempt < INITIAL_LAUNCH_RETRY_DELAYS_MS.length) {
- options.onFailedAttempt?.({ attempt, delayMs: INITIAL_LAUNCH_RETRY_DELAYS_MS[index + 1], error: lastError })
+ } catch (error) {
+ try {
+ await sidecar.shutdown()
+ } catch (shutdownError) {
+ throw codexSidecarTeardownError(
+ `Codex launch sidecar teardown failed after planning error: ${errorMessage(shutdownError)}`,
+ shutdownError,
+ )
}
+ throw error
}
}
- throw lastError ?? new Error('Codex launch failed before a terminal record could be created.')
-}
-
-type SidecarCreateInput = PlanCreateInput & {
- commandArgs: string[]
-}
+ async shutdown(): Promise {
+ this.shutdownStarted = true
+ if (this.shutdownPromise) {
+ await this.shutdownPromise
+ return
+ }
+ const attempt = waitForAllSettledOrThrow(
+ [...this.activeSidecars].map((sidecar) => Promise.resolve().then(() => sidecar.shutdown())),
+ 'Codex launch planner shutdown failed.',
+ )
+ this.shutdownPromise = attempt
+ try {
+ await attempt
+ } finally {
+ if (this.shutdownPromise === attempt) {
+ this.shutdownPromise = null
+ }
+ }
+ }
-function appServerMcpTarget(): 'unix' | 'windows' {
- return process.platform === 'win32' ? 'windows' : 'unix'
-}
+ private assertAcceptingPlans(): void {
+ if (this.shutdownStarted) {
+ throw new Error('Codex launch planner is shutting down; new Codex launch plans are not accepted.')
+ }
+ }
-export class CodexLaunchPlanner {
- constructor(
- private readonly createSidecar: (input: SidecarCreateInput) => Pick
- = (input) => new CodexTerminalSidecar({
- cwd: input.cwd,
- commandArgs: input.commandArgs,
- env: input.env,
- }),
- ) {}
+ private async retryFailedSidecarShutdownsBeforePlan(): Promise {
+ const failedSidecars = [...this.failedSidecarShutdowns]
+ .filter((sidecar) => this.activeSidecars.has(sidecar))
+ if (failedSidecars.length === 0) return
- async planCreate(input: PlanCreateInput): Promise {
- const sidecar = this.createSidecar({
- ...input,
- commandArgs: generateMcpInjection('codex', input.terminalId, input.cwd, appServerMcpTarget()).args,
- })
- let ready: Awaited>
try {
- ready = await sidecar.ensureReady()
+ await waitForAllSettledOrThrow(
+ failedSidecars.map((sidecar) => sidecar.shutdown()),
+ 'Codex launch planner failed to clear blocked sidecar shutdowns.',
+ )
} catch (error) {
- await sidecar.shutdown().catch(() => undefined)
- throw error
+ throw codexSidecarTeardownError(
+ `Codex launch planner cannot create a new plan while sidecar teardown is blocked: ${errorMessage(error)}`,
+ error,
+ )
}
+ }
- if (input.resumeSessionId) {
- return {
- sessionId: input.resumeSessionId,
- remote: {
- wsUrl: ready.wsUrl,
- processPid: ready.processPid,
- },
- sidecar,
+ private createSidecar(runtime: CodexRuntimeLike, getProxy: () => CodexRemoteProxy | undefined): CodexLaunchSidecar {
+ let shutdownPromise: Promise | null = null
+ let shutdownAttemptStarted = false
+ let shutdownSucceeded = false
+ let notifyShutdownStarted!: () => void
+ const shutdownStarted = new Promise((resolve) => {
+ notifyShutdownStarted = resolve
+ })
+ const assertAdoptable = () => {
+ if (this.shutdownStarted || shutdownAttemptStarted) {
+ throw new Error('Codex launch sidecar is shutting down; it cannot be adopted.')
}
}
-
- return {
- remote: {
- wsUrl: ready.wsUrl,
- processPid: ready.processPid,
+ const assertActive = () => {
+ if (this.shutdownStarted || shutdownAttemptStarted) {
+ throw new Error('Codex launch sidecar is shutting down; remote operations stopped.')
+ }
+ }
+ const sidecar: CodexLaunchSidecar = {
+ adopt: async ({ terminalId, generation }) => {
+ assertAdoptable()
+ await runtime.updateOwnershipMetadata({ terminalId, generation })
+ assertAdoptable()
+ this.activeSidecars.delete(sidecar)
+ this.failedSidecarShutdowns.delete(sidecar)
+ },
+ markCandidatePersisted: () => getProxy()?.markCandidatePersisted(),
+ onCandidate: (handler) => getProxy()?.onCandidate(handler) ?? (() => undefined),
+ onTurnStarted: (handler) => getProxy()?.onTurnStarted(handler) ?? (() => undefined),
+ onTurnCompleted: (handler) => getProxy()?.onTurnCompleted(handler) ?? (() => undefined),
+ onRepairTrigger: (handler) => getProxy()?.onRepairTrigger(handler) ?? (() => undefined),
+ onFsChanged: (handler) => runtime.onFsChanged(handler),
+ onThreadLifecycle: (handler) => getProxy()?.onThreadLifecycle(handler) ?? (() => undefined),
+ onLifecycleLoss: (handler) => {
+ const unsubRuntime = runtime.onThreadLifecycleLoss(handler)
+ const unsubProxy = getProxy()?.onLifecycleLoss(handler)
+ return () => {
+ unsubRuntime()
+ unsubProxy?.()
+ }
+ },
+ watchPath: async (targetPath, watchId) => {
+ assertActive()
+ const result = await runtime.watchPath(targetPath, watchId)
+ assertActive()
+ return result
+ },
+ unwatchPath: async (watchId) => {
+ assertActive()
+ await runtime.unwatchPath(watchId)
+ assertActive()
+ },
+ shutdown: async () => {
+ if (shutdownSucceeded) return
+ if (shutdownPromise) {
+ await shutdownPromise
+ return
+ }
+ if (!shutdownAttemptStarted) {
+ shutdownAttemptStarted = true
+ notifyShutdownStarted()
+ }
+ const attempt = Promise.resolve()
+ .then(async () => {
+ await getProxy()?.close()
+ await runtime.shutdown()
+ })
+ .then(() => {
+ shutdownSucceeded = true
+ this.activeSidecars.delete(sidecar)
+ this.failedSidecarShutdowns.delete(sidecar)
+ })
+ .catch((error) => {
+ this.failedSidecarShutdowns.add(sidecar)
+ throw error
+ })
+ shutdownPromise = attempt
+ try {
+ await attempt
+ } finally {
+ if (shutdownPromise === attempt) {
+ shutdownPromise = null
+ }
+ }
},
- sidecar,
}
- }
-
- async shutdown(): Promise {
- // Sidecars transfer to TerminalRegistry ownership immediately after create.
- // Unowned planning failures are shut down by the create call sites.
+ return sidecar
}
}
diff --git a/server/coding-cli/codex-app-server/launch-retry.ts b/server/coding-cli/codex-app-server/launch-retry.ts
new file mode 100644
index 000000000..c5a0e78eb
--- /dev/null
+++ b/server/coding-cli/codex-app-server/launch-retry.ts
@@ -0,0 +1,50 @@
+import { setTimeout as delay } from 'node:timers/promises'
+import { CodexLaunchConfigError } from '../codex-launch-config.js'
+import type { CodexLaunchPlan, CodexLaunchPlanner } from './launch-planner.js'
+
+export const CODEX_INITIAL_LAUNCH_ATTEMPTS = 5
+const CODEX_INITIAL_LAUNCH_RETRY_DELAY_MS = 100
+
+type CodexLaunchRetryLogger = {
+ warn: (fields: Record, message: string) => void
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
+}
+
+export async function planCodexLaunchWithRetry({
+ planner,
+ input,
+ attempts = CODEX_INITIAL_LAUNCH_ATTEMPTS,
+ retryDelayMs = CODEX_INITIAL_LAUNCH_RETRY_DELAY_MS,
+ logger,
+}: {
+ planner: CodexLaunchPlanner
+ input: Parameters[0]
+ attempts?: number
+ retryDelayMs?: number
+ logger?: CodexLaunchRetryLogger
+}): Promise {
+ let lastError: unknown
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
+ try {
+ return await planner.planCreate(input)
+ } catch (error) {
+ lastError = error
+ if (error instanceof CodexLaunchConfigError || attempt >= attempts) break
+
+ const delayMs = retryDelayMs * attempt
+ logger?.warn({
+ err: error,
+ attempt,
+ attempts,
+ delayMs,
+ cwd: input.cwd,
+ hasResumeSessionId: Boolean(input.resumeSessionId),
+ }, 'Codex launch planning failed; retrying')
+ await delay(delayMs)
+ }
+ }
+ throw lastError instanceof Error ? lastError : new Error(errorMessage(lastError))
+}
diff --git a/server/coding-cli/codex-app-server/protocol.ts b/server/coding-cli/codex-app-server/protocol.ts
index a528d9e79..1d4de776e 100644
--- a/server/coding-cli/codex-app-server/protocol.ts
+++ b/server/coding-cli/codex-app-server/protocol.ts
@@ -1,52 +1,235 @@
import { z } from 'zod'
+export const CodexRequestIdSchema = z.union([z.string(), z.number().int()])
+
export const CodexInitializeCapabilitiesSchema = z.object({
- experimentalApi: z.boolean(),
- optOutNotificationMethods: z.array(z.string()).optional(),
-})
+ experimentalApi: z.boolean().default(false),
+ optOutNotificationMethods: z.array(z.string()).nullable().optional(),
+}).strict()
export const CodexInitializeParamsSchema = z.object({
clientInfo: z.object({
name: z.string().min(1),
+ title: z.string().nullable().optional(),
version: z.string().min(1),
- }),
- capabilities: CodexInitializeCapabilitiesSchema.nullable(),
-})
+ }).strict(),
+ capabilities: CodexInitializeCapabilitiesSchema.nullable().optional(),
+}).strict()
export const CodexInitializeResultSchema = z.object({
userAgent: z.string().min(1),
codexHome: z.string().min(1),
platformFamily: z.string().min(1),
platformOs: z.string().min(1),
-})
+}).passthrough()
+
+export const CodexReasoningEffortSchema = z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh'])
+export const CodexSandboxModeSchema = z.enum(['read-only', 'workspace-write', 'danger-full-access'])
+export const CodexNetworkAccessSchema = z.enum(['restricted', 'enabled'])
+export const CodexApprovalsReviewerSchema = z.enum(['user', 'auto_review', 'guardian_subagent'])
+
+const CodexGranularAskForApprovalSchema = z.object({
+ sandbox_approval: z.boolean(),
+ rules: z.boolean(),
+ mcp_elicitations: z.boolean(),
+ skill_approval: z.boolean().default(false),
+ request_permissions: z.boolean().default(false),
+}).strict()
+
+export const CodexAskForApprovalSchema = z.union([
+ z.enum(['untrusted', 'on-failure', 'on-request', 'never']),
+ z.object({ granular: CodexGranularAskForApprovalSchema }).strict(),
+])
+
+export const CodexSandboxPolicySchema = z.discriminatedUnion('type', [
+ z.object({ type: z.literal('dangerFullAccess') }).strict(),
+ z.object({
+ type: z.literal('readOnly'),
+ networkAccess: z.boolean().default(false),
+ }).strict(),
+ z.object({
+ type: z.literal('externalSandbox'),
+ networkAccess: CodexNetworkAccessSchema.default('restricted'),
+ }).strict(),
+ z.object({
+ type: z.literal('workspaceWrite'),
+ writableRoots: z.array(z.string()).default([]),
+ networkAccess: z.boolean().default(false),
+ excludeTmpdirEnvVar: z.boolean().default(false),
+ excludeSlashTmp: z.boolean().default(false),
+ }).strict(),
+])
+
+export const CodexSandboxResultSchema = z.union([
+ CodexSandboxModeSchema,
+ CodexSandboxPolicySchema,
+])
+
+export const CodexUserInputSchema = z.discriminatedUnion('type', [
+ z.object({
+ type: z.literal('text'),
+ text: z.string(),
+ text_elements: z.array(z.unknown()).default([]),
+ }).passthrough(),
+ z.object({
+ type: z.literal('image'),
+ url: z.string(),
+ }).passthrough(),
+ z.object({
+ type: z.literal('localImage'),
+ path: z.string(),
+ }).passthrough(),
+ z.object({
+ type: z.literal('skill'),
+ name: z.string(),
+ path: z.string(),
+ }).passthrough(),
+ z.object({
+ type: z.literal('mention'),
+ name: z.string(),
+ path: z.string(),
+ }).passthrough(),
+])
+
+export const CodexThreadStatusSchema = z.discriminatedUnion('type', [
+ z.object({ type: z.literal('notLoaded') }).strict(),
+ z.object({ type: z.literal('idle') }).strict(),
+ z.object({ type: z.literal('systemError') }).passthrough(),
+ z.object({
+ type: z.literal('active'),
+ activeFlags: z.array(z.unknown()),
+ }).passthrough(),
+])
+
+export const CodexTurnStatusSchema = z.enum(['completed', 'interrupted', 'failed', 'inProgress'])
+
+export const CodexSessionSourceSchema = z.union([
+ z.enum(['cli', 'vscode', 'exec', 'appServer', 'unknown']),
+ z.object({ custom: z.string() }).strict(),
+ z.object({ subAgent: z.unknown() }).strict(),
+])
+
+export const CodexThreadItemTypeSchema = z.enum([
+ 'userMessage',
+ 'hookPrompt',
+ 'agentMessage',
+ 'plan',
+ 'reasoning',
+ 'commandExecution',
+ 'fileChange',
+ 'mcpToolCall',
+ 'dynamicToolCall',
+ 'collabAgentToolCall',
+ 'webSearch',
+ 'imageView',
+ 'imageGeneration',
+ 'enteredReviewMode',
+ 'exitedReviewMode',
+ 'contextCompaction',
+])
+
+export const CodexThreadItemSchema = z.object({
+ type: CodexThreadItemTypeSchema,
+ id: z.string().min(1),
+}).passthrough()
+
+export const CodexTurnSchema = z.object({
+ id: z.string().min(1),
+ items: z.array(CodexThreadItemSchema),
+ status: CodexTurnStatusSchema,
+ error: z.unknown().nullable().optional().default(null),
+ startedAt: z.number().nullable().optional().default(null),
+ completedAt: z.number().nullable().optional().default(null),
+ durationMs: z.number().nullable().optional().default(null),
+}).passthrough()
export const CodexThreadSchema = z.object({
id: z.string().min(1),
- path: z.string().min(1).nullable().optional(),
- ephemeral: z.boolean().optional(),
+ sessionId: z.string().min(1).optional(),
+ preview: z.string().optional().default(''),
+ ephemeral: z.boolean().optional().default(false),
+ modelProvider: z.string().optional().default('unknown'),
+ createdAt: z.number().optional().default(0),
+ updatedAt: z.number().optional().default(0),
+ status: CodexThreadStatusSchema.optional().default({ type: 'idle' }),
+ cwd: z.string().optional().default(''),
+ cliVersion: z.string().optional().default(''),
+ source: CodexSessionSourceSchema.optional().default('unknown'),
+ turns: z.array(CodexTurnSchema).optional().default([]),
+ forkedFromId: z.string().nullable().optional().default(null),
+ path: z.string().nullable().optional().default(null),
+ agentNickname: z.string().nullable().optional().default(null),
+ agentRole: z.string().nullable().optional().default(null),
+ gitInfo: z.unknown().nullable().optional().default(null),
+ name: z.string().nullable().optional().default(null),
}).passthrough()
export const CodexThreadStartParamsSchema = z.object({
- cwd: z.string().optional(),
- model: z.string().optional(),
- sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(),
- approvalPolicy: z.string().optional(),
- experimentalRawEvents: z.boolean(),
- persistExtendedHistory: z.boolean(),
-})
+ cwd: z.string().nullable().optional(),
+ model: z.string().nullable().optional(),
+ modelProvider: z.string().nullable().optional(),
+ serviceTier: z.string().nullable().optional(),
+ serviceName: z.string().nullable().optional(),
+ sandbox: CodexSandboxModeSchema.nullable().optional(),
+ approvalPolicy: CodexAskForApprovalSchema.nullable().optional(),
+ approvalsReviewer: CodexApprovalsReviewerSchema.nullable().optional(),
+ config: z.record(z.string(), z.unknown()).nullable().optional(),
+ baseInstructions: z.string().nullable().optional(),
+ developerInstructions: z.string().nullable().optional(),
+ personality: z.unknown().nullable().optional(),
+ ephemeral: z.boolean().nullable().optional(),
+ threadSource: z.unknown().nullable().optional(),
+ sessionStartSource: z.enum(['startup', 'clear']).nullable().optional(),
+ experimentalRawEvents: z.boolean().optional().default(false),
+ persistExtendedHistory: z.boolean().optional().default(false),
+}).strict()
export const CodexThreadResumeParamsSchema = z.object({
threadId: z.string().min(1),
- cwd: z.string().optional(),
- model: z.string().optional(),
- sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(),
- approvalPolicy: z.string().optional(),
- persistExtendedHistory: z.boolean(),
-})
+ cwd: z.string().nullable().optional(),
+ model: z.string().nullable().optional(),
+ modelProvider: z.string().nullable().optional(),
+ serviceTier: z.string().nullable().optional(),
+ sandbox: CodexSandboxModeSchema.nullable().optional(),
+ approvalPolicy: CodexAskForApprovalSchema.nullable().optional(),
+ approvalsReviewer: CodexApprovalsReviewerSchema.nullable().optional(),
+ config: z.record(z.string(), z.unknown()).nullable().optional(),
+ baseInstructions: z.string().nullable().optional(),
+ developerInstructions: z.string().nullable().optional(),
+ personality: z.unknown().nullable().optional(),
+ persistExtendedHistory: z.boolean().optional().default(false),
+ excludeTurns: z.boolean().nullable().optional(),
+}).strict()
+
+export const CodexThreadForkParamsSchema = z.object({
+ threadId: z.string().min(1),
+ cwd: z.string().nullable().optional(),
+ model: z.string().nullable().optional(),
+ modelProvider: z.string().nullable().optional(),
+ serviceTier: z.string().nullable().optional(),
+ sandbox: CodexSandboxModeSchema.nullable().optional(),
+ approvalPolicy: CodexAskForApprovalSchema.nullable().optional(),
+ approvalsReviewer: CodexApprovalsReviewerSchema.nullable().optional(),
+ config: z.record(z.string(), z.unknown()).nullable().optional(),
+ baseInstructions: z.string().nullable().optional(),
+ developerInstructions: z.string().nullable().optional(),
+ personality: z.unknown().nullable().optional(),
+ ephemeral: z.boolean().nullable().optional(),
+ excludeTurns: z.boolean().nullable().optional(),
+}).strict()
export const CodexThreadOperationResultSchema = z.object({
thread: CodexThreadSchema,
-})
+ approvalPolicy: CodexAskForApprovalSchema,
+ approvalsReviewer: CodexApprovalsReviewerSchema,
+ cwd: z.string(),
+ model: z.string(),
+ modelProvider: z.string(),
+ sandbox: CodexSandboxResultSchema,
+ serviceTier: z.string().nullable().optional().default(null),
+ instructionSources: z.array(z.unknown()).optional().default([]),
+ reasoningEffort: CodexReasoningEffortSchema.nullable().optional().default(null),
+}).passthrough()
export const CodexFsWatchParamsSchema = z.object({
path: z.string().min(1),
@@ -61,20 +244,86 @@ export const CodexFsUnwatchParamsSchema = z.object({
watchId: z.string().min(1),
})
+export const CodexLoadedThreadListResultSchema = z.object({
+ data: z.array(z.string().min(1)),
+})
+
+export const CodexThreadReadParamsSchema = z.object({
+ threadId: z.string().min(1),
+ includeTurns: z.boolean().optional().default(false),
+}).strict()
+
+export const CodexThreadReadResultSchema = z.object({
+ thread: CodexThreadSchema,
+}).passthrough()
+
+export const CodexThreadPageParamsSchema = z.object({
+ threadId: z.string().min(1),
+ cursor: z.string().min(1).optional(),
+ limit: z.number().int().positive().optional(),
+ sortDirection: z.enum(['asc', 'desc']).optional(),
+}).strict()
+
+export const CodexThreadTurnsListResultSchema = z.object({
+ revision: z.number().int().nonnegative().optional(),
+ nextCursor: z.string().nullable().optional().default(null),
+ backwardsCursor: z.string().nullable().optional().default(null),
+ turns: z.array(CodexTurnSchema),
+ bodies: z.record(z.string(), CodexTurnSchema).optional(),
+}).passthrough()
+
+export const CodexThreadTurnReadParamsSchema = z.object({
+ threadId: z.string().min(1),
+ turnId: z.string().min(1),
+ revision: z.number().int().nonnegative().optional(),
+}).strict()
+
+export const CodexThreadTurnReadResultSchema = CodexTurnSchema.extend({
+ turnId: z.string().min(1).optional(),
+ revision: z.number().int().nonnegative().optional(),
+}).passthrough()
+
+export const CodexTurnStartParamsSchema = z.object({
+ threadId: z.string().min(1),
+ input: z.array(CodexUserInputSchema),
+ cwd: z.string().nullable().optional(),
+ approvalPolicy: CodexAskForApprovalSchema.nullable().optional(),
+ approvalsReviewer: CodexApprovalsReviewerSchema.nullable().optional(),
+ sandboxPolicy: CodexSandboxPolicySchema.nullable().optional(),
+ model: z.string().nullable().optional(),
+ serviceTier: z.string().nullable().optional(),
+ effort: CodexReasoningEffortSchema.nullable().optional(),
+ summary: z.string().nullable().optional(),
+ personality: z.unknown().nullable().optional(),
+ outputSchema: z.unknown().nullable().optional(),
+}).strict()
+
+export const CodexTurnStartResultSchema = z.object({
+ turn: CodexTurnSchema,
+}).passthrough()
+
+export const CodexTurnInterruptParamsSchema = z.object({
+ threadId: z.string().min(1),
+ turnId: z.string().min(1),
+}).strict()
+
+export const CodexTurnInterruptResultSchema = z.object({}).strict()
+
export const CodexRpcErrorSchema = z.object({
- code: z.number(),
+ code: z.number().int(),
message: z.string().min(1),
+ data: z.unknown().optional(),
}).passthrough()
export const CodexRpcSuccessEnvelopeSchema = z.object({
- id: z.number().int(),
+ id: CodexRequestIdSchema,
result: z.unknown(),
-}).passthrough()
+}).strict()
export const CodexRpcErrorEnvelopeSchema = z.object({
- id: z.number().int().optional(),
+ id: CodexRequestIdSchema.optional(),
error: CodexRpcErrorSchema,
-}).passthrough()
+}).strict()
export const CodexRpcNotificationEnvelopeSchema = z.object({
method: z.string().min(1),
@@ -121,19 +370,51 @@ export const CodexFsChangedNotificationSchema = z.object({
}),
}).passthrough()
+export const CodexTurnStartedNotificationSchema = z.object({
+ method: z.literal('turn/started'),
+ params: z.object({
+ threadId: z.string().min(1),
+ turnId: z.string().min(1).optional(),
+ }).passthrough(),
+}).passthrough()
+
+export const CodexTurnCompletedNotificationSchema = z.object({
+ method: z.literal('turn/completed'),
+ params: z.object({
+ threadId: z.string().min(1),
+ turnId: z.string().min(1).optional(),
+ }).passthrough(),
+}).passthrough()
+
+export type CodexRequestId = z.infer
export type CodexInitializeCapabilities = z.infer
export type CodexInitializeParams = z.infer
export type CodexInitializeResult = z.infer
-export type CodexThreadHandle = z.infer
-export type CodexThreadStartParams = z.infer
-export type CodexThreadResumeParams = z.infer
+export type CodexThreadHandle = z.input
+export type CodexThreadStartParams = z.input
+export type CodexThreadResumeParams = z.input
+export type CodexThreadForkParams = z.input
export type CodexThreadOperationResult = z.infer
export type CodexFsWatchParams = z.infer
export type CodexFsWatchResult = z.infer
export type CodexFsUnwatchParams = z.infer
+export type CodexLoadedThreadListResult = z.infer
+export type CodexThreadReadParams = z.input
+export type CodexThreadReadResult = z.infer
+export type CodexThreadPageParams = z.input
+export type CodexThreadTurnsListParams = CodexThreadPageParams
+export type CodexThreadTurnsListResult = z.infer
+export type CodexThreadTurnReadParams = z.infer
+export type CodexThreadTurnReadResult = z.infer
+export type CodexTurnStartParams = z.input
+export type CodexTurnStartResult = z.infer
+export type CodexTurnInterruptParams = z.input
+export type CodexTurnInterruptResult = z.infer
export type CodexRpcError = z.infer
export type CodexThreadStartedNotification = z.infer
export type CodexThreadClosedNotification = z.infer
export type CodexThreadStatusChangedNotification = z.infer
export type CodexThreadLifecycleNotification = z.infer
export type CodexFsChangedNotification = z.infer
+export type CodexTurnStartedNotification = z.infer
+export type CodexTurnCompletedNotification = z.infer
diff --git a/server/coding-cli/codex-app-server/remote-proxy.ts b/server/coding-cli/codex-app-server/remote-proxy.ts
new file mode 100644
index 000000000..ec6ccc9cd
--- /dev/null
+++ b/server/coding-cli/codex-app-server/remote-proxy.ts
@@ -0,0 +1,534 @@
+import WebSocket, { WebSocketServer } from 'ws'
+import { allocateLocalhostPort, type LoopbackServerEndpoint } from '../../local-port.js'
+import {
+ CodexFsChangedNotificationSchema,
+ CodexThreadLifecycleNotificationSchema,
+ CodexTurnCompletedNotificationSchema,
+ CodexTurnStartedNotificationSchema,
+ type CodexThreadHandle,
+} from './protocol.js'
+import type { CodexThreadLifecycleEvent, CodexThreadLifecycleLossEvent, CodexTurnEvent } from './client.js'
+import { logger } from '../../logger.js'
+
+const log = logger.child({ component: 'codex-remote-proxy' })
+
+export type CodexRemoteProxyCandidate = {
+ thread: CodexThreadHandle
+ source: 'thread_start_response' | 'thread_started_notification'
+}
+
+export type CodexRemoteProxyRepairTrigger =
+ | { kind: 'proxy_close' | 'proxy_error' | 'candidate_capture_timeout'; error?: Error }
+ | { kind: 'fs_changed'; watchId: string; changedPaths: string[] }
+
+type JsonRpcId = string | number
+
+type PendingTurnStart = {
+ raw: WebSocket.RawData | string
+ client: WebSocket
+ upstream: WebSocket
+ id?: JsonRpcId
+ timer: NodeJS.Timeout
+}
+
+type ProxyConnection = {
+ client: WebSocket
+ upstream: WebSocket
+ pendingMethods: Map
+}
+
+type CodexRemoteProxyOptions = {
+ upstreamWsUrl: string
+ portAllocator?: () => Promise
+ requestHoldTimeoutMs?: number
+ candidateCaptureTimeoutMs?: number
+ requireCandidatePersistence?: boolean
+}
+
+const DEFAULT_REQUEST_HOLD_TIMEOUT_MS = 5_000
+const DEFAULT_CANDIDATE_CAPTURE_TIMEOUT_MS = 45_000
+
+export class CodexRemoteProxy {
+ private readonly upstreamWsUrl: string
+ private readonly portAllocator: () => Promise
+ private readonly requestHoldTimeoutMs: number
+ private readonly candidateCaptureTimeoutMs: number
+ private readonly requireCandidatePersistence: boolean
+ private server: WebSocketServer | null = null
+ private endpoint: LoopbackServerEndpoint | null = null
+ private candidatePersisted = false
+ private candidateCaptureFailed = false
+ private candidateCaptureTimer: NodeJS.Timeout | null = null
+ private readonly pendingTurnStarts = new Set()
+ private readonly connections = new Set()
+ private readonly candidateHandlers = new Set<(candidate: CodexRemoteProxyCandidate) => void>()
+ private readonly turnStartedHandlers = new Set<(event: CodexTurnEvent) => void>()
+ private readonly turnCompletedHandlers = new Set<(event: CodexTurnEvent) => void>()
+ private readonly repairTriggerHandlers = new Set<(event: CodexRemoteProxyRepairTrigger) => void>()
+ private readonly lifecycleHandlers = new Set<(event: CodexThreadLifecycleEvent) => void>()
+ private readonly lifecycleLossHandlers = new Set<(event: CodexThreadLifecycleLossEvent) => void>()
+
+ constructor(options: CodexRemoteProxyOptions) {
+ this.upstreamWsUrl = options.upstreamWsUrl
+ this.portAllocator = options.portAllocator ?? allocateLocalhostPort
+ this.requestHoldTimeoutMs = options.requestHoldTimeoutMs ?? DEFAULT_REQUEST_HOLD_TIMEOUT_MS
+ this.candidateCaptureTimeoutMs = options.candidateCaptureTimeoutMs ?? DEFAULT_CANDIDATE_CAPTURE_TIMEOUT_MS
+ this.requireCandidatePersistence = options.requireCandidatePersistence ?? true
+ this.candidatePersisted = !this.requireCandidatePersistence
+ }
+
+ get wsUrl(): string {
+ if (!this.endpoint) {
+ throw new Error('Codex remote proxy has not been started.')
+ }
+ return `ws://${this.endpoint.hostname}:${this.endpoint.port}`
+ }
+
+ async start(): Promise<{ wsUrl: string }> {
+ if (this.server) return { wsUrl: this.wsUrl }
+ const endpoint = await this.portAllocator()
+ await new Promise((resolve, reject) => {
+ const server = new WebSocketServer({ host: endpoint.hostname, port: endpoint.port }, () => resolve())
+ server.once('error', reject)
+ server.on('connection', (client) => this.handleClientConnection(client))
+ this.server = server
+ this.endpoint = endpoint
+ })
+ if (this.requireCandidatePersistence) {
+ this.ensureCandidateCaptureTimer()
+ }
+ log.info({
+ wsUrl: this.wsUrl,
+ upstreamWsUrl: this.upstreamWsUrl,
+ requireCandidatePersistence: this.requireCandidatePersistence,
+ }, 'Codex remote proxy listening')
+ return { wsUrl: this.wsUrl }
+ }
+
+ async close(): Promise