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/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/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 9803fb63a..8742ce1fa 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() {
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/coding-cli/codex-app-server/client.ts b/server/coding-cli/codex-app-server/client.ts
index c0154263d..2675cc35e 100644
--- a/server/coding-cli/codex-app-server/client.ts
+++ b/server/coding-cli/codex-app-server/client.ts
@@ -13,12 +13,35 @@ import {
CodexThreadLifecycleNotificationSchema,
CodexThreadStartedNotificationSchema,
CodexThreadOperationResultSchema,
+ CodexThreadPageParamsSchema,
+ CodexThreadForkParamsSchema,
+ CodexThreadReadParamsSchema,
+ CodexThreadReadResultSchema,
+ CodexThreadResumeParamsSchema,
+ CodexThreadSchema,
+ CodexThreadStartParamsSchema,
+ CodexThreadTurnReadResultSchema,
+ CodexThreadTurnsListResultSchema,
+ CodexTurnInterruptParamsSchema,
+ CodexTurnInterruptResultSchema,
+ CodexTurnStartParamsSchema,
+ CodexTurnStartResultSchema,
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 = {
@@ -43,6 +66,13 @@ export type CodexThreadLifecycleLossEvent =
const DEFAULT_REQUEST_TIMEOUT_MS = 5_000
const LOSS_STATUSES = new Set(['notLoaded', 'systemError'])
+type CodexThreadStartInput =
+ Omit & {
+ richClient?: boolean
+ }
+
+type CodexThreadResumeInput = Omit
+
export type CodexThreadLifecycleEvent = {
kind: 'thread_started'
thread: CodexThreadHandle
@@ -60,12 +90,8 @@ export type CodexAppServerDisconnectEvent = {
error?: Error
}
-function normalizeThread(thread: CodexThreadHandle): CodexThreadHandle {
- return {
- ...thread,
- path: thread.path ?? null,
- ephemeral: thread.ephemeral ?? false,
- }
+function normalizeThread(thread: CodexThreadHandle): CodexThreadOperationResult['thread'] {
+ return CodexThreadSchema.parse(thread)
}
export class CodexAppServerClient {
@@ -74,7 +100,7 @@ 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>()
@@ -95,6 +121,7 @@ export class CodexAppServerClient {
clientInfo: { name: 'freshell', version: '1.0.0' },
capabilities: {
experimentalApi: true,
+ optOutNotificationMethods: ['thread/started'],
},
})).then(async (result) => {
const parsed = CodexInitializeResultSchema.safeParse(result)
@@ -111,36 +138,37 @@ export class CodexAppServerClient {
return this.initializePromise
}
- async startThread(
- params: Omit,
- ): Promise<{ threadId: string }> {
- 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.')
}
- return { threadId: parsed.data.thread.id }
+ return {
+ ...parsed.data,
+ thread: normalizeThread(parsed.data.thread),
+ }
}
- async resumeThread(
- params: Omit,
- ): Promise<{ threadId: string }> {
+ 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.')
}
- return { threadId: parsed.data.thread.id }
+ return {
+ ...parsed.data,
+ thread: normalizeThread(parsed.data.thread),
+ }
}
async watchPath(targetPath: string, watchId: string): Promise<{ path: string }> {
@@ -170,6 +198,84 @@ export class CodexAppServerClient {
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
@@ -484,7 +590,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)
diff --git a/server/coding-cli/codex-app-server/launch-planner.ts b/server/coding-cli/codex-app-server/launch-planner.ts
index 020bf3278..532ab8ac3 100644
--- a/server/coding-cli/codex-app-server/launch-planner.ts
+++ b/server/coding-cli/codex-app-server/launch-planner.ts
@@ -1,5 +1,6 @@
import type { CodexAppServerRuntime } from './runtime.js'
import type { CodexThreadLifecycleLossEvent } from './client.js'
+import type { CodexThreadStartParams } from './protocol.js'
import { waitForAllSettledOrThrow } from '../../shutdown-join.js'
type CodexRuntimeLike = Pick<
@@ -35,6 +36,14 @@ type PlanCreateInput = {
approvalPolicy?: string
}
+export function normalizeCodexApprovalPolicy(value: string | undefined): CodexThreadStartParams['approvalPolicy'] {
+ if (value === undefined || value === 'default') return undefined
+ if (value === 'untrusted' || value === 'on-failure' || value === 'on-request' || value === 'never') {
+ return value
+ }
+ throw new Error(`Codex app-server does not support permission mode "${value}". Choose default, untrusted, on-failure, on-request, or never.`)
+}
+
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
@@ -89,7 +98,7 @@ export class CodexLaunchPlanner {
cwd: input.cwd,
model: input.model,
sandbox: input.sandbox,
- approvalPolicy: input.approvalPolicy,
+ approvalPolicy: normalizeCodexApprovalPolicy(input.approvalPolicy),
})
this.assertAcceptingPlans()
diff --git a/server/coding-cli/codex-app-server/protocol.ts b/server/coding-cli/codex-app-server/protocol.ts
index ea3e6a906..57e826141 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),
@@ -65,20 +248,82 @@ 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),
@@ -125,17 +370,30 @@ export const CodexFsChangedNotificationSchema = z.object({
}),
}).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
diff --git a/server/coding-cli/codex-app-server/runtime.ts b/server/coding-cli/codex-app-server/runtime.ts
index 96471533c..23451b3e9 100644
--- a/server/coding-cli/codex-app-server/runtime.ts
+++ b/server/coding-cli/codex-app-server/runtime.ts
@@ -14,12 +14,27 @@ import type {
CodexFsWatchResult,
CodexInitializeResult,
CodexThreadHandle,
+ CodexThreadOperationResult,
+ CodexThreadForkParams,
+ CodexThreadReadParams,
+ CodexThreadReadResult,
CodexThreadResumeParams,
CodexThreadStartParams,
+ CodexThreadTurnReadParams,
+ CodexThreadTurnReadResult,
+ CodexThreadTurnsListParams,
+ CodexThreadTurnsListResult,
+ CodexTurnInterruptParams,
+ CodexTurnStartParams,
} from './protocol.js'
type RuntimeStatus = 'running' | 'stopped'
+type CodexThreadStartInput =
+ Omit & {
+ richClient?: boolean
+ }
+
export type CodexAppServerRuntimeFailureSource =
| 'app_server_exit'
| 'app_server_client_disconnect'
@@ -557,21 +572,25 @@ export class CodexAppServerRuntime {
}
async startThread(
- params: Omit,
- ): Promise<{ threadId: string; wsUrl: string }> {
+ params: CodexThreadStartInput,
+ ): Promise {
const ready = await this.ensureReady()
+ const result = await this.client!.startThread(params)
return {
- ...(await this.client!.startThread(params)),
+ ...result,
+ threadId: result.thread.id,
wsUrl: ready.wsUrl,
}
}
async resumeThread(
params: Omit,
- ): Promise<{ threadId: string; wsUrl: string }> {
+ ): Promise {
const ready = await this.ensureReady()
+ const result = await this.client!.resumeThread(params)
return {
- ...(await this.client!.resumeThread(params)),
+ ...result,
+ threadId: result.thread.id,
wsUrl: ready.wsUrl,
}
}
@@ -581,6 +600,41 @@ export class CodexAppServerRuntime {
return this.client!.listLoadedThreads()
}
+ async forkThread(
+ params: CodexThreadForkParams,
+ ): Promise<{ threadId: string; wsUrl: string }> {
+ const ready = await this.ensureReady()
+ return {
+ ...(await this.client!.forkThread(params)),
+ wsUrl: ready.wsUrl,
+ }
+ }
+
+ async readThread(params: CodexThreadReadParams): Promise {
+ await this.ensureReady()
+ return await this.client!.readThread(params)
+ }
+
+ async listThreadTurns(params: CodexThreadTurnsListParams): Promise {
+ await this.ensureReady()
+ return await this.client!.listThreadTurns(params)
+ }
+
+ async readThreadTurn(params: CodexThreadTurnReadParams): Promise {
+ await this.ensureReady()
+ return await this.client!.readThreadTurn(params)
+ }
+
+ async startTurn(params: CodexTurnStartParams): Promise<{ turnId: string }> {
+ await this.ensureReady()
+ return await this.client!.startTurn(params)
+ }
+
+ async interruptTurn(params: CodexTurnInterruptParams): Promise {
+ await this.ensureReady()
+ await this.client!.interruptTurn(params)
+ }
+
async updateOwnershipMetadata(input: {
terminalId?: string | null
generation?: number | null
diff --git a/server/config-store.ts b/server/config-store.ts
index 1e475bbf5..bd98b01bc 100644
--- a/server/config-store.ts
+++ b/server/config-store.ts
@@ -285,6 +285,49 @@ function migrateLegacyFreshClaudeSettings(rawSettings: Record):
return migrated
}
+function normalizeFreshAgentCompatSettings(rawSettings: Record): Record {
+ const freshAgent = isRecord(rawSettings.freshAgent) ? rawSettings.freshAgent : undefined
+ const agentChat = isRecord(rawSettings.agentChat) ? rawSettings.agentChat : undefined
+
+ if (!freshAgent && !agentChat) {
+ return rawSettings
+ }
+
+ const merged: Record = {
+ ...(agentChat || {}),
+ ...(freshAgent || {}),
+ }
+
+ const freshPlugins = Array.isArray(freshAgent?.defaultPlugins) ? freshAgent.defaultPlugins : undefined
+ const agentPlugins = Array.isArray(agentChat?.defaultPlugins) ? agentChat.defaultPlugins : undefined
+ if ((freshPlugins?.length ?? 0) > 0) {
+ merged.defaultPlugins = freshPlugins
+ } else if (agentPlugins) {
+ merged.defaultPlugins = agentPlugins
+ }
+
+ const freshProviders = isRecord(freshAgent?.providers) ? freshAgent.providers : undefined
+ const agentProviders = isRecord(agentChat?.providers) ? agentChat.providers : undefined
+ if (freshProviders || agentProviders) {
+ merged.providers = {
+ ...(agentProviders || {}),
+ ...(freshProviders || {}),
+ }
+ }
+
+ if (typeof freshAgent?.initialSetupDone === 'boolean') {
+ merged.initialSetupDone = freshAgent.initialSetupDone
+ } else if (typeof agentChat?.initialSetupDone === 'boolean') {
+ merged.initialSetupDone = agentChat.initialSetupDone
+ }
+
+ return {
+ ...rawSettings,
+ freshAgent: merged,
+ agentChat: merged,
+ }
+}
+
export class ConfigStore {
private cache: UserConfig | null = null
private writeMutex = new Mutex()
@@ -310,9 +353,9 @@ export class ConfigStore {
this.lastReadError = error
if (existing) {
this.lastReadError = undefined
- const rawSettings = migrateLegacyFreshClaudeSettings(
+ const rawSettings = normalizeFreshAgentCompatSettings(migrateLegacyFreshClaudeSettings(
isRecord(existing.settings) ? { ...existing.settings } : {},
- )
+ ))
const extractedLegacyLocalSettingsSeed = extractLegacyLocalSettingsSeed(rawSettings)
const storedLegacyLocalSettingsSeed = isRecord(existing.legacyLocalSettingsSeed)
? extractLegacyLocalSettingsSeed(existing.legacyLocalSettingsSeed)
diff --git a/server/fresh-agent/adapters/claude/adapter.ts b/server/fresh-agent/adapters/claude/adapter.ts
new file mode 100644
index 000000000..8b396f1d1
--- /dev/null
+++ b/server/fresh-agent/adapters/claude/adapter.ts
@@ -0,0 +1,212 @@
+import {
+ RestoreResolutionError,
+ RestoreStaleRevisionError,
+ createAgentTimelineService,
+ type AgentTimelineService,
+} from '../../../agent-timeline/service.js'
+import type { AgentHistorySource } from '../../../agent-timeline/history-source.js'
+import type { SdkBridge } from '../../../sdk-bridge.js'
+import type { SdkSessionState } from '../../../sdk-bridge-types.js'
+import { FreshAgentStaleThreadRevisionError } from '../../runtime-manager.js'
+import type { FreshAgentCreateRequest, FreshAgentRuntimeAdapter, FreshAgentThreadLocator } from '../../runtime-adapter.js'
+import {
+ normalizeClaudeThreadSnapshot,
+ normalizeClaudeTurnBody,
+ normalizeClaudeTurnPage,
+} from './normalize.js'
+
+type ClaudeBridgePort = Pick<
+ SdkBridge,
+ | 'createSession'
+ | 'subscribe'
+ | 'sendUserMessage'
+ | 'interrupt'
+ | 'killSession'
+ | 'respondQuestion'
+ | 'respondPermission'
+ | 'getSession'
+ | 'findSessionByCliSessionId'
+>
+
+export type ClaudeFreshAgentAdapterDeps = {
+ sdkBridge: ClaudeBridgePort
+ agentHistorySource?: AgentHistorySource
+ timelineService?: AgentTimelineService
+}
+
+function mapTimelineError(error: unknown): never {
+ if (error instanceof RestoreStaleRevisionError) {
+ throw new FreshAgentStaleThreadRevisionError(error.actualRevision)
+ }
+ throw error
+}
+
+function toClaudeEffort(value: FreshAgentCreateRequest['effort']) {
+ if (value === undefined || value === 'low' || value === 'medium' || value === 'high' || value === 'max') {
+ return value
+ }
+ throw new Error(`Freshclaude does not support reasoning effort "${value}".`)
+}
+
+function mapMissingResult(ok: boolean, message: string): void {
+ if (!ok) {
+ throw new Error(message)
+ }
+}
+
+export function createClaudeFreshAgentAdapter(deps: ClaudeFreshAgentAdapterDeps): FreshAgentRuntimeAdapter {
+ const timelineService = deps.timelineService ?? (
+ deps.agentHistorySource
+ ? createAgentTimelineService({ agentHistorySource: deps.agentHistorySource })
+ : null
+ )
+
+ function resolveLiveSession(threadId: string): SdkSessionState | undefined {
+ return deps.sdkBridge.getSession(threadId) ?? deps.sdkBridge.findSessionByCliSessionId(threadId)
+ }
+
+ async function loadResolved(threadId: string, revision?: number) {
+ if (!timelineService) {
+ throw new Error('Claude timeline service is not configured')
+ }
+ try {
+ return await timelineService.getSnapshot({ sessionId: threadId, revision })
+ } catch (error) {
+ mapTimelineError(error)
+ }
+ }
+
+ return {
+ runtimeProvider: 'claude',
+
+ async create(input: FreshAgentCreateRequest) {
+ const session = await deps.sdkBridge.createSession({
+ cwd: input.cwd,
+ resumeSessionId: input.resumeSessionId,
+ model: input.model,
+ permissionMode: input.permissionMode,
+ effort: toClaudeEffort(input.effort),
+ plugins: input.plugins,
+ })
+ return { sessionId: session.sessionId }
+ },
+
+ async resume(input: FreshAgentCreateRequest) {
+ const session = await deps.sdkBridge.createSession({
+ cwd: input.cwd,
+ resumeSessionId: input.resumeSessionId,
+ model: input.model,
+ permissionMode: input.permissionMode,
+ effort: toClaudeEffort(input.effort),
+ plugins: input.plugins,
+ })
+ return { sessionId: session.sessionId }
+ },
+
+ subscribe(sessionId, listener) {
+ const subscription = deps.sdkBridge.subscribe(sessionId, listener as never)
+ if (!subscription) {
+ throw new Error(`Claude session ${sessionId} is not available`)
+ }
+ return subscription.off
+ },
+
+ send(sessionId, input) {
+ const images = input.images?.flatMap((image) => image.kind === 'data'
+ ? [{ mediaType: image.mediaType, data: image.data }]
+ : [])
+ mapMissingResult(
+ deps.sdkBridge.sendUserMessage(sessionId, input.text, images),
+ `Claude session ${sessionId} is not available`,
+ )
+ },
+
+ interrupt(sessionId) {
+ mapMissingResult(
+ deps.sdkBridge.interrupt(sessionId),
+ `Claude session ${sessionId} is not available`,
+ )
+ },
+
+ kill(sessionId) {
+ return deps.sdkBridge.killSession(sessionId)
+ },
+
+ answerQuestion(sessionId, requestId, answers) {
+ mapMissingResult(
+ deps.sdkBridge.respondQuestion(sessionId, String(requestId), answers),
+ `Claude question ${requestId} is not available`,
+ )
+ },
+
+ resolveApproval(sessionId, requestId, decision) {
+ mapMissingResult(
+ deps.sdkBridge.respondPermission(sessionId, String(requestId), decision as never),
+ `Claude approval ${requestId} is not available`,
+ )
+ },
+
+ async getSnapshot(thread: FreshAgentThreadLocator, revision?: number) {
+ const resolvedSnapshot = await loadResolved(thread.threadId, revision)
+ const liveSession = resolveLiveSession(thread.threadId)
+ const resolved = await deps.agentHistorySource?.resolve(
+ thread.threadId,
+ liveSession ? { liveSessionOverride: liveSession } : undefined,
+ )
+ if (!resolved || resolved.kind !== 'resolved') {
+ throw new RestoreResolutionError('RESTORE_NOT_FOUND', 'Restore session not found')
+ }
+ return normalizeClaudeThreadSnapshot({
+ threadId: thread.threadId,
+ resolved: {
+ ...resolved,
+ revision: resolvedSnapshot.revision,
+ latestTurnId: resolvedSnapshot.latestTurnId,
+ turns: resolvedSnapshot.turns,
+ },
+ liveSession,
+ status: liveSession?.status ?? 'idle',
+ })
+ },
+
+ async getTurnPage(thread, query) {
+ if (!timelineService) {
+ throw new Error('Claude timeline service is not configured')
+ }
+ try {
+ const page = await timelineService.getTimelinePage({
+ sessionId: thread.threadId,
+ cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
+ priority: typeof query.priority === 'string' ? query.priority as 'visible' | 'background' : undefined,
+ revision: Number(query.revision),
+ limit: typeof query.limit === 'number' ? query.limit : undefined,
+ includeBodies: query.includeBodies === true,
+ })
+ return normalizeClaudeTurnPage({ threadId: thread.threadId, page })
+ } catch (error) {
+ mapTimelineError(error)
+ }
+ },
+
+ async getTurnBody(thread, revision) {
+ if (!timelineService) {
+ throw new Error('Claude timeline service is not configured')
+ }
+ try {
+ const turn = await timelineService.getTurnBody({
+ sessionId: thread.threadId,
+ turnId: thread.turnId,
+ revision,
+ })
+ if (!turn) return null
+ return normalizeClaudeTurnBody({
+ turn,
+ revision,
+ threadId: thread.threadId,
+ })
+ } catch (error) {
+ mapTimelineError(error)
+ }
+ },
+ }
+}
diff --git a/server/fresh-agent/adapters/claude/normalize.ts b/server/fresh-agent/adapters/claude/normalize.ts
new file mode 100644
index 000000000..a8ed0560f
--- /dev/null
+++ b/server/fresh-agent/adapters/claude/normalize.ts
@@ -0,0 +1,251 @@
+import type { RestoreResolution } from '../../../agent-timeline/ledger.js'
+import type { AgentTimelinePage, AgentTimelineTurn } from '../../../agent-timeline/types.js'
+import type { QuestionDefinition, SdkSessionState } from '../../../sdk-bridge-types.js'
+import type { SdkSessionStatus } from '../../../../shared/ws-protocol.js'
+import type { ContentBlock } from '../../../../shared/ws-protocol.js'
+import {
+ FreshAgentSnapshotSchema,
+ FreshAgentTurnBodySchema,
+ FreshAgentTurnPageSchema,
+} from '../../../../shared/fresh-agent-contract.js'
+
+export type FreshAgentNormalizedItem =
+ | { id: string; kind: 'text'; text: string }
+ | { id: string; kind: 'thinking'; text: string }
+ | { id: string; kind: 'tool_use'; toolUseId: string; name: string; input?: Record }
+ | { id: string; kind: 'tool_result'; toolUseId: string; content: unknown; isError: boolean }
+
+export type FreshAgentNormalizedTurn = {
+ id: string
+ turnId: string
+ messageId: string
+ ordinal: number
+ source: 'durable' | 'live'
+ role: 'user' | 'assistant'
+ timestamp?: string
+ model?: string
+ summary: string
+ items: FreshAgentNormalizedItem[]
+}
+
+export type FreshAgentPendingApproval = {
+ requestId: string
+ toolName: string
+ toolUseID?: string
+ blockedPath?: string
+ decisionReason?: string
+ input?: Record
+}
+
+export type FreshAgentPendingQuestion = {
+ requestId: string
+ questions: QuestionDefinition[]
+}
+
+export type FreshAgentClaudeSnapshot = {
+ provider: 'claude'
+ threadId: string
+ sessionId: string
+ revision: number
+ latestTurnId: string | null
+ status: SdkSessionStatus
+ capabilities: {
+ send: boolean
+ interrupt: boolean
+ approvals: boolean
+ questions: boolean
+ fork: boolean
+ }
+ settings: {
+ model?: string
+ permissionMode?: string
+ plugins: string[]
+ }
+ tokenUsage: {
+ inputTokens: number
+ outputTokens: number
+ totalTokens: number
+ costUsd: number
+ }
+ pendingApprovals: FreshAgentPendingApproval[]
+ pendingQuestions: FreshAgentPendingQuestion[]
+ turns: FreshAgentNormalizedTurn[]
+ extensions: {
+ claude: {
+ timelineSessionId?: string
+ liveSessionId?: string
+ cliSessionId?: string
+ readiness?: RestoreResolution extends infer T ? T extends { kind: 'resolved'; readiness: infer R } ? R : never : never
+ }
+ }
+}
+
+export type FreshAgentClaudeTurnPage = {
+ threadId: string
+ revision: number
+ nextCursor: string | null
+ turns: FreshAgentNormalizedTurn[]
+ bodies?: Record
+}
+
+function blockSummary(blocks: ContentBlock[]): string {
+ const textBlock = blocks.find((block) => block.type === 'text' && block.text.trim().length > 0)
+ if (textBlock?.type === 'text') {
+ return textBlock.text.trim().slice(0, 140)
+ }
+ const thinkingBlock = blocks.find((block) => block.type === 'thinking' && block.thinking.trim().length > 0)
+ if (thinkingBlock?.type === 'thinking') {
+ return thinkingBlock.thinking.trim().slice(0, 140)
+ }
+ const toolBlock = blocks.find((block) => block.type === 'tool_use')
+ if (toolBlock?.type === 'tool_use') {
+ return toolBlock.name.slice(0, 140)
+ }
+ return ''
+}
+
+export function normalizeClaudeTurn(input: Pick): FreshAgentNormalizedTurn {
+ return {
+ id: input.turnId,
+ turnId: input.turnId,
+ messageId: input.messageId,
+ ordinal: input.ordinal,
+ source: input.source,
+ role: input.message.role,
+ ...(input.message.timestamp ? { timestamp: input.message.timestamp } : {}),
+ ...(input.message.model ? { model: input.message.model } : {}),
+ summary: blockSummary(input.message.content),
+ items: input.message.content.map((block, index) => {
+ const id = `${input.turnId}:item:${index}`
+ switch (block.type) {
+ case 'text':
+ return { id, kind: 'text', text: block.text }
+ case 'thinking':
+ return { id, kind: 'thinking', text: block.thinking }
+ case 'tool_use':
+ return { id, kind: 'tool_use', toolUseId: block.id, name: block.name, input: block.input }
+ case 'tool_result':
+ return {
+ id,
+ kind: 'tool_result',
+ toolUseId: block.tool_use_id,
+ content: block.content,
+ isError: Boolean(block.is_error),
+ }
+ }
+ }),
+ }
+}
+
+function normalizePendingApprovals(liveSession?: SdkSessionState): FreshAgentPendingApproval[] {
+ if (!liveSession) return []
+ return Array.from(liveSession.pendingPermissions.entries()).map(([requestId, approval]) => ({
+ requestId,
+ toolName: approval.toolName,
+ toolUseID: approval.toolUseID,
+ blockedPath: approval.blockedPath,
+ decisionReason: approval.decisionReason,
+ input: approval.input,
+ }))
+}
+
+function normalizePendingQuestions(liveSession?: SdkSessionState): FreshAgentPendingQuestion[] {
+ if (!liveSession) return []
+ return Array.from(liveSession.pendingQuestions.entries()).map(([requestId, question]) => ({
+ requestId,
+ questions: question.questions,
+ }))
+}
+
+export function normalizeClaudeThreadSnapshot(input: {
+ threadId: string
+ resolved: Extract
+ liveSession?: SdkSessionState
+ status: SdkSessionStatus
+}): FreshAgentClaudeSnapshot {
+ const sessionId = input.liveSession?.sessionId ?? input.resolved.liveSessionId ?? input.threadId
+ const turns = input.resolved.turns.map((turn) => normalizeClaudeTurn(turn))
+ const inputTokens = input.liveSession?.totalInputTokens ?? 0
+ const outputTokens = input.liveSession?.totalOutputTokens ?? 0
+ return FreshAgentSnapshotSchema.parse({
+ sessionType: 'freshclaude',
+ provider: 'claude',
+ threadId: input.threadId,
+ sessionId,
+ revision: input.resolved.revision,
+ latestTurnId: input.resolved.latestTurnId,
+ status: input.status,
+ capabilities: {
+ send: true,
+ interrupt: input.status !== 'exited',
+ approvals: normalizePendingApprovals(input.liveSession).length > 0,
+ questions: normalizePendingQuestions(input.liveSession).length > 0,
+ fork: false,
+ },
+ settings: {
+ ...(input.liveSession?.model ? { model: input.liveSession.model } : {}),
+ ...(input.liveSession?.permissionMode ? { permissionMode: input.liveSession.permissionMode } : {}),
+ plugins: input.liveSession?.plugins ? [...input.liveSession.plugins] : [],
+ },
+ tokenUsage: {
+ inputTokens,
+ outputTokens,
+ totalTokens: inputTokens + outputTokens,
+ costUsd: input.liveSession?.costUsd ?? 0,
+ },
+ pendingApprovals: normalizePendingApprovals(input.liveSession),
+ pendingQuestions: normalizePendingQuestions(input.liveSession),
+ turns,
+ extensions: {
+ claude: {
+ timelineSessionId: input.resolved.timelineSessionId,
+ liveSessionId: input.resolved.liveSessionId,
+ cliSessionId: input.liveSession?.cliSessionId,
+ readiness: input.resolved.readiness,
+ },
+ },
+ }) as FreshAgentClaudeSnapshot
+}
+
+export function normalizeClaudeTurnPage(input: {
+ threadId: string
+ page: AgentTimelinePage
+}): FreshAgentClaudeTurnPage {
+ return FreshAgentTurnPageSchema.parse({
+ sessionType: 'freshclaude',
+ provider: 'claude',
+ threadId: input.threadId,
+ revision: input.page.revision,
+ nextCursor: input.page.nextCursor,
+ turns: input.page.items.map((item) => ({
+ id: item.turnId,
+ turnId: item.turnId,
+ messageId: item.messageId,
+ ordinal: item.ordinal,
+ source: item.source,
+ role: item.role,
+ ...(item.timestamp ? { timestamp: item.timestamp } : {}),
+ summary: item.summary,
+ items: [],
+ })),
+ ...(input.page.bodies ? {
+ bodies: Object.fromEntries(
+ Object.entries(input.page.bodies).map(([turnId, turn]) => [turnId, normalizeClaudeTurn(turn)]),
+ ),
+ } : {}),
+ }) as FreshAgentClaudeTurnPage
+}
+
+export function normalizeClaudeTurnBody(input: {
+ turn: AgentTimelineTurn
+ revision: number
+ threadId: string
+}) {
+ return FreshAgentTurnBodySchema.parse({
+ ...normalizeClaudeTurn(input.turn),
+ sessionType: 'freshclaude',
+ provider: 'claude',
+ threadId: input.threadId,
+ revision: input.revision,
+ })
+}
diff --git a/server/fresh-agent/adapters/codex/adapter.ts b/server/fresh-agent/adapters/codex/adapter.ts
new file mode 100644
index 000000000..d1e893b25
--- /dev/null
+++ b/server/fresh-agent/adapters/codex/adapter.ts
@@ -0,0 +1,292 @@
+import type { FreshAgentCreateRequest, FreshAgentInputImage, FreshAgentRuntimeAdapter } from '../../runtime-adapter.js'
+import type {
+ CodexThreadForkParams,
+ CodexTurnInterruptParams,
+ CodexTurnStartParams,
+} from '../../../coding-cli/codex-app-server/protocol.js'
+import {
+ normalizeCodexThreadSnapshot,
+ normalizeCodexTurn,
+ normalizeCodexTurnBody,
+ normalizeCodexTurnPage,
+} from './normalize.js'
+
+type CodexThreadLifecycleEvent =
+ | {
+ kind: 'thread_started'
+ thread: {
+ id: string
+ updatedAt?: number
+ status?: unknown
+ }
+ }
+ | {
+ kind: 'thread_closed'
+ threadId: string
+ }
+ | {
+ kind: 'thread_status_changed'
+ threadId: string
+ status: unknown
+ }
+
+type CodexRuntimePort = {
+ startThread: (input: {
+ cwd?: string
+ model?: string
+ sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access'
+ approvalPolicy?: 'untrusted' | 'on-failure' | 'on-request' | 'never'
+ excludeTurns?: boolean
+ }) => Promise<{ threadId: string; wsUrl: string }>
+ resumeThread: (input: {
+ threadId: string
+ cwd?: string
+ model?: string
+ sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access'
+ approvalPolicy?: 'untrusted' | 'on-failure' | 'on-request' | 'never'
+ }) => Promise<{ threadId: string; wsUrl: string }>
+ forkThread?: (input: CodexThreadForkParams) => Promise<{ threadId: string; wsUrl: string }>
+ startTurn?: (input: CodexTurnStartParams) => Promise<{ turnId: string }>
+ interruptTurn?: (input: CodexTurnInterruptParams) => Promise
+ onThreadLifecycle?: (handler: (event: CodexThreadLifecycleEvent) => void) => () => void
+ readThread: (input: { threadId: string; includeTurns?: boolean }) => Promise>
+ listThreadTurns: (input: {
+ threadId: string
+ cursor?: string
+ limit?: number
+ }) => Promise>
+ readThreadTurn: (input: { threadId: string; turnId: string; revision?: number }) => Promise>
+}
+
+function toCodexApprovalPolicy(value: string | undefined) {
+ if (value === undefined) return undefined
+ if (value === 'untrusted' || value === 'on-failure' || value === 'on-request' || value === 'never') {
+ return value
+ }
+ throw new Error(`Freshcodex does not support approval policy "${value}". Choose untrusted, on-failure, on-request, or never.`)
+}
+
+function toCodexReasoningEffort(value: FreshAgentCreateRequest['effort'] | undefined) {
+ if (value === undefined) return undefined
+ if (value === 'none' || value === 'minimal' || value === 'low' || value === 'medium' || value === 'high' || value === 'xhigh') {
+ return value
+ }
+ throw new Error(`Freshcodex does not support reasoning effort "${value}". Choose none, minimal, low, medium, high, or xhigh.`)
+}
+
+function toCodexSandboxPolicy(value: FreshAgentCreateRequest['sandbox'] | undefined): CodexTurnStartParams['sandboxPolicy'] {
+ switch (value) {
+ case undefined:
+ return undefined
+ case 'danger-full-access':
+ return { type: 'dangerFullAccess' }
+ case 'read-only':
+ return { type: 'readOnly' }
+ case 'workspace-write':
+ return { type: 'workspaceWrite' }
+ default:
+ throw new Error(`Freshcodex does not support sandbox "${String(value)}".`)
+ }
+}
+
+function toCodexUserInput(text: string, images: FreshAgentInputImage[] | undefined): CodexTurnStartParams['input'] {
+ const input: CodexTurnStartParams['input'] = [{
+ type: 'text',
+ text,
+ text_elements: [],
+ }]
+ for (const image of images ?? []) {
+ if (image.kind === 'url') {
+ input.push({ type: 'image', url: image.url })
+ } else if (image.kind === 'local') {
+ input.push({ type: 'localImage', path: image.path })
+ } else {
+ input.push({ type: 'image', url: `data:${image.mediaType};base64,${image.data}` })
+ }
+ }
+ return input
+}
+
+function normalizeCodexThreadStatus(status: unknown): string {
+ if (!status || typeof status !== 'object') return 'idle'
+ const type = (status as { type?: unknown }).type
+ if (type === 'active') return 'running'
+ if (type === 'notLoaded') return 'starting'
+ if (type === 'systemError') return 'exited'
+ if (type === 'idle') return 'idle'
+ return 'idle'
+}
+
+function makeCodexStatusEvent(sessionId: string, status: unknown, revision?: number) {
+ return {
+ type: 'sdk.session.snapshot',
+ sessionId,
+ latestTurnId: null,
+ status: normalizeCodexThreadStatus(status),
+ timelineSessionId: sessionId,
+ revision,
+ }
+}
+
+export function createCodexFreshAgentAdapter(deps: {
+ runtime: CodexRuntimePort
+}): FreshAgentRuntimeAdapter {
+ const activeTurnByThread = new Map()
+ const settingsByThread = new Map()
+
+ return {
+ runtimeProvider: 'codex',
+
+ async create(input: FreshAgentCreateRequest) {
+ toCodexReasoningEffort(input.effort)
+ const started = await deps.runtime.startThread({
+ cwd: input.cwd,
+ model: input.model,
+ sandbox: input.sandbox,
+ approvalPolicy: toCodexApprovalPolicy(input.permissionMode),
+ excludeTurns: true,
+ })
+ settingsByThread.set(started.threadId, input)
+ return { sessionId: started.threadId, sessionRef: { provider: 'codex', sessionId: started.threadId } }
+ },
+
+ async resume(input: FreshAgentCreateRequest) {
+ if (!input.resumeSessionId) {
+ throw new Error('Codex rich resume requires resumeSessionId')
+ }
+ toCodexReasoningEffort(input.effort)
+ const resumed = await deps.runtime.resumeThread({
+ threadId: input.resumeSessionId,
+ cwd: input.cwd,
+ model: input.model,
+ sandbox: input.sandbox,
+ approvalPolicy: toCodexApprovalPolicy(input.permissionMode),
+ })
+ settingsByThread.set(resumed.threadId, input)
+ return { sessionId: resumed.threadId, sessionRef: { provider: 'codex', sessionId: resumed.threadId } }
+ },
+
+ subscribe(sessionId, listener) {
+ if (!deps.runtime.onThreadLifecycle) {
+ throw new Error('Codex app-server runtime does not support thread lifecycle subscriptions.')
+ }
+ return deps.runtime.onThreadLifecycle((event) => {
+ if (event.kind === 'thread_started') {
+ if (event.thread.id !== sessionId) return
+ listener(makeCodexStatusEvent(sessionId, event.thread.status, event.thread.updatedAt))
+ return
+ }
+ if (event.kind === 'thread_closed') {
+ if (event.threadId !== sessionId) return
+ activeTurnByThread.delete(sessionId)
+ listener({
+ type: 'sdk.status',
+ sessionId,
+ status: 'exited',
+ })
+ return
+ }
+ if (event.threadId !== sessionId) return
+ const status = normalizeCodexThreadStatus(event.status)
+ if (status !== 'running' && status !== 'starting') {
+ activeTurnByThread.delete(sessionId)
+ }
+ listener(makeCodexStatusEvent(sessionId, event.status))
+ })
+ },
+
+ async send(sessionId, input) {
+ if (!deps.runtime.startTurn) {
+ throw new Error('Codex app-server runtime does not support turn/start.')
+ }
+ const settings = {
+ ...settingsByThread.get(sessionId),
+ ...input.settings,
+ }
+ const turn = await deps.runtime.startTurn({
+ threadId: sessionId,
+ input: toCodexUserInput(input.text, input.images),
+ cwd: settings.cwd,
+ approvalPolicy: toCodexApprovalPolicy(settings.permissionMode),
+ sandboxPolicy: toCodexSandboxPolicy(settings.sandbox),
+ model: settings.model,
+ effort: toCodexReasoningEffort(settings.effort),
+ })
+ activeTurnByThread.set(sessionId, turn.turnId)
+ },
+
+ async interrupt(sessionId) {
+ if (!deps.runtime.interruptTurn) {
+ throw new Error('Codex app-server runtime does not support turn/interrupt.')
+ }
+ const turnId = activeTurnByThread.get(sessionId)
+ if (!turnId) {
+ throw new Error(`No active Codex turn is tracked for ${sessionId}.`)
+ }
+ await deps.runtime.interruptTurn({ threadId: sessionId, turnId })
+ activeTurnByThread.delete(sessionId)
+ },
+
+ async fork(sessionId, input) {
+ if (!deps.runtime.forkThread) {
+ throw new Error('Codex app-server runtime does not support thread/fork.')
+ }
+ const settings = settingsByThread.get(sessionId)
+ return await deps.runtime.forkThread({
+ threadId: sessionId,
+ cwd: typeof input?.cwd === 'string' ? input.cwd : settings?.cwd,
+ model: typeof input?.model === 'string' ? input.model : settings?.model,
+ sandbox: typeof input?.sandbox === 'string' ? input.sandbox as FreshAgentCreateRequest['sandbox'] : settings?.sandbox,
+ approvalPolicy: toCodexApprovalPolicy(
+ typeof input?.permissionMode === 'string' ? input.permissionMode : settings?.permissionMode,
+ ),
+ excludeTurns: true,
+ })
+ },
+
+ async getSnapshot(thread, revision) {
+ const rawSnapshot = await deps.runtime.readThread({ threadId: thread.threadId, includeTurns: true })
+ const rawThreadTurns: unknown[] = Array.isArray(rawSnapshot.thread?.turns)
+ ? rawSnapshot.thread.turns
+ : []
+ const rawTurns = rawThreadTurns
+ .filter((turn): turn is Record => !!turn && typeof turn === 'object' && !Array.isArray(turn))
+ .map((turn, index) => normalizeCodexTurn(turn, index))
+ return normalizeCodexThreadSnapshot({
+ threadId: thread.threadId,
+ revision: Number(rawSnapshot.thread?.updatedAt ?? revision ?? 0),
+ status: normalizeCodexThreadStatus(rawSnapshot.thread?.status),
+ transcript: {
+ turns: rawTurns,
+ },
+ rawSnapshot,
+ })
+ },
+
+ async getTurnPage(thread, query) {
+ const rawPage = await deps.runtime.listThreadTurns({
+ threadId: thread.threadId,
+ cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
+ limit: typeof query.limit === 'number' ? query.limit : undefined,
+ })
+ return normalizeCodexTurnPage({
+ threadId: thread.threadId,
+ revision: Number(rawPage.revision ?? query.revision ?? 0),
+ rawPage,
+ })
+ },
+
+ async getTurnBody(thread, revision) {
+ const rawTurn = await deps.runtime.readThreadTurn({
+ threadId: thread.threadId,
+ turnId: thread.turnId,
+ revision,
+ })
+ return normalizeCodexTurnBody({
+ threadId: thread.threadId,
+ revision,
+ rawTurn,
+ })
+ },
+ }
+}
diff --git a/server/fresh-agent/adapters/codex/normalize.ts b/server/fresh-agent/adapters/codex/normalize.ts
new file mode 100644
index 000000000..d9a5496d8
--- /dev/null
+++ b/server/fresh-agent/adapters/codex/normalize.ts
@@ -0,0 +1,251 @@
+import {
+ FreshAgentSnapshotSchema,
+ FreshAgentTurnBodySchema,
+ FreshAgentTurnPageSchema,
+ type FreshAgentTranscriptItem,
+ type FreshAgentTurn,
+} from '../../../../shared/fresh-agent-contract.js'
+
+type CodexRawSnapshot = {
+ thread?: {
+ preview?: string
+ turns?: unknown[]
+ }
+ summary?: string
+ tokenUsage?: {
+ inputTokens: number
+ outputTokens: number
+ cachedTokens?: number
+ totalTokens: number
+ contextTokens?: number
+ compactPercent?: number
+ }
+ worktrees?: Array<{ id: string; path: string; branch?: string }>
+ diffs?: Array<{ id: string; path: string; title?: string }>
+ childThreads?: Array<{ id: string; threadId: string; origin: string; title?: string }>
+ extension?: { codex?: Record }
+}
+
+function normalizeCodexItem(turnId: string, item: Record, index: number): FreshAgentTranscriptItem[] {
+ const id = typeof item.id === 'string' && item.id.length > 0 ? item.id : `${turnId}:item:${index}`
+ switch (item.type) {
+ case 'userMessage': {
+ const content = Array.isArray(item.content) ? item.content : []
+ if (content.length === 0) {
+ return [{ id, kind: 'text', text: '' }]
+ }
+ return content.map((part, partIndex) => {
+ const typedPart = part && typeof part === 'object' ? part as Record : {}
+ if (typedPart.type === 'text') {
+ return {
+ id: `${id}:part:${partIndex}`,
+ kind: 'text' as const,
+ text: typeof typedPart.text === 'string' ? typedPart.text : '',
+ }
+ }
+ return {
+ id: `${id}:part:${partIndex}`,
+ kind: 'text' as const,
+ text: `[${String(typedPart.type ?? 'input')}]`,
+ }
+ })
+ }
+ case 'agentMessage':
+ return [{ id, kind: 'text', text: typeof item.text === 'string' ? item.text : '' }]
+ case 'plan':
+ return [{ id, kind: 'text', text: typeof item.text === 'string' ? item.text : '' }]
+ case 'reasoning': {
+ const summary = Array.isArray(item.summary) ? item.summary.filter((value): value is string => typeof value === 'string') : []
+ const content = Array.isArray(item.content) ? item.content.filter((value): value is string => typeof value === 'string') : []
+ return [{
+ id,
+ kind: 'reasoning',
+ summary,
+ content,
+ text: summary.join('\n') || content.join('\n'),
+ }]
+ }
+ case 'commandExecution':
+ return [{
+ id,
+ kind: 'command',
+ command: typeof item.command === 'string' ? item.command : '',
+ cwd: typeof item.cwd === 'string' ? item.cwd : undefined,
+ status: item.status === 'inProgress' ? 'running' : item.status === 'declined' ? 'declined' : item.status === 'failed' ? 'failed' : 'completed',
+ output: typeof item.aggregatedOutput === 'string' ? item.aggregatedOutput : null,
+ exitCode: typeof item.exitCode === 'number' ? item.exitCode : null,
+ extensions: { codex: item },
+ }]
+ case 'fileChange':
+ return [{
+ id,
+ kind: 'file_change',
+ status: item.status === 'inProgress' ? 'running' : item.status === 'declined' ? 'declined' : item.status === 'failed' ? 'failed' : 'completed',
+ changes: Array.isArray(item.changes)
+ ? item.changes.filter((change): change is Record => !!change && typeof change === 'object' && !Array.isArray(change))
+ : [],
+ extensions: { codex: item },
+ }]
+ case 'mcpToolCall':
+ return [{
+ id,
+ kind: 'mcp_tool',
+ server: typeof item.server === 'string' ? item.server : '',
+ tool: typeof item.tool === 'string' ? item.tool : '',
+ status: item.status === 'inProgress' ? 'running' : item.status === 'failed' ? 'failed' : 'completed',
+ arguments: item.arguments ?? null,
+ result: item.result,
+ error: item.error,
+ }]
+ case 'dynamicToolCall':
+ return [{
+ id,
+ kind: 'dynamic_tool',
+ namespace: typeof item.namespace === 'string' ? item.namespace : null,
+ tool: typeof item.tool === 'string' ? item.tool : '',
+ status: item.status === 'inProgress' ? 'running' : item.status === 'failed' ? 'failed' : 'completed',
+ arguments: item.arguments ?? null,
+ contentItems: Array.isArray(item.contentItems) ? item.contentItems : null,
+ success: typeof item.success === 'boolean' ? item.success : null,
+ }]
+ case 'collabAgentToolCall':
+ return [{
+ id,
+ kind: 'collab_agent',
+ tool: String(item.tool ?? ''),
+ status: item.status === 'inProgress' ? 'running' : item.status === 'failed' ? 'failed' : 'completed',
+ senderThreadId: String(item.senderThreadId ?? ''),
+ receiverThreadIds: Array.isArray(item.receiverThreadIds)
+ ? item.receiverThreadIds.filter((value): value is string => typeof value === 'string')
+ : [],
+ prompt: typeof item.prompt === 'string' ? item.prompt : null,
+ model: typeof item.model === 'string' ? item.model : null,
+ reasoningEffort: typeof item.reasoningEffort === 'string' ? item.reasoningEffort : null,
+ agentsStates: item.agentsStates && typeof item.agentsStates === 'object' && !Array.isArray(item.agentsStates)
+ ? item.agentsStates as Record
+ : {},
+ }]
+ case 'webSearch':
+ return [{
+ id,
+ kind: 'web_search',
+ query: typeof item.query === 'string' ? item.query : '',
+ action: item.action ?? null,
+ }]
+ case 'imageView':
+ return [{ id, kind: 'image_view', path: typeof item.path === 'string' ? item.path : '' }]
+ case 'imageGeneration':
+ return [{
+ id,
+ kind: 'image_generation',
+ status: String(item.status ?? ''),
+ revisedPrompt: typeof item.revisedPrompt === 'string' ? item.revisedPrompt : null,
+ result: String(item.result ?? ''),
+ savedPath: typeof item.savedPath === 'string' ? item.savedPath : undefined,
+ }]
+ case 'enteredReviewMode':
+ return [{ id, kind: 'review_mode', event: 'entered', review: String(item.review ?? '') }]
+ case 'exitedReviewMode':
+ return [{ id, kind: 'review_mode', event: 'exited', review: String(item.review ?? '') }]
+ case 'contextCompaction':
+ return [{ id, kind: 'context_compaction' }]
+ case 'hookPrompt':
+ return [{ id, kind: 'text', text: 'Hook prompt' }]
+ default:
+ throw new Error(`Unsupported Codex thread item type: ${String(item.type)}`)
+ }
+}
+
+export function normalizeCodexTurn(rawTurn: Record, ordinal = 0): FreshAgentTurn {
+ const turnId = String(rawTurn.id ?? `turn:${ordinal}`)
+ const rawItems = Array.isArray(rawTurn.items)
+ ? rawTurn.items.filter((item): item is Record => !!item && typeof item === 'object' && !Array.isArray(item))
+ : []
+ const items = rawItems.flatMap((item, index) => normalizeCodexItem(turnId, item, index))
+ const firstText = items.find((item): item is Extract => item.kind === 'text')
+ return {
+ id: turnId,
+ turnId,
+ ordinal,
+ source: 'durable',
+ summary: firstText?.text.slice(0, 140) ?? '',
+ items,
+ }
+}
+
+export function normalizeCodexTurnPage(input: {
+ threadId: string
+ revision: number
+ rawPage: { turns?: unknown[]; nextCursor?: string | null; backwardsCursor?: string | null }
+}) {
+ const turns = (Array.isArray(input.rawPage.turns) ? input.rawPage.turns : [])
+ .filter((turn): turn is Record => !!turn && typeof turn === 'object' && !Array.isArray(turn))
+ .map((turn, index) => normalizeCodexTurn(turn, index))
+
+ return FreshAgentTurnPageSchema.parse({
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ threadId: input.threadId,
+ revision: input.revision,
+ nextCursor: input.rawPage.nextCursor ?? null,
+ backwardsCursor: input.rawPage.backwardsCursor ?? null,
+ turns,
+ bodies: Object.fromEntries(turns.map((turn) => [turn.turnId, turn])),
+ })
+}
+
+export function normalizeCodexTurnBody(input: {
+ threadId: string
+ revision: number
+ rawTurn: Record
+}) {
+ return FreshAgentTurnBodySchema.parse({
+ ...normalizeCodexTurn(input.rawTurn),
+ sessionType: 'freshcodex',
+ provider: 'codex',
+ threadId: input.threadId,
+ revision: input.revision,
+ })
+}
+
+export function normalizeCodexThreadSnapshot(input: {
+ threadId: string
+ revision: number
+ status: string
+ transcript: { turns: FreshAgentTurn[] }
+ rawSnapshot: CodexRawSnapshot
+}) {
+ const extensions = input.rawSnapshot.extension?.codex ?? {}
+ const isRunning = input.status === 'running' || input.status === 'compacting'
+ return FreshAgentSnapshotSchema.parse({
+ sessionType: 'freshcodex',
+ provider: 'codex' as const,
+ threadId: input.threadId,
+ revision: input.revision,
+ status: input.status,
+ summary: input.rawSnapshot.summary ?? input.rawSnapshot.thread?.preview ?? input.transcript.turns[0]?.summary ?? '',
+ capabilities: {
+ send: !isRunning,
+ interrupt: isRunning,
+ approvals: false,
+ questions: false,
+ fork: !isRunning,
+ worktrees: (input.rawSnapshot.worktrees?.length ?? 0) > 0,
+ diffs: (input.rawSnapshot.diffs?.length ?? 0) > 0,
+ childThreads: (input.rawSnapshot.childThreads?.length ?? 0) > 0,
+ },
+ tokenUsage: input.rawSnapshot.tokenUsage ?? {
+ inputTokens: 0,
+ outputTokens: 0,
+ cachedTokens: 0,
+ totalTokens: 0,
+ },
+ worktrees: input.rawSnapshot.worktrees ?? [],
+ diffs: input.rawSnapshot.diffs ?? [],
+ childThreads: input.rawSnapshot.childThreads ?? [],
+ turns: input.transcript.turns,
+ extensions: {
+ codex: extensions,
+ },
+ })
+}
diff --git a/server/fresh-agent/provider-registry.ts b/server/fresh-agent/provider-registry.ts
new file mode 100644
index 000000000..0759067ba
--- /dev/null
+++ b/server/fresh-agent/provider-registry.ts
@@ -0,0 +1,39 @@
+import type { FreshAgentSessionType, FreshAgentRuntimeProvider } from '../../shared/fresh-agent.js'
+import type { FreshAgentRuntimeAdapter } from './runtime-adapter.js'
+
+export type FreshAgentProviderRegistration = {
+ sessionType: FreshAgentSessionType
+ runtimeProvider: FreshAgentRuntimeProvider
+ adapter: FreshAgentRuntimeAdapter
+}
+
+export class FreshAgentProviderRegistry {
+ private readonly registrationsBySessionType = new Map()
+ private readonly registrationsByRuntimeProvider = new Map()
+
+ constructor(registrations: FreshAgentProviderRegistration[]) {
+ for (const registration of registrations) {
+ this.registrationsBySessionType.set(registration.sessionType, registration)
+ const runtimeRegistration = this.registrationsByRuntimeProvider.get(registration.runtimeProvider)
+ if (!runtimeRegistration) {
+ this.registrationsByRuntimeProvider.set(registration.runtimeProvider, registration)
+ } else if (runtimeRegistration.adapter !== registration.adapter) {
+ throw new Error(
+ `Fresh-agent runtime provider ${registration.runtimeProvider} has multiple adapters; register shared session types with the same adapter instance.`,
+ )
+ }
+ }
+ }
+
+ resolveBySessionType(sessionType: FreshAgentSessionType): FreshAgentProviderRegistration | undefined {
+ return this.registrationsBySessionType.get(sessionType)
+ }
+
+ resolveByRuntimeProvider(runtimeProvider: FreshAgentRuntimeProvider): FreshAgentProviderRegistration | undefined {
+ return this.registrationsByRuntimeProvider.get(runtimeProvider)
+ }
+}
+
+export function createFreshAgentProviderRegistry(registrations: FreshAgentProviderRegistration[]) {
+ return new FreshAgentProviderRegistry(registrations)
+}
diff --git a/server/fresh-agent/router.ts b/server/fresh-agent/router.ts
new file mode 100644
index 000000000..b7ae2fee4
--- /dev/null
+++ b/server/fresh-agent/router.ts
@@ -0,0 +1,189 @@
+import { Router } from 'express'
+import { z } from 'zod'
+
+import {
+ AgentTimelinePageQuerySchema,
+ AgentTimelineTurnBodyQuerySchema,
+ ReadModelPrioritySchema,
+} from '../../shared/read-models.js'
+import {
+ FreshAgentRuntimeManager,
+ FreshAgentRuntimeUnavailableError,
+ FreshAgentStaleThreadRevisionError,
+ FreshAgentUnsupportedCapabilityError,
+ FreshAgentLostSessionError,
+ FreshAgentSessionLocatorMismatchError,
+ FreshAgentContractValidationError,
+} from './runtime-manager.js'
+import { createRequestAbortSignal } from '../read-models/request-abort.js'
+import { setResponsePerfContext } from '../request-logger.js'
+import {
+ defaultReadModelScheduler,
+ isReadModelAbortError,
+ type ReadModelWorkScheduler,
+} from '../read-models/work-scheduler.js'
+
+const ThreadParamsSchema = z.object({
+ sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']),
+ provider: z.enum(['claude', 'codex', 'opencode']),
+ threadId: z.string().min(1),
+})
+
+const TurnParamsSchema = ThreadParamsSchema.extend({
+ turnId: z.string().min(1),
+})
+
+export function createFreshAgentRouter(deps: {
+ runtimeManager: FreshAgentRuntimeManager
+ readModelScheduler?: ReadModelWorkScheduler
+}) {
+ const router = Router()
+ const readModelScheduler = deps.readModelScheduler ?? defaultReadModelScheduler
+
+ function sendFreshAgentError(res: any, error: unknown) {
+ if (error instanceof FreshAgentStaleThreadRevisionError) {
+ return res.status(409).json({
+ error: 'Stale thread revision',
+ code: error.code,
+ currentRevision: error.currentRevision,
+ })
+ }
+ if (error instanceof FreshAgentRuntimeUnavailableError) {
+ return res.status(503).json({ error: error.message, code: error.code })
+ }
+ if (error instanceof FreshAgentUnsupportedCapabilityError) {
+ return res.status(409).json({ error: error.message, code: error.code })
+ }
+ if (error instanceof FreshAgentLostSessionError) {
+ return res.status(404).json({ error: error.message, code: error.code })
+ }
+ if (error instanceof FreshAgentSessionLocatorMismatchError) {
+ return res.status(409).json({ error: error.message, code: error.code })
+ }
+ if (error instanceof FreshAgentContractValidationError) {
+ return res.status(502).json({
+ error: error.message,
+ code: error.code,
+ surface: error.surface,
+ details: error.details,
+ })
+ }
+ const message = error instanceof Error ? error.message : 'Fresh-agent request failed'
+ return res.status(500).json({ error: message })
+ }
+
+ router.get('/fresh-agent/threads/:sessionType/:provider/:threadId', async (req, res) => {
+ const params = ThreadParamsSchema.safeParse(req.params)
+ const query = z.object({
+ priority: ReadModelPrioritySchema.optional(),
+ revision: z.coerce.number().int().nonnegative().optional(),
+ }).safeParse({
+ priority: typeof req.query.priority === 'string' ? req.query.priority : undefined,
+ revision: typeof req.query.revision === 'string' ? req.query.revision : undefined,
+ })
+
+ if (!params.success || !query.success) {
+ return res.status(400).json({
+ error: 'Invalid request',
+ details: [...(!params.success ? params.error.issues : []), ...(!query.success ? query.error.issues : [])],
+ })
+ }
+
+ const signal = createRequestAbortSignal(req, res)
+ try {
+ const snapshot = await readModelScheduler.schedule({
+ lane: query.data.priority ?? 'visible',
+ signal,
+ run: async () => deps.runtimeManager.getSnapshot({
+ sessionType: params.data.sessionType,
+ provider: params.data.provider,
+ threadId: params.data.threadId,
+ revision: query.data.revision,
+ }),
+ })
+ setResponsePerfContext(res, {
+ readModelLane: query.data.priority ?? 'visible',
+ responsePayloadBytes: Buffer.byteLength(JSON.stringify(snapshot), 'utf8'),
+ })
+ res.json(snapshot)
+ } catch (error) {
+ if (signal.aborted || isReadModelAbortError(error)) return
+ return sendFreshAgentError(res, error)
+ }
+ })
+
+ router.get('/fresh-agent/threads/:sessionType/:provider/:threadId/turns', async (req, res) => {
+ const params = ThreadParamsSchema.safeParse(req.params)
+ const query = AgentTimelinePageQuerySchema.safeParse({
+ cursor: typeof req.query.cursor === 'string' ? req.query.cursor : undefined,
+ priority: typeof req.query.priority === 'string' ? req.query.priority : undefined,
+ revision: typeof req.query.revision === 'string' ? req.query.revision : undefined,
+ limit: typeof req.query.limit === 'string' ? Number(req.query.limit) : undefined,
+ includeBodies: typeof req.query.includeBodies === 'string' ? req.query.includeBodies : undefined,
+ })
+
+ if (!params.success || !query.success) {
+ return res.status(400).json({
+ error: 'Invalid request',
+ details: [...(!params.success ? params.error.issues : []), ...(!query.success ? query.error.issues : [])],
+ })
+ }
+
+ const signal = createRequestAbortSignal(req, res)
+ try {
+ const page = await readModelScheduler.schedule({
+ lane: query.data.priority ?? 'visible',
+ signal,
+ run: async () => deps.runtimeManager.getTurnPage({
+ sessionType: params.data.sessionType,
+ provider: params.data.provider,
+ threadId: params.data.threadId,
+ cursor: query.data.cursor,
+ priority: query.data.priority,
+ revision: query.data.revision,
+ limit: query.data.limit,
+ includeBodies: query.data.includeBodies,
+ }),
+ })
+ setResponsePerfContext(res, {
+ readModelLane: query.data.priority ?? 'visible',
+ responsePayloadBytes: Buffer.byteLength(JSON.stringify(page), 'utf8'),
+ })
+ res.json(page)
+ } catch (error) {
+ if (signal.aborted || isReadModelAbortError(error)) return
+ return sendFreshAgentError(res, error)
+ }
+ })
+
+ router.get('/fresh-agent/threads/:sessionType/:provider/:threadId/turns/:turnId', async (req, res) => {
+ const params = TurnParamsSchema.safeParse(req.params)
+ const query = AgentTimelineTurnBodyQuerySchema.safeParse({
+ revision: typeof req.query.revision === 'string' ? req.query.revision : undefined,
+ })
+ if (!params.success || !query.success) {
+ return res.status(400).json({
+ error: 'Invalid request',
+ details: [...(!params.success ? params.error.issues : []), ...(!query.success ? query.error.issues : [])],
+ })
+ }
+
+ try {
+ const turn = await deps.runtimeManager.getTurnBody({
+ sessionType: params.data.sessionType,
+ provider: params.data.provider,
+ threadId: params.data.threadId,
+ turnId: params.data.turnId,
+ revision: query.data.revision,
+ })
+ if (!turn) {
+ return res.status(404).json({ error: 'Turn not found' })
+ }
+ res.json(turn)
+ } catch (error) {
+ return sendFreshAgentError(res, error)
+ }
+ })
+
+ return router
+}
diff --git a/server/fresh-agent/runtime-adapter.ts b/server/fresh-agent/runtime-adapter.ts
new file mode 100644
index 000000000..ad81a4675
--- /dev/null
+++ b/server/fresh-agent/runtime-adapter.ts
@@ -0,0 +1,57 @@
+import type { FreshAgentRuntimeProvider, FreshAgentSessionType } from '../../shared/fresh-agent.js'
+import type { FreshAgentRequestId } from '../../shared/fresh-agent-contract.js'
+
+export type FreshAgentCreateRequest = {
+ requestId: string
+ sessionType: FreshAgentSessionType
+ provider?: FreshAgentRuntimeProvider
+ cwd?: string
+ resumeSessionId?: string
+ sessionRef?: { provider: string; sessionId: string }
+ model?: string
+ modelSelection?: { kind: string; modelId: string }
+ permissionMode?: string
+ sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access'
+ effort?: string
+ plugins?: string[]
+}
+
+export type FreshAgentCreateResult = {
+ sessionId: string
+ sessionType: FreshAgentSessionType
+ runtimeProvider: FreshAgentRuntimeProvider
+ sessionRef?: { provider: string; sessionId: string }
+}
+
+export type FreshAgentThreadLocator = {
+ sessionType: FreshAgentSessionType
+ provider: FreshAgentRuntimeProvider
+ threadId: string
+}
+
+export type FreshAgentSessionLocator = {
+ sessionType: FreshAgentSessionType
+ provider: FreshAgentRuntimeProvider
+ sessionId: string
+}
+
+export type FreshAgentInputImage =
+ | { kind: 'url'; url: string; mediaType?: string }
+ | { kind: 'local'; path: string; mediaType?: string }
+ | { kind: 'data'; mediaType: string; data: string }
+
+export interface FreshAgentRuntimeAdapter {
+ readonly runtimeProvider: FreshAgentRuntimeProvider
+ create(input: FreshAgentCreateRequest): Promise<{ sessionId: string; sessionRef?: { provider: string; sessionId: string } }>
+ resume?(input: FreshAgentCreateRequest): Promise<{ sessionId: string; sessionRef?: { provider: string; sessionId: string } }>
+ subscribe?(sessionId: string, listener: (message: unknown) => void): Promise<() => void> | (() => void)
+ send?(sessionId: string, input: { text: string; images?: FreshAgentInputImage[]; settings?: FreshAgentCreateRequest }): Promise | void
+ interrupt?(sessionId: string): Promise | void
+ kill?(sessionId: string): Promise | boolean
+ fork?(sessionId: string, input?: Record): Promise | unknown
+ answerQuestion?(sessionId: string, requestId: FreshAgentRequestId, answers: Record): Promise | void
+ resolveApproval?(sessionId: string, requestId: FreshAgentRequestId, decision: Record): Promise | void
+ getSnapshot?(thread: FreshAgentThreadLocator, revision?: number): Promise
+ getTurnPage?(thread: FreshAgentThreadLocator, query: Record): Promise
+ getTurnBody?(thread: FreshAgentThreadLocator & { turnId: string }, revision: number): Promise
+}
diff --git a/server/fresh-agent/runtime-manager.ts b/server/fresh-agent/runtime-manager.ts
new file mode 100644
index 000000000..3036dd838
--- /dev/null
+++ b/server/fresh-agent/runtime-manager.ts
@@ -0,0 +1,295 @@
+import {
+ makeFreshAgentSessionKey,
+ type FreshAgentRuntimeProvider,
+ type FreshAgentSessionType,
+} from '../../shared/fresh-agent.js'
+import {
+ FreshAgentSnapshotSchema,
+ FreshAgentTurnBodySchema,
+ FreshAgentTurnPageSchema,
+ type FreshAgentRequestId,
+} from '../../shared/fresh-agent-contract.js'
+import type { FreshAgentProviderRegistry } from './provider-registry.js'
+import type {
+ FreshAgentCreateRequest,
+ FreshAgentCreateResult,
+ FreshAgentInputImage,
+ FreshAgentRuntimeAdapter,
+ FreshAgentSessionLocator,
+} from './runtime-adapter.js'
+
+export class FreshAgentRuntimeUnavailableError extends Error {
+ readonly code = 'FRESH_AGENT_RUNTIME_UNAVAILABLE' as const
+}
+
+export class FreshAgentStaleThreadRevisionError extends Error {
+ readonly code = 'STALE_THREAD_REVISION' as const
+
+ constructor(readonly currentRevision: number) {
+ super('Fresh-agent thread revision is stale')
+ }
+}
+
+export class FreshAgentUnsupportedCapabilityError extends Error {
+ readonly code = 'FRESH_AGENT_UNSUPPORTED_CAPABILITY' as const
+}
+
+export class FreshAgentLostSessionError extends Error {
+ readonly code = 'FRESH_AGENT_LOST_SESSION' as const
+}
+
+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: 'snapshot' | 'turn-page' | 'turn-body', readonly details: unknown) {
+ super(`Fresh-agent ${surface} did not match the shared contract`)
+ }
+}
+
+type FreshAgentRuntimeManagerOptions = {
+ registry: FreshAgentProviderRegistry
+}
+
+type SessionRecord = {
+ sessionType: FreshAgentSessionType
+ runtimeProvider: FreshAgentRuntimeProvider
+ adapter: FreshAgentRuntimeAdapter
+}
+
+export class FreshAgentRuntimeManager {
+ private readonly sessions = new Map()
+
+ constructor(private readonly options: FreshAgentRuntimeManagerOptions) {}
+
+ async create(input: FreshAgentCreateRequest): Promise {
+ const registration = this.requireRegistration(input.sessionType, input.provider)
+
+ const created = input.resumeSessionId && registration.adapter.resume
+ ? await registration.adapter.resume(input)
+ : await registration.adapter.create(input)
+ this.sessions.set(this.key({
+ sessionType: input.sessionType,
+ provider: registration.runtimeProvider,
+ sessionId: created.sessionId,
+ }), {
+ sessionType: input.sessionType,
+ runtimeProvider: registration.runtimeProvider,
+ adapter: registration.adapter,
+ })
+ return {
+ sessionId: created.sessionId,
+ sessionType: input.sessionType,
+ runtimeProvider: registration.runtimeProvider,
+ sessionRef: created.sessionRef,
+ }
+ }
+
+ attach(input: FreshAgentSessionLocator): FreshAgentCreateResult {
+ const registration = this.requireRegistration(input.sessionType, input.provider)
+
+ this.sessions.set(this.key(input), {
+ sessionType: input.sessionType,
+ runtimeProvider: registration.runtimeProvider,
+ adapter: registration.adapter,
+ })
+
+ return {
+ sessionId: input.sessionId,
+ sessionType: input.sessionType,
+ runtimeProvider: registration.runtimeProvider,
+ }
+ }
+
+ async resume(input: FreshAgentCreateRequest): Promise {
+ const registration = this.requireRegistration(input.sessionType, input.provider)
+ if (!registration.adapter.resume) {
+ throw new FreshAgentUnsupportedCapabilityError(`Resume is not supported for ${input.sessionType}`)
+ }
+ const resumed = await registration.adapter.resume(input)
+ this.sessions.set(this.key({
+ sessionType: input.sessionType,
+ provider: registration.runtimeProvider,
+ sessionId: resumed.sessionId,
+ }), {
+ sessionType: input.sessionType,
+ runtimeProvider: registration.runtimeProvider,
+ adapter: registration.adapter,
+ })
+ return {
+ sessionId: resumed.sessionId,
+ sessionType: input.sessionType,
+ runtimeProvider: registration.runtimeProvider,
+ sessionRef: resumed.sessionRef,
+ }
+ }
+
+ async subscribe(locator: FreshAgentSessionLocator, listener: (message: unknown) => void) {
+ const record = this.requireSession(locator)
+ if (!record.adapter.subscribe) {
+ throw new FreshAgentUnsupportedCapabilityError(`Subscribe is not supported for ${record.sessionType}`)
+ }
+ return await record.adapter.subscribe(locator.sessionId, listener)
+ }
+
+ async send(locator: FreshAgentSessionLocator, input: { text: string; images?: FreshAgentInputImage[]; settings?: FreshAgentCreateRequest }) {
+ const record = this.requireSession(locator)
+ if (!record.adapter.send) {
+ throw new FreshAgentUnsupportedCapabilityError(`Send is not supported for ${record.sessionType}`)
+ }
+ await record.adapter.send(locator.sessionId, input)
+ }
+
+ async interrupt(locator: FreshAgentSessionLocator) {
+ const record = this.requireSession(locator)
+ if (!record.adapter.interrupt) {
+ throw new FreshAgentUnsupportedCapabilityError(`Interrupt is not supported for ${record.sessionType}`)
+ }
+ await record.adapter.interrupt(locator.sessionId)
+ }
+
+ async kill(locator: FreshAgentSessionLocator): Promise {
+ const record = this.requireSession(locator)
+ try {
+ if (record.adapter.kill) {
+ return await record.adapter.kill(locator.sessionId)
+ }
+ return true
+ } finally {
+ this.sessions.delete(this.key(locator))
+ }
+ }
+
+ async fork(locator: FreshAgentSessionLocator, input?: Record) {
+ const record = this.requireSession(locator)
+ if (!record.adapter.fork) {
+ throw new FreshAgentUnsupportedCapabilityError(`Fork is not supported for ${record.sessionType}`)
+ }
+ return await record.adapter.fork(locator.sessionId, input)
+ }
+
+ async answerQuestion(locator: FreshAgentSessionLocator, requestId: FreshAgentRequestId, answers: Record) {
+ const record = this.requireSession(locator)
+ if (!record.adapter.answerQuestion) {
+ throw new FreshAgentUnsupportedCapabilityError(`Questions are not supported for ${record.sessionType}`)
+ }
+ await record.adapter.answerQuestion(locator.sessionId, requestId, answers)
+ }
+
+ async resolveApproval(locator: FreshAgentSessionLocator, requestId: FreshAgentRequestId, decision: Record) {
+ const record = this.requireSession(locator)
+ if (!record.adapter.resolveApproval) {
+ throw new FreshAgentUnsupportedCapabilityError(`Approvals are not supported for ${record.sessionType}`)
+ }
+ await record.adapter.resolveApproval(locator.sessionId, requestId, decision)
+ }
+
+ async getSnapshot(input: {
+ sessionType: FreshAgentSessionType
+ provider: FreshAgentRuntimeProvider
+ threadId: string
+ revision?: number
+ }) {
+ const registration = this.requireRegistration(input.sessionType, input.provider)
+ if (!registration?.adapter.getSnapshot) {
+ throw new FreshAgentRuntimeUnavailableError(`No fresh-agent snapshot adapter registered for ${input.sessionType}`)
+ }
+ const snapshot = await registration.adapter.getSnapshot({
+ sessionType: input.sessionType,
+ provider: input.provider,
+ threadId: input.threadId,
+ }, input.revision)
+ const parsed = FreshAgentSnapshotSchema.safeParse(snapshot)
+ if (!parsed.success) {
+ throw new FreshAgentContractValidationError('snapshot', parsed.error.issues)
+ }
+ return parsed.data
+ }
+
+ async getTurnPage(input: {
+ sessionType: FreshAgentSessionType
+ provider: FreshAgentRuntimeProvider
+ threadId: string
+ cursor?: string
+ priority?: string
+ revision: number
+ limit?: number
+ includeBodies?: boolean
+ }) {
+ const registration = this.requireRegistration(input.sessionType, input.provider)
+ if (!registration?.adapter.getTurnPage) {
+ throw new FreshAgentRuntimeUnavailableError(`No fresh-agent turn-page adapter registered for ${input.sessionType}`)
+ }
+ const page = await registration.adapter.getTurnPage(
+ { sessionType: input.sessionType, provider: input.provider, threadId: input.threadId },
+ input,
+ )
+ const parsed = FreshAgentTurnPageSchema.safeParse(page)
+ if (!parsed.success) {
+ throw new FreshAgentContractValidationError('turn-page', parsed.error.issues)
+ }
+ return parsed.data
+ }
+
+ async getTurnBody(input: {
+ sessionType: FreshAgentSessionType
+ provider: FreshAgentRuntimeProvider
+ threadId: string
+ turnId: string
+ revision: number
+ }) {
+ const registration = this.requireRegistration(input.sessionType, input.provider)
+ if (!registration?.adapter.getTurnBody) {
+ throw new FreshAgentRuntimeUnavailableError(`No fresh-agent turn-body adapter registered for ${input.sessionType}`)
+ }
+ const body = await registration.adapter.getTurnBody(
+ {
+ sessionType: input.sessionType,
+ provider: input.provider,
+ threadId: input.threadId,
+ turnId: input.turnId,
+ },
+ input.revision,
+ )
+ const parsed = FreshAgentTurnBodySchema.safeParse(body)
+ if (!parsed.success) {
+ throw new FreshAgentContractValidationError('turn-body', parsed.error.issues)
+ }
+ return parsed.data
+ }
+
+ private requireRegistration(sessionType: FreshAgentSessionType, provider?: FreshAgentRuntimeProvider) {
+ const registration = this.options.registry.resolveBySessionType(sessionType)
+ if (!registration) {
+ throw new FreshAgentRuntimeUnavailableError(`No fresh-agent adapter registered for ${sessionType}`)
+ }
+ if (provider && registration.runtimeProvider !== provider) {
+ throw new FreshAgentSessionLocatorMismatchError(
+ `Fresh-agent session type ${sessionType} uses ${registration.runtimeProvider}, not ${provider}`,
+ )
+ }
+ return registration
+ }
+
+ private key(locator: FreshAgentSessionLocator): string {
+ return makeFreshAgentSessionKey(locator)
+ }
+
+ private requireSession(locator: FreshAgentSessionLocator): SessionRecord {
+ const record = this.sessions.get(this.key(locator))
+ if (!record) {
+ throw new FreshAgentLostSessionError(
+ `Fresh-agent session ${locator.sessionType}/${locator.provider}/${locator.sessionId} is not tracked`,
+ )
+ }
+ if (record.sessionType !== locator.sessionType || record.runtimeProvider !== locator.provider) {
+ throw new FreshAgentSessionLocatorMismatchError(
+ `Fresh-agent session ${locator.sessionId} is tracked as ${record.sessionType}/${record.runtimeProvider}, not ${locator.sessionType}/${locator.provider}`,
+ )
+ }
+ return record
+ }
+}
diff --git a/server/index.ts b/server/index.ts
index d443fae17..0d0d7cd31 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -80,6 +80,11 @@ import {
import { CodexLaunchPlanner } from './coding-cli/codex-app-server/launch-planner.js'
import { registerStaticClientRoutes } from './static-client-routes.js'
import { joinCodexShutdownOwners } from './shutdown-join.js'
+import { createFreshAgentProviderRegistry } from './fresh-agent/provider-registry.js'
+import { FreshAgentRuntimeManager } from './fresh-agent/runtime-manager.js'
+import { createFreshAgentRouter } from './fresh-agent/router.js'
+import { createClaudeFreshAgentAdapter } from './fresh-agent/adapters/claude/adapter.js'
+import { createCodexFreshAgentAdapter } from './fresh-agent/adapters/codex/adapter.js'
function compileArgTemplate(
template: string[] | undefined,
@@ -296,9 +301,40 @@ async function main() {
getLiveSessionByCliSessionId: (timelineSessionId) => sdkBridge.findLiveSessionByCliSessionId(timelineSessionId),
})
sdkBridge = new SdkBridge(agentHistorySource)
+ const claudeFreshAgentTimelineService = createAgentTimelineService({
+ agentHistorySource,
+ })
+ const claudeFreshAgentAdapter = createClaudeFreshAgentAdapter({
+ sdkBridge,
+ agentHistorySource,
+ timelineService: claudeFreshAgentTimelineService,
+ })
const server = http.createServer(app)
+ const codexFreshAgentRuntime = new CodexAppServerRuntime({ serverInstanceId })
const codexLaunchPlanner = new CodexLaunchPlanner(() => new CodexAppServerRuntime({ serverInstanceId }))
+ const codexFreshAgentAdapter = createCodexFreshAgentAdapter({
+ runtime: codexFreshAgentRuntime,
+ })
+ const freshAgentRuntimeManager = new FreshAgentRuntimeManager({
+ registry: createFreshAgentProviderRegistry([
+ {
+ sessionType: 'freshclaude',
+ runtimeProvider: 'claude',
+ adapter: claudeFreshAgentAdapter,
+ },
+ {
+ sessionType: 'kilroy',
+ runtimeProvider: 'claude',
+ adapter: claudeFreshAgentAdapter,
+ },
+ {
+ sessionType: 'freshcodex',
+ runtimeProvider: 'codex',
+ adapter: codexFreshAgentAdapter,
+ },
+ ]),
+ })
const wsHandler = new WsHandler(
server,
registry,
@@ -328,6 +364,7 @@ async function main() {
codexActivityListProvider: () => codexActivity.tracker.list(),
agentHistorySource,
opencodeActivityListProvider: () => opencodeActivity.tracker.list(),
+ freshAgentRuntimeManager,
},
)
attachProxyUpgradeHandler(server)
@@ -503,10 +540,9 @@ async function main() {
}))
app.use('/api', createAgentTimelineRouter({
- service: createAgentTimelineService({
- agentHistorySource,
- }),
+ service: claudeFreshAgentTimelineService,
}))
+ app.use('/api', createFreshAgentRouter({ runtimeManager: freshAgentRuntimeManager }))
app.use('/api', createProjectColorsRouter({ configStore, codingCliIndexer }))
@@ -814,6 +850,7 @@ async function main() {
await joinCodexShutdownOwners({
registry,
codexLaunchPlanner,
+ codexFreshAgentRuntime,
terminalShutdownTimeoutMs: 5000,
})
} finally {
diff --git a/server/mcp/freshell-tool.ts b/server/mcp/freshell-tool.ts
index 085dfad00..04153a116 100644
--- a/server/mcp/freshell-tool.ts
+++ b/server/mcp/freshell-tool.ts
@@ -8,6 +8,7 @@
import { z } from 'zod'
import { createApiClient, resolveConfig, type ApiClient } from './http-client.js'
import { translateKeys } from '../cli/keys.js'
+import { isCanonicalClaudeSessionId } from '../../shared/session-contract.js'
// Lazy-initialized client -- created on first use so env vars are read at call time.
let _client: ApiClient | undefined
@@ -46,7 +47,7 @@ FRESHELL_URL and FRESHELL_TOKEN are already set in your environment.
## Mental model
- Tabs contain pane trees (splits). Panes contain content.
-- Pane kinds: terminal, editor, browser, agent-chat (Claude/Codex/etc.), picker (transient).
+- Pane kinds: terminal, editor, browser, fresh-agent (Freshclaude/Freshcodex/etc.), picker (transient).
- **Picker panes are ephemeral.** A freshly-created tab without mode/browser/editor starts as a picker pane while the user chooses what to launch. Once they select, the picker is replaced by the real pane with a **new pane ID**. Never target a picker pane for splits or mutations -- use mode/browser/editor params on new-tab/split-pane to skip the picker entirely.
- Typical workflow: new-tab -> send-keys -> wait-for -> capture-pane/screenshot.
@@ -70,7 +71,7 @@ FRESHELL_URL and FRESHELL_TOKEN are already set in your environment.
## Key gotchas
- **Tab and pane IDs are ephemeral.** IDs from open-browser, new-tab, and split-pane are valid only within the current session. If the Freshell server restarts or the agent conversation resumes after a disconnect, previously returned IDs may no longer exist. Always call open-browser or list-tabs fresh rather than reusing stale IDs.
-- **Always screenshot with `screenshot({ scope: "tab", target: tabId })` after open-browser.** Network errors, CORS issues, or server problems can cause blank pages. open-browser returns a tabId — use it immediately to screenshot and confirm the page rendered before proceeding.
+- **Always screenshot with \`screenshot({ scope: "tab", target: tabId })\` after open-browser.** Network errors, CORS issues, or server problems can cause blank pages. open-browser returns a tabId — use it immediately to screenshot and confirm the page rendered before proceeding.
- send-keys: use literal mode (literal: true + keys as a string) for natural-language prompts or multi-word text. Do NOT append "ENTER" as literal text -- send the command with literal:true, then send ["ENTER"] as a separate call in token mode.
- wait-for with stable (seconds of no output) is more reliable than pattern matching across different CLI providers.
- Editor panes show "Loading..." until the tab is visited in the browser. When screenshotting multiple tabs, visit each tab first (select-tab), then loop back for screenshots.
@@ -469,7 +470,7 @@ Meta:
## Screenshot guidance
-- **Always screenshot with `screenshot({ scope: "tab", target: tabId })` after open-browser.** Network errors, blank pages, and CORS failures are silent unless you look. open-browser returns a tabId — use it immediately to confirm the page rendered before acting on it.
+- **Always screenshot with \`screenshot({ scope: "tab", target: tabId })\` after open-browser.** Network errors, blank pages, and CORS failures are silent unless you look. open-browser returns a tabId — use it immediately to confirm the page rendered before acting on it.
- Tab and pane IDs from earlier in a session may become stale after reconnections or server restarts. If screenshot fails to find a tab/pane, call list-tabs or list-panes to get fresh IDs rather than reusing old ones.
- Use a dedicated canary tab when validating screenshot behavior so live project panes are not contaminated.
- Close temporary tabs/panes after verification unless user asked to keep them open.
@@ -541,7 +542,7 @@ async function routeAction(
// -- Tab actions --
case 'new-tab': {
const { name, mode, shell, cwd, browser, editor, resume, prompt, ...rest } = params || {}
- const sessionRef = typeof mode === 'string' && typeof resume === 'string'
+ const sessionRef = typeof mode === 'string' && typeof resume === 'string' && isCanonicalClaudeSessionId(resume)
? { provider: mode, sessionId: resume }
: undefined
const tabResult = await c.post('/api/tabs', {
diff --git a/server/sdk-bridge-types.ts b/server/sdk-bridge-types.ts
index 0ba1b337b..b852125e1 100644
--- a/server/sdk-bridge-types.ts
+++ b/server/sdk-bridge-types.ts
@@ -74,6 +74,7 @@ export interface SdkSessionState {
cwd?: string
model?: string
permissionMode?: string
+ plugins?: string[]
tools?: Array<{ name: string }>
status: SdkSessionStatus
createdAt: number
diff --git a/server/sdk-bridge.ts b/server/sdk-bridge.ts
index 9a8b5ca3a..f5d9f41d9 100644
--- a/server/sdk-bridge.ts
+++ b/server/sdk-bridge.ts
@@ -114,6 +114,7 @@ export class SdkBridge extends EventEmitter {
private cloneSessionState(state: SdkSessionState): SdkSessionState {
return {
...state,
+ plugins: state.plugins ? [...state.plugins] : undefined,
tools: state.tools ? state.tools.map((tool) => ({ ...tool })) : undefined,
messages: state.messages.map((message) => ({
...message,
@@ -167,6 +168,7 @@ export class SdkBridge extends EventEmitter {
cwd: options.cwd,
model: options.model,
permissionMode: options.permissionMode,
+ plugins: options.plugins ? sanitizeAgentChatPluginPaths(options.plugins) : undefined,
status: 'starting',
createdAt: Date.now(),
messages: [],
diff --git a/server/shutdown-join.ts b/server/shutdown-join.ts
index af0ae6b6e..924c6acf7 100644
--- a/server/shutdown-join.ts
+++ b/server/shutdown-join.ts
@@ -35,16 +35,21 @@ type CodexShutdownOwners = {
codexLaunchPlanner: {
shutdown(): Promise
}
+ codexFreshAgentRuntime?: {
+ shutdown(): Promise
+ }
terminalShutdownTimeoutMs: number
}
export async function joinCodexShutdownOwners({
registry,
codexLaunchPlanner,
+ codexFreshAgentRuntime,
terminalShutdownTimeoutMs,
}: CodexShutdownOwners): Promise {
await waitForAllSettledOrThrow([
invokeShutdownTask(() => registry.shutdownGracefully(terminalShutdownTimeoutMs)),
invokeShutdownTask(() => codexLaunchPlanner.shutdown()),
+ ...(codexFreshAgentRuntime ? [invokeShutdownTask(() => codexFreshAgentRuntime.shutdown())] : []),
], 'Codex shutdown owners failed.')
}
diff --git a/server/tabs-registry/types.ts b/server/tabs-registry/types.ts
index 89592e925..d4b6828dd 100644
--- a/server/tabs-registry/types.ts
+++ b/server/tabs-registry/types.ts
@@ -10,6 +10,7 @@ export const RegistryPaneKindSchema = z.enum([
'picker',
'claude-chat',
'agent-chat',
+ 'fresh-agent',
'extension',
])
export type RegistryPaneKind = z.infer
diff --git a/server/ws-handler.ts b/server/ws-handler.ts
index e7cee3ca8..54f8ab123 100644
--- a/server/ws-handler.ts
+++ b/server/ws-handler.ts
@@ -60,6 +60,14 @@ import {
TerminalKillSchema,
CodingCliInputSchema,
CodingCliKillSchema,
+ FreshAgentAttachSchema,
+ FreshAgentCreateSchema,
+ FreshAgentApprovalRespondSchema,
+ FreshAgentForkSchema,
+ FreshAgentInterruptSchema,
+ FreshAgentKillSchema,
+ FreshAgentQuestionRespondSchema,
+ FreshAgentSendSchema,
SdkCreateSchema,
SdkSendSchema,
SdkPermissionRespondSchema,
@@ -74,6 +82,7 @@ import {
} from '../shared/ws-protocol.js'
import { UiLayoutSyncSchema } from './agent-api/layout-schema.js'
import type { LayoutStore } from './agent-api/layout-store.js'
+import { LiveTerminalHandleSchema } from '../shared/session-contract.js'
type WsHandlerConfig = {
maxConnections: number
@@ -90,6 +99,38 @@ type WsHandlerConfig = {
terminalCreateRateWindowMs: number
}
+type FreshAgentRuntimeManagerLike = {
+ create: (input: any) => Promise
+ attach: (input: any) => any
+ subscribe?: (locator: any, listener: (message: unknown) => void) => Promise<() => void> | (() => void)
+ send?: (locator: any, input: any) => Promise | void
+ interrupt?: (locator: any) => Promise | void
+ resolveApproval?: (locator: any, requestId: string | number, decision: Record) => Promise | void
+ answerQuestion?: (locator: any, requestId: string | number, answers: Record) => Promise | void
+ kill?: (locator: any) => Promise | boolean
+ fork?: (locator: any, input?: Record) => Promise | unknown
+}
+
+type FreshAgentLocator = {
+ sessionId: string
+ sessionType: string
+ provider: string
+}
+
+type FreshAgentCreatedRecord = {
+ sessionId: string
+ sessionType: string
+ provider: string
+ runtimeProvider: string
+ sessionRef?: { provider: string; sessionId: string }
+}
+
+type FreshAgentSubscriptionEntry = {
+ active: boolean
+ off?: () => void
+ pending?: Promise
+}
+
export type WsHandlerOptions = {
codingCliManager?: CodingCliSessionManager
codexLaunchPlanner?: CodexLaunchPlanner
@@ -104,6 +145,7 @@ export type WsHandlerOptions = {
codexActivityListProvider?: () => CodexActivityRecord[]
agentHistorySource?: AgentHistorySource
opencodeActivityListProvider?: () => OpencodeActivityRecord[]
+ freshAgentRuntimeManager?: FreshAgentRuntimeManagerLike
}
function readWsHandlerConfig(): WsHandlerConfig {
@@ -238,6 +280,13 @@ function extractSessionLocatorsFromUiContent(content: Record):
return locators
}
+ if (kind === 'fresh-agent') {
+ if (isNonEmptyString(content.resumeSessionId) && isValidClaudeSessionId(content.resumeSessionId)) {
+ locators.push({ provider: 'claude', sessionId: content.resumeSessionId })
+ }
+ return locators
+ }
+
if (kind !== 'terminal') return locators
const mode = CodingCliProviderSchema.safeParse(content.mode)
@@ -325,6 +374,7 @@ type ClientState = {
sdkSessions: Set
sdkSubscriptions: Map void>
sdkSessionTargets: Map
+ freshAgentSubscriptions: Map
interestedSessions: Set
sidebarOpenSessionKeys: Set
helloTimer?: NodeJS.Timeout
@@ -384,12 +434,15 @@ export class WsHandler {
private layoutStore?: LayoutStore
private extensionManager?: ExtensionManager
private agentHistorySource?: AgentHistorySource
+ private freshAgentRuntimeManager?: FreshAgentRuntimeManagerLike
private terminalStreamBroker: TerminalStreamBroker
private terminalCreateLocks = new Map>()
private createdTerminalByRequestId = new Map()
private sdkCreateLocks = new Map>()
private createdSdkSessionByRequestId = new Map()
private sdkSessionByCreateOwnerKey = new Map()
+ private freshAgentCreateLocks = new Map>()
+ private createdFreshAgentByRequestId = new Map()
private screenshotRequests = new Map()
private sessionsRevision = 0
private terminalsRevision = 0
@@ -420,6 +473,7 @@ export class WsHandler {
this.codingCliManager = options.codingCliManager
this.codexLaunchPlanner = options.codexLaunchPlanner
this.sdkBridge = options.sdkBridge
+ this.freshAgentRuntimeManager = options.freshAgentRuntimeManager
this.sessionRepairService = options.sessionRepairService
this.handshakeSnapshotProvider = options.handshakeSnapshotProvider
this.terminalMetaListProvider = options.terminalMetaListProvider
@@ -510,6 +564,14 @@ export class WsHandler {
dynamicCodingCliCreateSchema,
CodingCliInputSchema,
CodingCliKillSchema,
+ FreshAgentCreateSchema,
+ FreshAgentAttachSchema,
+ FreshAgentSendSchema,
+ FreshAgentInterruptSchema,
+ FreshAgentApprovalRespondSchema,
+ FreshAgentQuestionRespondSchema,
+ FreshAgentKillSchema,
+ FreshAgentForkSchema,
SdkCreateSchema,
SdkSendSchema,
SdkPermissionRespondSchema,
@@ -743,6 +805,23 @@ export class WsHandler {
return current
}
+ private withFreshAgentCreateLock(key: string, task: () => Promise): Promise {
+ const previous = this.freshAgentCreateLocks.get(key) ?? Promise.resolve()
+
+ let current: Promise
+ current = previous
+ .catch(() => undefined)
+ .then(task)
+ .finally(() => {
+ if (this.freshAgentCreateLocks.get(key) === current) {
+ this.freshAgentCreateLocks.delete(key)
+ }
+ })
+
+ this.freshAgentCreateLocks.set(key, current)
+ return current
+ }
+
private async resolveSdkCreateOwnership(
requestId: string,
resumeSessionId?: string,
@@ -832,6 +911,14 @@ export class WsHandler {
return undefined
}
+ private clearFreshAgentCreateCachesForSession(sessionId: string): void {
+ for (const [requestId, cached] of this.createdFreshAgentByRequestId.entries()) {
+ if (cached.sessionId === sessionId) {
+ this.createdFreshAgentByRequestId.delete(requestId)
+ }
+ }
+ }
+
private resolveSdkOwnerSession(ownerKey: string): SdkSessionState | undefined {
const cachedSessionId = this.sdkSessionByCreateOwnerKey.get(ownerKey)
if (!cachedSessionId) return undefined
@@ -1053,6 +1140,7 @@ export class WsHandler {
sdkSessions: new Set(),
sdkSubscriptions: new Map(),
sdkSessionTargets: new Map(),
+ freshAgentSubscriptions: new Map(),
interestedSessions: new Set(),
sidebarOpenSessionKeys: new Set(),
}
@@ -1105,6 +1193,7 @@ export class WsHandler {
off()
}
state.sdkSubscriptions.clear()
+ this.cancelAllFreshAgentSubscriptions(state)
for (const [requestId, pending] of this.screenshotRequests) {
if (pending.connectionId !== ws.connectionId) continue
@@ -1568,6 +1657,129 @@ export class WsHandler {
}
}
+ private freshAgentKey(locator: FreshAgentLocator): string {
+ return `${locator.sessionType}:${locator.provider}:${locator.sessionId}`
+ }
+
+ private freshAgentEventMessage(locator: FreshAgentLocator, event: unknown) {
+ return {
+ type: 'freshAgent.event',
+ sessionId: locator.sessionId,
+ sessionType: locator.sessionType,
+ provider: locator.provider,
+ event,
+ }
+ }
+
+ private freshAgentUnavailableMessage() {
+ return 'Fresh Agent runtime is not enabled'
+ }
+
+ private sendFreshAgentSubscriptionError(ws: LiveWebSocket, locator: FreshAgentLocator, error: unknown): void {
+ this.safeSend(ws, this.freshAgentEventMessage(locator, {
+ type: 'sdk.error',
+ sessionId: locator.sessionId,
+ code: 'FRESH_AGENT_SUBSCRIBE_FAILED',
+ message: errorMessage(error),
+ }))
+ }
+
+ private logFreshAgentSubscriptionOffError(locator: FreshAgentLocator, error: unknown): void {
+ log.warn({
+ err: error instanceof Error ? error : new Error(String(error)),
+ sessionId: locator.sessionId,
+ sessionType: locator.sessionType,
+ provider: locator.provider,
+ }, 'Fresh Agent subscription cleanup failed')
+ }
+
+ private ensureFreshAgentSubscription(
+ ws: LiveWebSocket,
+ state: ClientState,
+ locator: FreshAgentLocator,
+ ): void {
+ const manager = this.freshAgentRuntimeManager
+ if (!manager?.subscribe) return
+
+ const key = this.freshAgentKey(locator)
+ const existing = state.freshAgentSubscriptions.get(key)
+ if (existing) {
+ existing.active = true
+ return
+ }
+
+ const entry: FreshAgentSubscriptionEntry = { active: true }
+ state.freshAgentSubscriptions.set(key, entry)
+
+ const listener = (event: unknown) => {
+ if (!entry.active) return
+ this.safeSend(ws, this.freshAgentEventMessage(locator, event))
+ }
+
+ entry.pending = Promise.resolve()
+ .then(() => manager.subscribe?.(locator, listener))
+ .then((off) => {
+ entry.pending = undefined
+ if (!entry.active) {
+ if (off) {
+ try {
+ off()
+ } catch (error) {
+ this.logFreshAgentSubscriptionOffError(locator, error)
+ }
+ }
+ state.freshAgentSubscriptions.delete(key)
+ return
+ }
+ if (off) {
+ entry.off = off
+ }
+ })
+ .catch((error) => {
+ entry.pending = undefined
+ state.freshAgentSubscriptions.delete(key)
+ if (entry.active) {
+ this.sendFreshAgentSubscriptionError(ws, locator, error)
+ }
+ })
+ }
+
+ private cancelFreshAgentSubscription(
+ state: ClientState,
+ locator: FreshAgentLocator,
+ ): void {
+ const key = this.freshAgentKey(locator)
+ const entry = state.freshAgentSubscriptions.get(key)
+ if (!entry) return
+
+ entry.active = false
+ state.freshAgentSubscriptions.delete(key)
+ if (entry.off) {
+ try {
+ entry.off()
+ } catch (error) {
+ this.logFreshAgentSubscriptionOffError(locator, error)
+ }
+ }
+ }
+
+ private cancelAllFreshAgentSubscriptions(state: ClientState): void {
+ for (const [key, entry] of Array.from(state.freshAgentSubscriptions.entries())) {
+ entry.active = false
+ state.freshAgentSubscriptions.delete(key)
+ if (entry.off) {
+ try {
+ entry.off()
+ } catch (error) {
+ log.warn({
+ err: error instanceof Error ? error : new Error(String(error)),
+ key,
+ }, 'Fresh Agent subscription cleanup failed')
+ }
+ }
+ }
+ }
+
/**
* Wait for ws.bufferedAmount to drop below threshold.
* Returns true if drained, false if timed out, connection closed, or cancelled.
@@ -2810,6 +3022,215 @@ export class WsHandler {
return
}
+ case 'freshAgent.create': {
+ const manager = this.freshAgentRuntimeManager
+ if (!manager) {
+ this.send(ws, {
+ type: 'freshAgent.create.failed',
+ requestId: m.requestId,
+ code: 'FRESH_AGENT_RUNTIME_UNAVAILABLE',
+ message: this.freshAgentUnavailableMessage(),
+ retryable: false,
+ })
+ return
+ }
+
+ await this.withFreshAgentCreateLock(m.requestId, async () => {
+ const cached = this.createdFreshAgentByRequestId.get(m.requestId)
+ if (cached) {
+ this.send(ws, {
+ type: 'freshAgent.created',
+ requestId: m.requestId,
+ ...cached,
+ })
+ this.ensureFreshAgentSubscription(ws, state, {
+ sessionId: cached.sessionId,
+ sessionType: cached.sessionType,
+ provider: cached.runtimeProvider,
+ })
+ return
+ }
+
+ try {
+ const result = await manager.create({
+ requestId: m.requestId,
+ sessionType: m.sessionType,
+ provider: m.provider,
+ cwd: m.cwd,
+ resumeSessionId: m.resumeSessionId,
+ sessionRef: m.sessionRef,
+ model: m.model,
+ modelSelection: m.modelSelection ?? undefined,
+ permissionMode: m.permissionMode,
+ sandbox: m.sandbox,
+ effort: m.effort,
+ plugins: m.plugins,
+ })
+ const runtimeProvider = typeof result?.runtimeProvider === 'string'
+ ? result.runtimeProvider
+ : m.provider
+ if (!runtimeProvider) {
+ throw new Error('Fresh Agent runtime provider was not resolved')
+ }
+ const record: FreshAgentCreatedRecord = {
+ sessionId: result.sessionId,
+ sessionType: result.sessionType ?? m.sessionType,
+ provider: runtimeProvider,
+ runtimeProvider,
+ ...(result.sessionRef ? { sessionRef: result.sessionRef } : {}),
+ }
+ this.createdFreshAgentByRequestId.set(m.requestId, record)
+ this.send(ws, {
+ type: 'freshAgent.created',
+ requestId: m.requestId,
+ ...record,
+ })
+ this.ensureFreshAgentSubscription(ws, state, {
+ sessionId: record.sessionId,
+ sessionType: record.sessionType,
+ provider: record.runtimeProvider,
+ })
+ } catch (error) {
+ log.warn({
+ err: error instanceof Error ? error : new Error(String(error)),
+ requestId: m.requestId,
+ sessionType: m.sessionType,
+ provider: m.provider,
+ }, 'freshAgent.create failed')
+ const code = typeof (error as { code?: unknown })?.code === 'string'
+ ? (error as { code: string }).code
+ : 'FRESH_AGENT_CREATE_FAILED'
+ this.send(ws, {
+ type: 'freshAgent.create.failed',
+ requestId: m.requestId,
+ code,
+ message: errorMessage(error),
+ retryable: true,
+ })
+ }
+ })
+ return
+ }
+
+ case 'freshAgent.attach': {
+ const manager = this.freshAgentRuntimeManager
+ if (!manager) {
+ this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() })
+ return
+ }
+ const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider }
+ try {
+ await Promise.resolve(manager.attach(locator))
+ this.ensureFreshAgentSubscription(ws, state, locator)
+ } catch (error) {
+ log.warn({
+ err: error instanceof Error ? error : new Error(String(error)),
+ ...locator,
+ }, 'freshAgent.attach failed')
+ this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error) })
+ }
+ return
+ }
+
+ case 'freshAgent.send': {
+ const manager = this.freshAgentRuntimeManager
+ if (!manager?.send) {
+ this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() })
+ return
+ }
+ const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider }
+ try {
+ await manager.send(locator, { text: m.text, images: m.images })
+ } catch (error) {
+ this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error) })
+ }
+ return
+ }
+
+ case 'freshAgent.interrupt': {
+ const manager = this.freshAgentRuntimeManager
+ if (!manager?.interrupt) {
+ this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() })
+ return
+ }
+ const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider }
+ try {
+ await manager.interrupt(locator)
+ } catch (error) {
+ this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error) })
+ }
+ return
+ }
+
+ case 'freshAgent.approval.respond': {
+ const manager = this.freshAgentRuntimeManager
+ if (!manager?.resolveApproval) {
+ this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() })
+ return
+ }
+ const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider }
+ try {
+ await manager.resolveApproval(locator, m.requestId, m.decision)
+ } catch (error) {
+ this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error) })
+ }
+ return
+ }
+
+ case 'freshAgent.question.respond': {
+ const manager = this.freshAgentRuntimeManager
+ if (!manager?.answerQuestion) {
+ this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() })
+ return
+ }
+ const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider }
+ try {
+ await manager.answerQuestion(locator, m.requestId, m.answers)
+ } catch (error) {
+ this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error) })
+ }
+ return
+ }
+
+ case 'freshAgent.fork': {
+ const manager = this.freshAgentRuntimeManager
+ if (!manager?.fork) {
+ this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() })
+ return
+ }
+ const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider }
+ try {
+ await manager.fork(locator, m.input)
+ } catch (error) {
+ this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error) })
+ }
+ return
+ }
+
+ case 'freshAgent.kill': {
+ const manager = this.freshAgentRuntimeManager
+ if (!manager?.kill) {
+ this.sendError(ws, { code: 'INTERNAL_ERROR', message: this.freshAgentUnavailableMessage() })
+ return
+ }
+ const locator = { sessionId: m.sessionId, sessionType: m.sessionType, provider: m.provider }
+ this.cancelFreshAgentSubscription(state, locator)
+ try {
+ const success = await manager.kill(locator)
+ this.clearFreshAgentCreateCachesForSession(m.sessionId)
+ this.send(ws, {
+ type: 'freshAgent.killed',
+ sessionId: m.sessionId,
+ sessionType: m.sessionType,
+ provider: m.provider,
+ success,
+ })
+ } catch (error) {
+ this.sendError(ws, { code: 'INTERNAL_ERROR', message: errorMessage(error) })
+ }
+ return
+ }
+
case 'sdk.send': {
if (!this.sdkBridge) {
this.sendError(ws, { code: 'INTERNAL_ERROR', message: 'SDK bridge not enabled' })
@@ -3302,6 +3723,8 @@ export class WsHandler {
this.screenshotRequests.delete(requestId)
}
this.createdTerminalByRequestId.clear()
+ this.createdFreshAgentByRequestId.clear()
+ this.freshAgentCreateLocks.clear()
// Close all client connections
for (const ws of this.connections) {
diff --git a/shared/fresh-agent-contract.ts b/shared/fresh-agent-contract.ts
new file mode 100644
index 000000000..c266e707b
--- /dev/null
+++ b/shared/fresh-agent-contract.ts
@@ -0,0 +1,314 @@
+import { z } from 'zod'
+
+export const FreshAgentSessionTypeSchema = z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode'])
+export const FreshAgentRuntimeProviderSchema = z.enum(['claude', 'codex', 'opencode'])
+
+export const FreshAgentThreadLocatorSchema = z.object({
+ sessionType: FreshAgentSessionTypeSchema,
+ provider: FreshAgentRuntimeProviderSchema,
+ threadId: z.string().min(1),
+}).strict()
+
+export const FreshAgentRequestIdSchema = z.union([z.string().min(1), z.number().int()])
+
+export const FreshAgentCapabilitiesSchema = z.object({
+ send: z.boolean(),
+ interrupt: z.boolean(),
+ approvals: z.boolean(),
+ questions: z.boolean(),
+ fork: z.boolean(),
+ worktrees: z.boolean().optional(),
+ diffs: z.boolean().optional(),
+ childThreads: z.boolean().optional(),
+}).strict()
+
+export const FreshAgentTokenUsageSchema = 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(),
+ costUsd: z.number().nonnegative().optional(),
+}).strict()
+
+export const FreshAgentSettingsSchema = z.object({
+ model: z.string().min(1).optional(),
+ permissionMode: z.string().min(1).optional(),
+ effort: z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']).optional(),
+ plugins: z.array(z.string()).optional(),
+}).strict()
+
+const JsonValueSchema: z.ZodType = z.lazy(() => z.union([
+ z.string(),
+ z.number(),
+ z.boolean(),
+ z.null(),
+ z.array(JsonValueSchema),
+ z.record(z.string(), JsonValueSchema),
+]))
+
+export const FreshAgentTranscriptItemSchema = z.discriminatedUnion('kind', [
+ z.object({
+ id: z.string().min(1),
+ kind: z.literal('text'),
+ text: z.string(),
+ }).strict(),
+ z.object({
+ id: z.string().min(1),
+ kind: z.literal('thinking'),
+ text: z.string(),
+ }).strict(),
+ z.object({
+ id: z.string().min(1),
+ kind: z.literal('reasoning'),
+ summary: z.array(z.string()),
+ content: z.array(z.string()),
+ text: z.string().optional(),
+ }).strict(),
+ z.object({
+ id: z.string().min(1),
+ kind: z.literal('tool_use'),
+ toolUseId: z.string().min(1),
+ name: z.string().min(1),
+ input: JsonValueSchema.optional(),
+ }).strict(),
+ z.object({
+ id: z.string().min(1),
+ kind: z.literal('tool_result'),
+ toolUseId: z.string().min(1),
+ content: JsonValueSchema,
+ isError: z.boolean(),
+ }).strict(),
+ z.object({
+ id: z.string().min(1),
+ kind: z.literal('command'),
+ command: z.string(),
+ cwd: z.string().optional(),
+ status: z.enum(['running', 'completed', 'failed', 'declined']),
+ output: z.string().nullable().optional(),
+ exitCode: z.number().int().nullable().optional(),
+ extensions: z.record(z.string(), z.unknown()).optional(),
+ }).strict(),
+ z.object({
+ id: z.string().min(1),
+ kind: z.literal('file_change'),
+ status: z.enum(['running', 'completed', 'failed', 'declined']),
+ changes: z.array(z.record(z.string(), z.unknown())),
+ extensions: z.record(z.string(), z.unknown()).optional(),
+ }).strict(),
+ z.object({
+ id: z.string().min(1),
+ kind: z.literal('mcp_tool'),
+ server: z.string(),
+ tool: z.string(),
+ status: z.enum(['running', 'completed', 'failed']),
+ arguments: JsonValueSchema,
+ result: JsonValueSchema.optional(),
+ error: JsonValueSchema.optional(),
+ }).strict(),
+ z.object({
+ id: z.string().min(1),
+ kind: z.literal('dynamic_tool'),
+ namespace: z.string().nullable().optional(),
+ tool: z.string(),
+ status: z.enum(['running', 'completed', 'failed']),
+ arguments: JsonValueSchema,
+ contentItems: z.array(z.unknown()).nullable().optional(),
+ success: z.boolean().nullable().optional(),
+ }).strict(),
+ z.object({
+ id: z.string().min(1),
+ kind: z.literal('collab_agent'),
+ tool: z.string(),
+ status: z.enum(['running', 'completed', 'failed']),
+ senderThreadId: z.string().min(1),
+ receiverThreadIds: z.array(z.string().min(1)),
+ prompt: z.string().nullable().optional(),
+ model: z.string().nullable().optional(),
+ reasoningEffort: z.string().nullable().optional(),
+ agentsStates: z.record(z.string(), z.unknown()),
+ }).strict(),
+ z.object({
+ id: z.string().min(1),
+ kind: z.literal('web_search'),
+ query: z.string(),
+ action: z.unknown().nullable().optional(),
+ }).strict(),
+ z.object({
+ id: z.string().min(1),
+ kind: z.literal('image_view'),
+ path: z.string(),
+ }).strict(),
+ z.object({
+ id: z.string().min(1),
+ kind: z.literal('image_generation'),
+ status: z.string(),
+ revisedPrompt: z.string().nullable().optional(),
+ result: z.string(),
+ savedPath: z.string().optional(),
+ displayStatus: z.string().optional(),
+ }).strict(),
+ z.object({
+ id: z.string().min(1),
+ kind: z.literal('review_mode'),
+ event: z.enum(['entered', 'exited']),
+ review: z.string(),
+ }).strict(),
+ z.object({
+ id: z.string().min(1),
+ kind: z.literal('context_compaction'),
+ }).strict(),
+])
+
+export const FreshAgentTurnSchema = z.object({
+ id: z.string().min(1),
+ turnId: z.string().min(1),
+ messageId: z.string().min(1).optional(),
+ ordinal: z.number().int().nonnegative().optional(),
+ source: z.enum(['durable', 'live', 'server']).optional(),
+ role: z.enum(['user', 'assistant', 'system', 'tool']).optional(),
+ timestamp: z.string().optional(),
+ model: z.string().optional(),
+ summary: z.string(),
+ items: z.array(FreshAgentTranscriptItemSchema),
+}).strict()
+
+export const FreshAgentPendingApprovalSchema = z.object({
+ requestId: FreshAgentRequestIdSchema,
+ toolName: z.string().optional(),
+ toolUseID: z.string().optional(),
+ blockedPath: z.string().optional(),
+ decisionReason: z.string().optional(),
+ input: z.record(z.string(), z.unknown()).optional(),
+ providerRequest: z.record(z.string(), z.unknown()).optional(),
+}).strict()
+
+export const FreshAgentQuestionDefinitionSchema = z.object({
+ question: z.string(),
+ header: z.string().optional(),
+ options: z.array(z.object({
+ label: z.string(),
+ description: z.string(),
+ }).strict()).optional(),
+ multiSelect: z.boolean().optional(),
+}).strict()
+
+export const FreshAgentPendingQuestionSchema = z.object({
+ requestId: FreshAgentRequestIdSchema,
+ questions: z.array(FreshAgentQuestionDefinitionSchema),
+ providerRequest: z.record(z.string(), z.unknown()).optional(),
+}).strict()
+
+export const FreshAgentWorktreeSchema = z.object({
+ id: z.string().min(1),
+ path: z.string().min(1),
+ branch: z.string().optional(),
+}).strict()
+
+export const FreshAgentDiffSummarySchema = z.object({
+ id: z.string().min(1),
+ path: z.string().optional(),
+ title: z.string().optional(),
+ status: z.string().optional(),
+}).strict()
+
+export const FreshAgentChildThreadSchema = z.object({
+ id: z.string().min(1),
+ threadId: z.string().min(1),
+ origin: z.string(),
+ title: z.string().optional(),
+ receiverThreadIds: z.array(z.string().min(1)).optional(),
+}).strict()
+
+export const FreshAgentExtensionsSchema = z.object({
+ claude: z.record(z.string(), z.unknown()).optional(),
+ codex: z.record(z.string(), z.unknown()).optional(),
+ opencode: z.record(z.string(), z.unknown()).optional(),
+}).strict()
+
+export const FreshAgentSnapshotSchema = FreshAgentThreadLocatorSchema.extend({
+ sessionId: z.string().min(1).optional(),
+ revision: z.number().int().nonnegative(),
+ latestTurnId: z.string().nullable().optional(),
+ status: z.string().min(1),
+ summary: z.string().optional(),
+ capabilities: FreshAgentCapabilitiesSchema,
+ settings: FreshAgentSettingsSchema.optional(),
+ tokenUsage: FreshAgentTokenUsageSchema,
+ pendingApprovals: z.array(FreshAgentPendingApprovalSchema).default([]),
+ pendingQuestions: z.array(FreshAgentPendingQuestionSchema).default([]),
+ worktrees: z.array(FreshAgentWorktreeSchema).default([]),
+ diffs: z.array(FreshAgentDiffSummarySchema).default([]),
+ childThreads: z.array(FreshAgentChildThreadSchema).default([]),
+ turns: z.array(FreshAgentTurnSchema).default([]),
+ extensions: FreshAgentExtensionsSchema.default({}),
+}).strict()
+
+export const FreshAgentTurnPageSchema = FreshAgentThreadLocatorSchema.extend({
+ revision: z.number().int().nonnegative(),
+ nextCursor: z.string().nullable(),
+ backwardsCursor: z.string().nullable().optional(),
+ turns: z.array(FreshAgentTurnSchema),
+ bodies: z.record(z.string(), FreshAgentTurnSchema).optional(),
+}).strict()
+
+export const FreshAgentTurnBodySchema = FreshAgentTurnSchema.extend({
+ sessionType: FreshAgentSessionTypeSchema,
+ provider: FreshAgentRuntimeProviderSchema,
+ threadId: z.string().min(1),
+ revision: z.number().int().nonnegative(),
+}).strict()
+
+export const FreshAgentActionResultSchema = FreshAgentThreadLocatorSchema.extend({
+ action: z.enum([
+ 'send',
+ 'interrupt',
+ 'fork',
+ 'review',
+ 'question.respond',
+ 'approval.respond',
+ ]),
+ revision: z.number().int().nonnegative().optional(),
+ result: z.record(z.string(), z.unknown()).default({}),
+}).strict()
+
+export const FreshAgentContractErrorSchema = z.object({
+ code: z.string().min(1),
+ message: z.string().min(1),
+ sessionType: FreshAgentSessionTypeSchema.optional(),
+ provider: FreshAgentRuntimeProviderSchema.optional(),
+ threadId: z.string().min(1).optional(),
+ details: z.unknown().optional(),
+}).strict()
+
+export const FRESH_AGENT_CONTRACT_SCHEMA_NAMES = [
+ 'FreshAgentThreadLocatorSchema',
+ 'FreshAgentRequestIdSchema',
+ 'FreshAgentCapabilitiesSchema',
+ 'FreshAgentTokenUsageSchema',
+ 'FreshAgentSettingsSchema',
+ 'FreshAgentTranscriptItemSchema',
+ 'FreshAgentTurnSchema',
+ 'FreshAgentPendingApprovalSchema',
+ 'FreshAgentPendingQuestionSchema',
+ 'FreshAgentWorktreeSchema',
+ 'FreshAgentDiffSummarySchema',
+ 'FreshAgentChildThreadSchema',
+ 'FreshAgentExtensionsSchema',
+ 'FreshAgentSnapshotSchema',
+ 'FreshAgentTurnPageSchema',
+ 'FreshAgentTurnBodySchema',
+ 'FreshAgentActionResultSchema',
+ 'FreshAgentContractErrorSchema',
+] as const
+
+export type FreshAgentThreadLocator = z.infer
+export type FreshAgentRequestId = z.infer
+export type FreshAgentTranscriptItem = z.infer
+export type FreshAgentTurn = z.infer
+export type FreshAgentPendingApproval = z.infer
+export type FreshAgentPendingQuestion = z.infer
+export type FreshAgentSnapshot = z.infer
+export type FreshAgentTurnPage = z.infer
+export type FreshAgentTurnBody = z.infer
diff --git a/shared/fresh-agent.ts b/shared/fresh-agent.ts
new file mode 100644
index 000000000..a81d5568b
--- /dev/null
+++ b/shared/fresh-agent.ts
@@ -0,0 +1,178 @@
+export type FreshAgentSessionType = 'freshclaude' | 'freshcodex' | 'kilroy' | 'freshopencode'
+
+export type FreshAgentRuntimeProvider = 'claude' | 'codex' | 'opencode'
+
+export type FreshAgentThreadIdentity = {
+ sessionType: FreshAgentSessionType
+ provider: FreshAgentRuntimeProvider
+ threadId: string
+}
+
+export type FreshAgentSessionIdentity = Omit & {
+ sessionId: string
+}
+
+export type FreshAgentCompatibilityShape = {
+ kind?: unknown
+ provider?: unknown
+ sessionType?: unknown
+ sessionId?: unknown
+ createRequestId?: unknown
+ status?: unknown
+ resumeSessionId?: unknown
+ sessionRef?: unknown
+ initialCwd?: unknown
+ createError?: unknown
+ model?: unknown
+ permissionMode?: unknown
+ effort?: unknown
+ plugins?: unknown
+ settingsDismissed?: unknown
+}
+
+export type FreshAgentDescriptor = {
+ sessionType: FreshAgentSessionType
+ runtimeProvider: FreshAgentRuntimeProvider
+ label: string
+ hidden?: boolean
+ disabled?: boolean
+}
+
+export const FRESH_AGENT_DESCRIPTORS: readonly FreshAgentDescriptor[] = [
+ {
+ sessionType: 'freshclaude',
+ runtimeProvider: 'claude',
+ label: 'Freshclaude',
+ },
+ {
+ sessionType: 'freshcodex',
+ runtimeProvider: 'codex',
+ label: 'Freshcodex',
+ },
+ {
+ sessionType: 'kilroy',
+ runtimeProvider: 'claude',
+ label: 'Kilroy',
+ hidden: true,
+ },
+ {
+ sessionType: 'freshopencode',
+ runtimeProvider: 'opencode',
+ label: 'Freshopencode',
+ disabled: true,
+ },
+] as const
+
+const FRESH_AGENT_DESCRIPTOR_BY_SESSION_TYPE = new Map(
+ FRESH_AGENT_DESCRIPTORS.map((descriptor) => [descriptor.sessionType, descriptor]),
+)
+
+export function isFreshAgentSessionType(value: unknown): value is FreshAgentSessionType {
+ return typeof value === 'string' && FRESH_AGENT_DESCRIPTOR_BY_SESSION_TYPE.has(value as FreshAgentSessionType)
+}
+
+export function getFreshAgentDescriptor(
+ sessionType: string | undefined,
+): FreshAgentDescriptor | undefined {
+ if (!sessionType) return undefined
+ return FRESH_AGENT_DESCRIPTOR_BY_SESSION_TYPE.get(sessionType as FreshAgentSessionType)
+}
+
+export function resolveFreshAgentRuntimeProvider(
+ sessionType: string | undefined,
+): FreshAgentRuntimeProvider | undefined {
+ return getFreshAgentDescriptor(sessionType)?.runtimeProvider
+}
+
+export function makeFreshAgentThreadKey(identity: FreshAgentThreadIdentity): string {
+ return `${identity.sessionType}:${identity.provider}:${identity.threadId}`
+}
+
+export function makeFreshAgentSessionKey(identity: FreshAgentSessionIdentity): string {
+ return makeFreshAgentThreadKey({
+ sessionType: identity.sessionType,
+ provider: identity.provider,
+ threadId: identity.sessionId,
+ })
+}
+
+export function normalizeFreshAgentSessionType(
+ value: unknown,
+): FreshAgentSessionType | undefined {
+ return isFreshAgentSessionType(value) ? value : undefined
+}
+
+export function migrateLegacyFreshAgentContent(
+ input: T,
+): T | (Omit & {
+ kind: 'fresh-agent'
+ provider: FreshAgentRuntimeProvider
+ sessionType: FreshAgentSessionType
+}) {
+ if (!input || typeof input !== 'object') {
+ return input
+ }
+
+ if (input.kind === 'fresh-agent') {
+ const sessionType = normalizeFreshAgentSessionType(input.sessionType)
+ ?? normalizeFreshAgentSessionType(input.provider)
+ const provider = (typeof input.provider === 'string'
+ && (input.provider === 'claude' || input.provider === 'codex' || input.provider === 'opencode'))
+ ? input.provider
+ : resolveFreshAgentRuntimeProvider(sessionType)
+
+ if (!sessionType || !provider) {
+ return input
+ }
+
+ return {
+ ...input,
+ kind: 'fresh-agent',
+ provider,
+ sessionType,
+ }
+ }
+
+ if (input.kind !== 'agent-chat') {
+ return input
+ }
+
+ const sessionType = normalizeFreshAgentSessionType(input.provider)
+ const provider = resolveFreshAgentRuntimeProvider(sessionType)
+ if (!sessionType || !provider) {
+ return input
+ }
+
+ return {
+ ...input,
+ kind: 'fresh-agent',
+ provider,
+ sessionType,
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return !!value && typeof value === 'object' && !Array.isArray(value)
+}
+
+export function migrateLegacyFreshAgentNode(node: unknown): unknown {
+ if (!isRecord(node)) {
+ return node
+ }
+
+ if (node.type === 'leaf' && isRecord(node.content)) {
+ return {
+ ...node,
+ content: migrateLegacyFreshAgentContent(node.content),
+ }
+ }
+
+ if (node.type === 'split' && Array.isArray(node.children)) {
+ return {
+ ...node,
+ children: node.children.map(migrateLegacyFreshAgentNode),
+ }
+ }
+
+ return node
+}
diff --git a/shared/read-models.ts b/shared/read-models.ts
index 43262124d..ce46a17ae 100644
--- a/shared/read-models.ts
+++ b/shared/read-models.ts
@@ -94,6 +94,12 @@ export const RestoreStaleRevisionResponseSchema = z.object({
currentRevision: z.number().int().nonnegative(),
})
+export const FreshAgentStaleRevisionResponseSchema = z.object({
+ error: z.string().min(1),
+ code: z.literal('STALE_THREAD_REVISION'),
+ currentRevision: z.number().int().nonnegative(),
+})
+
export const TerminalScrollbackQuerySchema = z.object({
cursor: z.string().min(1).optional(),
limit: z.number().int().positive().max(200).optional(),
@@ -114,5 +120,6 @@ export type TerminalDirectoryQuery = z.infer
export type AgentTimelineTurnBodyQuery = z.infer
export type RestoreStaleRevisionResponse = z.infer
+export type FreshAgentStaleRevisionResponse = z.infer
export type TerminalScrollbackQuery = z.infer
export type TerminalSearchQuery = z.infer
diff --git a/shared/session-contract.ts b/shared/session-contract.ts
index 6f2d42061..28d01f3a9 100644
--- a/shared/session-contract.ts
+++ b/shared/session-contract.ts
@@ -143,7 +143,9 @@ export function migrateLegacyAgentChatDurableState({
const canonicalClaudeSessionId = isCanonicalClaudeSessionId(cliSessionId)
? cliSessionId
- : (isCanonicalClaudeSessionId(timelineSessionId) ? timelineSessionId : undefined)
+ : (isCanonicalClaudeSessionId(timelineSessionId)
+ ? timelineSessionId
+ : (isCanonicalClaudeSessionId(resumeSessionId) ? resumeSessionId : undefined))
if (canonicalClaudeSessionId) {
return {
diff --git a/shared/settings.ts b/shared/settings.ts
index 71ce9532d..aef0d558d 100644
--- a/shared/settings.ts
+++ b/shared/settings.ts
@@ -143,6 +143,11 @@ export type ServerSettings = {
externalEditor: ExternalEditor
customEditorCommand?: string
}
+ freshAgent: {
+ initialSetupDone?: boolean
+ defaultPlugins: string[]
+ providers: Partial>
+ }
agentChat: {
initialSetupDone?: boolean
defaultPlugins: string[]
@@ -190,6 +195,11 @@ export type LocalSettings = {
width: number
collapsed: boolean
}
+ freshAgent: {
+ showThinking: boolean
+ showTools: boolean
+ showTimecodes: boolean
+ }
agentChat: {
showThinking: boolean
showTools: boolean
@@ -216,6 +226,7 @@ export type ResolvedSettings = {
codingCli: ServerSettings['codingCli']
panes: ServerSettings['panes'] & LocalSettings['panes']
editor: ServerSettings['editor']
+ freshAgent: ServerSettings['freshAgent'] & LocalSettings['freshAgent']
agentChat: ServerSettings['agentChat'] & LocalSettings['agentChat']
extensions: ServerSettings['extensions']
network: ServerSettings['network']
@@ -594,6 +605,11 @@ export function buildServerSettingsSchema(validCliProviders?: readonly string[])
externalEditor: ExternalEditorSchema,
customEditorCommand: z.string().optional(),
}).strict(),
+ freshAgent: z.object({
+ initialSetupDone: z.boolean().optional(),
+ defaultPlugins: z.array(z.string()),
+ providers: z.record(z.string(), createAgentChatProviderDefaultsPatchSchema()),
+ }).strict(),
agentChat: z.object({
initialSetupDone: z.boolean().optional(),
defaultPlugins: z.array(z.string()),
@@ -638,6 +654,11 @@ export function buildServerSettingsPatchSchema(validCliProviders?: readonly stri
externalEditor: ExternalEditorSchema.optional(),
customEditorCommand: z.string().optional(),
}).strict().optional(),
+ freshAgent: z.object({
+ initialSetupDone: z.boolean().optional(),
+ defaultPlugins: z.array(z.string()).optional(),
+ providers: z.record(z.string(), createAgentChatProviderDefaultsPatchSchema()).optional(),
+ }).strict().optional(),
agentChat: z.object({
initialSetupDone: z.boolean().optional(),
defaultPlugins: z.array(z.string()).optional(),
@@ -690,6 +711,10 @@ export function createDefaultServerSettings(options: SettingsDefaultsOptions = {
editor: {
externalEditor: 'auto',
},
+ freshAgent: {
+ defaultPlugins: [],
+ providers: {},
+ },
agentChat: {
defaultPlugins: [],
providers: {},
@@ -735,6 +760,11 @@ export const defaultLocalSettings: LocalSettings = {
width: 288,
collapsed: false,
},
+ freshAgent: {
+ showThinking: false,
+ showTools: false,
+ showTimecodes: false,
+ },
agentChat: {
showThinking: false,
showTools: false,
@@ -899,17 +929,23 @@ function sanitizeServerSettingsPatch(patch: ServerSettingsPatch): ServerSettings
}
}
- if (isRecord(candidate.agentChat)) {
- const agentChat: ServerSettingsPatch['agentChat'] = {}
- if (hasOwn(candidate.agentChat, 'initialSetupDone') && typeof candidate.agentChat.initialSetupDone === 'boolean') {
- agentChat.initialSetupDone = candidate.agentChat.initialSetupDone
+ const rawFreshAgent = isRecord(candidate.freshAgent)
+ ? candidate.freshAgent
+ : isRecord(candidate.agentChat)
+ ? candidate.agentChat
+ : null
+
+ if (rawFreshAgent) {
+ const freshAgent: ServerSettingsPatch['freshAgent'] = {}
+ if (hasOwn(rawFreshAgent, 'initialSetupDone') && typeof rawFreshAgent.initialSetupDone === 'boolean') {
+ freshAgent.initialSetupDone = rawFreshAgent.initialSetupDone
}
- if (hasOwn(candidate.agentChat, 'defaultPlugins') && Array.isArray(candidate.agentChat.defaultPlugins)) {
- agentChat.defaultPlugins = sanitizeAgentChatPluginPaths(candidate.agentChat.defaultPlugins)
+ if (hasOwn(rawFreshAgent, 'defaultPlugins') && Array.isArray(rawFreshAgent.defaultPlugins)) {
+ freshAgent.defaultPlugins = sanitizeAgentChatPluginPaths(rawFreshAgent.defaultPlugins)
}
- if (isRecord(candidate.agentChat.providers)) {
- const providers: NonNullable['providers'] = {}
- for (const [providerName, providerPatch] of Object.entries(candidate.agentChat.providers)) {
+ if (isRecord(rawFreshAgent.providers)) {
+ const providers: NonNullable['providers'] = {}
+ for (const [providerName, providerPatch] of Object.entries(rawFreshAgent.providers)) {
const normalizedProviderPatchInput = normalizeLegacyAgentChatProviderDefaultsInput(providerPatch)
const parsed = agentChatProviderDefaultsPatchSchema.safeParse(
normalizedProviderPatchInput,
@@ -936,11 +972,12 @@ function sanitizeServerSettingsPatch(patch: ServerSettingsPatch): ServerSettings
}
}
if (Object.keys(providers).length > 0) {
- agentChat.providers = providers
+ freshAgent.providers = providers
}
}
- if (Object.keys(agentChat).length > 0) {
- sanitized.agentChat = agentChat
+ if (Object.keys(freshAgent).length > 0) {
+ sanitized.freshAgent = freshAgent
+ sanitized.agentChat = freshAgent
}
}
@@ -1012,9 +1049,9 @@ function normalizeLegacyAgentChatProviderDefaultsInput(
export function mergeServerSettings(base: ServerSettings, patch: ServerSettingsPatch): ServerSettings {
const normalizedPatch = sanitizeServerSettingsPatch(patch)
const codingCliPatch = normalizedPatch.codingCli
- const agentChatPatch = normalizedPatch.agentChat
- const normalizedAgentChatPatch = agentChatPatch as Partial | undefined
- const normalizedAgentChatProvidersPatch = agentChatPatch?.providers as Partial> | undefined
+ const freshAgentPatch = (normalizedPatch.freshAgent ?? normalizedPatch.agentChat) as
+ | Partial
+ | undefined
return {
...base,
@@ -1045,12 +1082,19 @@ export function mergeServerSettings(base: ServerSettings, patch: ServerSettingsP
providers: mergeRecordOfObjects(base.codingCli.providers, codingCliPatch?.providers),
},
editor: mergeDefined(base.editor, normalizedPatch.editor),
+ freshAgent: {
+ ...mergeDefined(base.freshAgent, freshAgentPatch),
+ defaultPlugins: hasOwn(freshAgentPatch, 'defaultPlugins')
+ ? sanitizeAgentChatPluginPaths(freshAgentPatch?.defaultPlugins)
+ : base.freshAgent.defaultPlugins,
+ providers: mergeRecordOfObjects(base.freshAgent.providers, freshAgentPatch?.providers),
+ },
agentChat: {
- ...mergeDefined(base.agentChat, normalizedAgentChatPatch),
- defaultPlugins: hasOwn(normalizedAgentChatPatch, 'defaultPlugins')
- ? sanitizeAgentChatPluginPaths(normalizedAgentChatPatch?.defaultPlugins)
+ ...mergeDefined(base.agentChat, freshAgentPatch),
+ defaultPlugins: hasOwn(freshAgentPatch, 'defaultPlugins')
+ ? sanitizeAgentChatPluginPaths(freshAgentPatch?.defaultPlugins)
: base.agentChat.defaultPlugins,
- providers: mergeRecordOfObjects(base.agentChat.providers, normalizedAgentChatProvidersPatch),
+ providers: mergeRecordOfObjects(base.agentChat.providers, freshAgentPatch?.providers),
},
extensions: {
disabled: hasOwn(normalizedPatch.extensions, 'disabled')
@@ -1062,6 +1106,7 @@ export function mergeServerSettings(base: ServerSettings, patch: ServerSettingsP
}
export function resolveLocalSettings(patch?: LocalSettingsPatch): LocalSettings {
+ const freshAgentPatch = patch?.freshAgent ?? patch?.agentChat
return {
...defaultLocalSettings,
...(hasOwn(patch, 'theme') ? { theme: patch?.theme ?? defaultLocalSettings.theme } : {}),
@@ -1073,7 +1118,8 @@ export function resolveLocalSettings(patch?: LocalSettingsPatch): LocalSettings
sortMode: normalizeLocalSortMode(patch?.sidebar?.sortMode),
worktreeGrouping: normalizeWorktreeGrouping(patch?.sidebar?.worktreeGrouping),
},
- agentChat: mergeDefined(defaultLocalSettings.agentChat, patch?.agentChat),
+ freshAgent: mergeDefined(defaultLocalSettings.freshAgent, freshAgentPatch),
+ agentChat: mergeDefined(defaultLocalSettings.agentChat, freshAgentPatch),
notifications: mergeDefined(defaultLocalSettings.notifications, patch?.notifications),
}
}
@@ -1114,12 +1160,13 @@ export function mergeLocalSettings(base: LocalSettingsPatch | undefined, patch:
next.sidebar = sidebar as LocalSettingsPatch['sidebar']
}
- const agentChat = mergeDefined(
- (base?.agentChat || {}) as Record,
- patch.agentChat as Record | undefined,
+ const freshAgent = mergeDefined(
+ (base?.freshAgent || base?.agentChat || {}) as Record,
+ (patch.freshAgent || patch.agentChat) as Record | undefined,
)
- if (Object.keys(agentChat).length > 0) {
- next.agentChat = agentChat as LocalSettingsPatch['agentChat']
+ if (Object.keys(freshAgent).length > 0) {
+ next.freshAgent = freshAgent as LocalSettingsPatch['freshAgent']
+ next.agentChat = freshAgent as LocalSettingsPatch['agentChat']
}
const notifications = mergeDefined(
@@ -1162,6 +1209,12 @@ export function composeResolvedSettings(server: ServerSettings, local: LocalSett
...local.panes,
},
editor: { ...server.editor },
+ freshAgent: {
+ ...server.freshAgent,
+ defaultPlugins: [...server.freshAgent.defaultPlugins],
+ providers: mergeRecordOfObjects(server.freshAgent.providers),
+ ...local.freshAgent,
+ },
agentChat: {
...server.agentChat,
defaultPlugins: [...server.agentChat.defaultPlugins],
@@ -1202,8 +1255,15 @@ export function extractLegacyLocalSettingsSeed(
}
maybeAssignNested(patch, 'sidebar', sidebarPatch)
}
- if (isRecord(raw.agentChat)) {
- maybeAssignNested(patch, 'agentChat', pickKeys(raw.agentChat, AGENT_CHAT_LOCAL_KEYS))
+ const rawFreshAgentLocal = isRecord(raw.freshAgent)
+ ? raw.freshAgent
+ : isRecord(raw.agentChat)
+ ? raw.agentChat
+ : undefined
+ if (rawFreshAgentLocal) {
+ const freshAgentPatch = pickKeys(rawFreshAgentLocal, AGENT_CHAT_LOCAL_KEYS)
+ maybeAssignNested(patch, 'freshAgent', freshAgentPatch)
+ maybeAssignNested(patch, 'agentChat', freshAgentPatch)
}
if (isRecord(raw.notifications)) {
maybeAssignNested(patch, 'notifications', pickKeys(raw.notifications, ['soundEnabled']))
@@ -1248,11 +1308,18 @@ export function stripLocalSettings(
}
}
- if (isRecord(raw.agentChat)) {
- const strippedAgentChat = omitKeys(raw.agentChat, AGENT_CHAT_LOCAL_KEYS)
- if (Object.keys(strippedAgentChat).length > 0) {
- next.agentChat = strippedAgentChat
+ const rawFreshAgent = isRecord(raw.freshAgent)
+ ? raw.freshAgent
+ : isRecord(raw.agentChat)
+ ? raw.agentChat
+ : undefined
+ if (rawFreshAgent) {
+ const strippedFreshAgent = omitKeys(rawFreshAgent, AGENT_CHAT_LOCAL_KEYS)
+ if (Object.keys(strippedFreshAgent).length > 0) {
+ next.freshAgent = strippedFreshAgent
+ next.agentChat = strippedFreshAgent
} else {
+ delete next.freshAgent
delete next.agentChat
}
}
diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts
index bff6630f3..ed2811b07 100644
--- a/shared/ws-protocol.ts
+++ b/shared/ws-protocol.ts
@@ -395,7 +395,91 @@ export const SdkQuestionRespondSchema = z.object({
answers: z.record(z.string(), z.string()),
})
+export const FreshAgentCreateSchema = z.object({
+ type: z.literal('freshAgent.create'),
+ requestId: z.string().min(1),
+ sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']),
+ provider: z.enum(['claude', 'codex', 'opencode']).optional(),
+ cwd: z.string().optional(),
+ resumeSessionId: z.string().optional(),
+ model: z.string().optional(),
+ permissionMode: z.string().optional(),
+ sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(),
+ sessionRef: z.object({ provider: z.string().min(1), sessionId: z.string().min(1) }).optional(),
+ modelSelection: z.object({ kind: z.string().min(1), modelId: z.string().min(1) }).optional().or(z.null()),
+ effort: z.string().trim().min(1).optional(),
+ plugins: z.array(z.string()).optional(),
+})
+
+export const FreshAgentAttachSchema = z.object({
+ type: z.literal('freshAgent.attach'),
+ sessionId: z.string().min(1),
+ sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']),
+ provider: z.enum(['claude', 'codex', 'opencode']),
+ resumeSessionId: z.string().optional(),
+})
+
+export const FreshAgentSendSchema = z.object({
+ type: z.literal('freshAgent.send'),
+ sessionId: z.string().min(1),
+ sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']),
+ provider: z.enum(['claude', 'codex', 'opencode']),
+ text: z.string().min(1),
+ images: z.array(z.object({
+ mediaType: z.string(),
+ data: z.string(),
+ })).optional(),
+})
+
+export const FreshAgentInterruptSchema = z.object({
+ type: z.literal('freshAgent.interrupt'),
+ sessionId: z.string().min(1),
+ sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']),
+ provider: z.enum(['claude', 'codex', 'opencode']),
+})
+
+export const FreshAgentApprovalRespondSchema = z.object({
+ type: z.literal('freshAgent.approval.respond'),
+ sessionId: z.string().min(1),
+ sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']),
+ provider: z.enum(['claude', 'codex', 'opencode']),
+ requestId: z.union([z.string().min(1), z.number().int()]),
+ decision: z.record(z.string(), z.unknown()),
+})
+
+export const FreshAgentQuestionRespondSchema = z.object({
+ type: z.literal('freshAgent.question.respond'),
+ sessionId: z.string().min(1),
+ sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']),
+ provider: z.enum(['claude', 'codex', 'opencode']),
+ requestId: z.union([z.string().min(1), z.number().int()]),
+ answers: z.record(z.string(), z.string()),
+})
+
+export const FreshAgentKillSchema = z.object({
+ type: z.literal('freshAgent.kill'),
+ sessionId: z.string().min(1),
+ sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']),
+ provider: z.enum(['claude', 'codex', 'opencode']),
+})
+
+export const FreshAgentForkSchema = z.object({
+ type: z.literal('freshAgent.fork'),
+ sessionId: z.string().min(1),
+ sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']),
+ provider: z.enum(['claude', 'codex', 'opencode']),
+ input: z.record(z.string(), z.unknown()).optional(),
+})
+
export const BrowserSdkMessageSchema = z.discriminatedUnion('type', [
+ FreshAgentCreateSchema,
+ FreshAgentAttachSchema,
+ FreshAgentSendSchema,
+ FreshAgentInterruptSchema,
+ FreshAgentApprovalRespondSchema,
+ FreshAgentQuestionRespondSchema,
+ FreshAgentKillSchema,
+ FreshAgentForkSchema,
SdkCreateSchema,
SdkSendSchema,
SdkPermissionRespondSchema,
@@ -428,6 +512,14 @@ export const ClientMessageSchema = z.discriminatedUnion('type', [
CodingCliCreateSchema,
CodingCliInputSchema,
CodingCliKillSchema,
+ FreshAgentCreateSchema,
+ FreshAgentAttachSchema,
+ FreshAgentSendSchema,
+ FreshAgentInterruptSchema,
+ FreshAgentApprovalRespondSchema,
+ FreshAgentQuestionRespondSchema,
+ FreshAgentKillSchema,
+ FreshAgentForkSchema,
SdkCreateSchema,
SdkSendSchema,
SdkPermissionRespondSchema,
@@ -677,6 +769,12 @@ export type SdkRestoreFailureCode =
| 'RESTORE_DIVERGED'
| 'RESTORE_STALE_REVISION'
+export type FreshAgentServerMessage =
+ | { type: 'freshAgent.created'; requestId: string; sessionId: string; sessionType: string; provider: string; runtimeProvider: string; sessionRef?: { provider: string; sessionId: string } }
+ | { type: 'freshAgent.create.failed'; requestId: string; code: string; message: string; retryable?: boolean }
+ | { type: 'freshAgent.event'; sessionId: string; sessionType: string; provider: string; event: unknown }
+ | { type: 'freshAgent.killed'; sessionId: string; sessionType: string; provider: string; success: boolean }
+
export type SdkServerMessage =
| { type: 'sdk.created'; requestId: string; sessionId: string }
| {
@@ -792,6 +890,7 @@ export type ServerMessage =
| CodingCliExitMessage
| CodingCliStderrMessage
| CodingCliKilledMessage
+ | FreshAgentServerMessage
| SdkServerMessage
| ExtensionRegistryMessage
| ExtensionServerStartingMessage
diff --git a/src/App.tsx b/src/App.tsx
index 379406fc3..9f5c67c23 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -61,6 +61,7 @@ import { setCodexActivitySnapshot, upsertCodexActivity, removeCodexActivity, res
import { setOpencodeActivitySnapshot, upsertOpencodeActivity, removeOpencodeActivity, resetOpencodeActivity } from '@/store/opencodeActivitySlice'
import { setRegistry, updateServerStatus } from '@/store/extensionsSlice'
import { handleSdkMessage } from '@/lib/sdk-message-handler'
+import { handleFreshAgentMessage } from '@/lib/fresh-agent-ws'
import { createLogger } from '@/lib/client-logger'
import type { LocalSettingsPatch, ServerSettings } from '@shared/settings'
import { z } from 'zod'
@@ -195,6 +196,7 @@ export default function App() {
() => { (ws as any).ws?.close() },
// sendWsMessage: send a raw WS message for test cleanup (e.g., terminal.kill)
(msg: unknown) => { ws.send(msg) },
+ (msg) => { ws.receiveMessageForTest?.(msg) },
() => perfAuditBridgeRef.current?.snapshot() ?? null,
)
ws.setOutboundMessageObserver?.((msg) => {
@@ -917,7 +919,8 @@ export default function App() {
dispatch(updateServerStatus({ name: msg.name, serverRunning: false, serverPort: undefined }))
}
- // SDK message handling (freshclaude pane)
+ handleFreshAgentMessage(dispatch, msg as Record, ws)
+ // SDK message handling (freshclaude compatibility surface)
handleSdkMessage(dispatch, msg as Record, ws)
})
diff --git a/src/components/MobileTabStrip.tsx b/src/components/MobileTabStrip.tsx
index 927266d44..a2b5af0eb 100644
--- a/src/components/MobileTabStrip.tsx
+++ b/src/components/MobileTabStrip.tsx
@@ -5,11 +5,13 @@ import { getTabDisplayTitle } from '@/lib/tab-title'
import { getBusyPaneIdsForTab } from '@/lib/pane-activity'
import { triggerHapticFeedback } from '@/lib/mobile-haptics'
import type { ChatSessionState } from '@/store/agentChatTypes'
+import type { FreshAgentSessionState } from '@/store/freshAgentTypes'
import type { PaneRuntimeActivityRecord } from '@/store/paneRuntimeActivitySlice'
const EMPTY_CODEX_ACTIVITY_BY_ID = {}
const EMPTY_OPENCODE_ACTIVITY_BY_ID = {}
const EMPTY_AGENT_CHAT_SESSIONS: Record = {}
+const EMPTY_FRESH_AGENT_SESSIONS: Record = {}
const EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID: Record = {}
interface MobileTabStripProps {
@@ -27,6 +29,7 @@ export function MobileTabStrip({ onOpenSwitcher, sidebarCollapsed, onToggleSideb
const codexActivityByTerminalId = useAppSelector((s) => s.codexActivity?.byTerminalId ?? EMPTY_CODEX_ACTIVITY_BY_ID)
const opencodeActivityByTerminalId = useAppSelector((s) => s.opencodeActivity?.byTerminalId ?? EMPTY_OPENCODE_ACTIVITY_BY_ID)
const agentChatSessions = useAppSelector((s) => s.agentChat?.sessions ?? EMPTY_AGENT_CHAT_SESSIONS)
+ const freshAgentSessions = useAppSelector((s) => s.freshAgent?.sessions ?? EMPTY_FRESH_AGENT_SESSIONS)
const paneRuntimeActivityByPaneId = useAppSelector(
(s) => s.paneRuntimeActivity?.byPaneId ?? EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID
)
@@ -46,6 +49,7 @@ export function MobileTabStrip({ onOpenSwitcher, sidebarCollapsed, onToggleSideb
opencodeActivityByTerminalId,
paneRuntimeActivityByPaneId,
agentChatSessions,
+ freshAgentSessions,
}).length > 0
: false
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
index 4a9bb6dd3..6e38da2f5 100644
--- a/src/components/Sidebar.tsx
+++ b/src/components/Sidebar.tsx
@@ -21,6 +21,7 @@ import { mergeSessionMetadataByKey } from '@/lib/session-metadata'
import { collectBusySessionKeys } from '@/lib/pane-activity'
import { selectPrimaryTerminalIdForTab } from '@/store/selectors/paneTerminalSelectors'
import type { ChatSessionState } from '@/store/agentChatTypes'
+import type { FreshAgentSessionState } from '@/store/freshAgentTypes'
import type { PaneRuntimeActivityRecord } from '@/store/paneRuntimeActivitySlice'
const EMPTY_TERMINALS: BackgroundTerminal[] = []
@@ -28,6 +29,7 @@ const EMPTY_LAYOUTS: Record = {}
const EMPTY_CODEX_ACTIVITY_BY_ID = {}
const EMPTY_OPENCODE_ACTIVITY_BY_ID = {}
const EMPTY_AGENT_CHAT_SESSIONS: Record = {}
+const EMPTY_FRESH_AGENT_SESSIONS: Record = {}
const EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID: Record = {}
function sameSessionRef(
@@ -321,6 +323,7 @@ export default function Sidebar({
opencodeActivityByTerminalId: state.opencodeActivity?.byTerminalId ?? EMPTY_OPENCODE_ACTIVITY_BY_ID,
paneRuntimeActivityByPaneId: state.paneRuntimeActivity?.byPaneId ?? EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID,
agentChatSessions: state.agentChat?.sessions ?? EMPTY_AGENT_CHAT_SESSIONS,
+ freshAgentSessions: state.freshAgent?.sessions ?? EMPTY_FRESH_AGENT_SESSIONS,
}), shallowEqual)
const busySessionKeySet = useMemo(() => new Set(busySessionKeys), [busySessionKeys])
diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx
index 2d170f67b..dce5eb79b 100644
--- a/src/components/TabBar.tsx
+++ b/src/components/TabBar.tsx
@@ -36,6 +36,7 @@ import { CSS } from '@dnd-kit/utilities'
import type { Tab, TabAttentionStyle } from '@/store/types'
import type { PaneContent, PaneNode } from '@/store/paneTypes'
import type { ChatSessionState } from '@/store/agentChatTypes'
+import type { FreshAgentSessionState } from '@/store/freshAgentTypes'
import type { PaneRuntimeActivityRecord } from '@/store/paneRuntimeActivitySlice'
import { ContextIds } from '@/components/context-menu/context-menu-constants'
import { applyTabRename } from '@/store/titleSync'
@@ -132,6 +133,7 @@ const EMPTY_ATTENTION: Record = {}
const EMPTY_CODEX_ACTIVITY_BY_ID = {}
const EMPTY_OPENCODE_ACTIVITY_BY_ID = {}
const EMPTY_AGENT_CHAT_SESSIONS: Record = {}
+const EMPTY_FRESH_AGENT_SESSIONS: Record = {}
const EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID: Record = {}
interface TabBarProps {
@@ -154,6 +156,7 @@ export default function TabBar({ sidebarCollapsed, onToggleSidebar }: TabBarProp
const codexActivityByTerminalId = useAppSelector((s) => s.codexActivity?.byTerminalId ?? EMPTY_CODEX_ACTIVITY_BY_ID)
const opencodeActivityByTerminalId = useAppSelector((s) => s.opencodeActivity?.byTerminalId ?? EMPTY_OPENCODE_ACTIVITY_BY_ID)
const agentChatSessions = useAppSelector((s) => s.agentChat?.sessions ?? EMPTY_AGENT_CHAT_SESSIONS)
+ const freshAgentSessions = useAppSelector((s) => s.freshAgent?.sessions ?? EMPTY_FRESH_AGENT_SESSIONS)
const paneRuntimeActivityByPaneId = useAppSelector(
(s) => s.paneRuntimeActivity?.byPaneId ?? EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID
)
@@ -213,7 +216,8 @@ export default function TabBar({ sidebarCollapsed, onToggleSidebar }: TabBarProp
opencodeActivityByTerminalId,
paneRuntimeActivityByPaneId,
agentChatSessions,
- }), [agentChatSessions, codexActivityByTerminalId, opencodeActivityByTerminalId, paneLayouts, paneRuntimeActivityByPaneId])
+ freshAgentSessions,
+ }), [agentChatSessions, codexActivityByTerminalId, freshAgentSessions, opencodeActivityByTerminalId, paneLayouts, paneRuntimeActivityByPaneId])
const [renamingId, setRenamingId] = useState(null)
const [renameValue, setRenameValue] = useState('')
diff --git a/src/components/TabSwitcher.tsx b/src/components/TabSwitcher.tsx
index 86f0e7e69..cc2a79ecd 100644
--- a/src/components/TabSwitcher.tsx
+++ b/src/components/TabSwitcher.tsx
@@ -7,11 +7,13 @@ import { useCallback, useMemo } from 'react'
import type { Tab, TerminalStatus } from '@/store/types'
import { triggerHapticFeedback } from '@/lib/mobile-haptics'
import type { ChatSessionState } from '@/store/agentChatTypes'
+import type { FreshAgentSessionState } from '@/store/freshAgentTypes'
import type { PaneRuntimeActivityRecord } from '@/store/paneRuntimeActivitySlice'
const EMPTY_CODEX_ACTIVITY_BY_ID = {}
const EMPTY_OPENCODE_ACTIVITY_BY_ID = {}
const EMPTY_AGENT_CHAT_SESSIONS: Record = {}
+const EMPTY_FRESH_AGENT_SESSIONS: Record = {}
const EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID: Record = {}
interface TabSwitcherProps {
@@ -48,6 +50,7 @@ export function TabSwitcher({ onClose }: TabSwitcherProps) {
const codexActivityByTerminalId = useAppSelector((s) => s.codexActivity?.byTerminalId ?? EMPTY_CODEX_ACTIVITY_BY_ID)
const opencodeActivityByTerminalId = useAppSelector((s) => s.opencodeActivity?.byTerminalId ?? EMPTY_OPENCODE_ACTIVITY_BY_ID)
const agentChatSessions = useAppSelector((s) => s.agentChat?.sessions ?? EMPTY_AGENT_CHAT_SESSIONS)
+ const freshAgentSessions = useAppSelector((s) => s.freshAgent?.sessions ?? EMPTY_FRESH_AGENT_SESSIONS)
const paneRuntimeActivityByPaneId = useAppSelector(
(s) => s.paneRuntimeActivity?.byPaneId ?? EMPTY_PANE_RUNTIME_ACTIVITY_BY_ID
)
@@ -107,6 +110,7 @@ export function TabSwitcher({ onClose }: TabSwitcherProps) {
opencodeActivityByTerminalId,
paneRuntimeActivityByPaneId,
agentChatSessions,
+ freshAgentSessions,
}).length > 0
return (