Model: Provider default tracks the latest Opus release.
+
Effort: Only live capability-supported levels appear for the selected model.
+
Pinned legacy model IDs stay visible as unavailable until you change them.
+
+
+
+
diff --git a/docs/plans/2026-04-18-freshclaude-capability-driven-model-controls-test-plan.md b/docs/plans/2026-04-18-freshclaude-capability-driven-model-controls-test-plan.md
new file mode 100644
index 00000000..4d31cb31
--- /dev/null
+++ b/docs/plans/2026-04-18-freshclaude-capability-driven-model-controls-test-plan.md
@@ -0,0 +1,211 @@
+# Freshclaude Capability-Driven Model Controls Test Plan
+
+Reconciliation result: the approved testing strategy still holds. The implementation plan and current worktree do not add paid services, external infrastructure, or a larger user-facing surface than the user already approved. The main adjustment is prioritization, not scope: the capability registry, HTTP route, client capability state, persistence migration path, and browser/jsdom harnesses already exist here, so the highest-value red checks are now the unresolved effort-provenance cleanup cases and the contract that tracked saved selections remain visible instead of being silently healed.
+
+## Source-of-Truth Legend
+
+- `SoT1`: the user request and approved trycycle strategy in the transcript. Freshclaude must stop hardcoding stale model and thinking options, pick up newer Anthropic improvements automatically, and avoid silent fallback or silent migration behavior.
+- `SoT2`: the implementation plan at `docs/plans/2026-04-18-freshclaude-capability-driven-model-controls.md`, especially `User-Visible Behavior`, `Contracts And Invariants`, and Task 5/6. This is the authoritative contract for provider-default `opus`, tracked vs exact selections, dynamic effort strings, stale-capability refresh, saved-selection rows, unavailable exact rows, and provenance-sensitive effort cleanup.
+- `SoT3`: the concrete shared and server contracts already present in this worktree: `shared/agent-chat-capabilities.ts`, `shared/settings.ts`, `shared/ws-protocol.ts`, `server/agent-chat-capability-registry.ts`, `server/agent-chat-capabilities-router.ts`, `server/settings-router.ts`, `server/config-store.ts`, and `server/ws-handler.ts`.
+- `SoT4`: the current user-facing entry points and persistence surfaces already wired in this worktree: `src/components/agent-chat/AgentChatView.tsx`, `src/components/agent-chat/AgentChatSettings.tsx`, `src/components/panes/PaneContainer.tsx`, `src/components/TabsView.tsx`, `src/store/persistMiddleware.ts`, `src/store/persistedState.ts`, `src/lib/tab-registry-snapshot.ts`, `/api/settings`, `/api/agent-chat/capabilities/:provider`, and the pane picker flow used in Playwright.
+- `SoT5`: the installed Claude SDK `supportedModels()` shape described in the approved strategy. This is a differential reference for a conditional non-CI contract probe only, not the primary acceptance gate.
+
+## Harness Requirements
+
+No new harnesses need to be built. The existing harnesses already cover the required proof surfaces:
+
+1. **Direct API harness**
+ What it does: exercises the real Express routers, shared schemas, config-store persistence, registry normalization, and websocket input validation.
+ Exposes: `supertest`, isolated temp config directories, and real schema parsing.
+ Estimated complexity: none beyond extending existing tests.
+ Tests depending on it: 9, 10, 11.
+
+2. **Programmatic state and jsdom interaction harness**
+ What it does: mounts `AgentChatView`, `AgentChatSettings`, `PaneContainer`, and `TabsView` against the real Redux store so tests can inspect pane state, persisted settings intent, and sent websocket payloads without mocking the whole app.
+ Exposes: `window.__FRESHELL_TEST_HARNESS__` in browser tests, store dispatch/state inspection in jsdom, mocked `ws.send`, and mocked API routes.
+ Estimated complexity: none beyond extending existing tests.
+ Tests depending on it: 2, 3, 12, 13, 14, 16.
+
+3. **Browser interaction and artifact harness**
+ What it does: runs the real UI through Playwright with route interception, sent-websocket capture, on-disk `config.json` inspection via `TestServerInfo.homeDir`, and screenshot assertions.
+ Exposes: pane-picker interactions, reload and multi-page flows, sent `sdk.create` payload capture, capability-route interception, and screenshot comparison.
+ Estimated complexity: none beyond extending existing tests.
+ Tests depending on it: 1, 4, 5, 6, 7, 8, 15.
+
+## Test Plan
+
+1. **Name:** Unsupported provider-default effort is cleared from persisted Freshclaude settings after create-time validation
+ **Type:** scenario
+ **Disposition:** extend `test/e2e-browser/specs/settings-persistence-split.spec.ts`
+ **Harness:** browser interaction + programmatic state + output capture
+ **Preconditions:** Seed server settings with `agentChat.providers.freshclaude.effort = 'turbo'` and no `modelSelection`. Intercept `GET /api/agent-chat/capabilities/freshclaude` so provider-default `opus` no longer supports effort. Enable Freshclaude in the pane picker.
+ **Actions:** Create a Freshclaude pane from the picker, allow the automatic `sdk.create`, inspect the sent websocket payload, inspect resolved settings and on-disk `config.json`, reload, and create a second Freshclaude pane.
+ **Expected outcome:** The first create sends `model: 'opus'` and omits `effort`. The stale saved effort is cleared from resolved settings and from `config.json`, and the reloaded second pane also creates with `model: 'opus'` and no `effort`. Freshell does not silently substitute a different model or keep replaying the invalid effort on later panes. Sources: `SoT1`, `SoT2`, `SoT3`, `SoT4`.
+ **Interactions:** pane picker, directory confirmation, capability GET, settings persistence, config-store write path, websocket `sdk.create`.
+
+2. **Name:** Create-time cleanup drops an unsupported pane-local effort snapshot without rewriting provider defaults
+ **Type:** regression
+ **Disposition:** extend `test/unit/client/components/agent-chat/AgentChatView.behavior.test.tsx`
+ **Harness:** programmatic state + jsdom interaction
+ **Preconditions:** Seed provider settings with a still-valid saved default effort for Freshclaude. Render a creating agent-chat pane whose restored `modelSelection` and `effort` no longer match current provider defaults and whose selected model no longer supports effort.
+ **Actions:** Mount `AgentChatView`, let the create path resolve and send `sdk.create`, then inspect pane state and saved-settings dispatches.
+ **Expected outcome:** Pane state is sanitized so the outgoing `sdk.create` omits `effort`, but no `saveServerSettingsPatch` is dispatched to clear the provider default because the stale effort belonged only to the restored pane snapshot. Sources: `SoT2`, `SoT3`, `SoT4`.
+ **Interactions:** create-time capability validation, pane-state mutation, settings save thunk, websocket `sdk.create`.
+
+3. **Name:** Passive cleanup of a restored pane snapshot clears only pane state when provider defaults still remain valid
+ **Type:** regression
+ **Disposition:** extend `test/unit/client/components/agent-chat/AgentChatView.behavior.test.tsx`
+ **Harness:** programmatic state + jsdom interaction
+ **Preconditions:** Seed provider settings with a valid saved effort override for the current provider defaults. Render a running or restored agent-chat pane whose local snapshot carries an unsupported effort for a different selected model.
+ **Actions:** Mount `AgentChatView` so the passive cleanup effect runs after capability resolution, then inspect pane state and saved-settings dispatches.
+ **Expected outcome:** The pane-local `effort` is cleared from pane state, but provider settings remain untouched and no persisted-clear request is sent. This proves Freshell distinguishes pane-local cleanup from global-default cleanup. Sources: `SoT2`, `SoT3`, `SoT4`.
+ **Interactions:** passive cleanup effect, capability resolution, pane merge/update flow, settings thunk.
+
+4. **Name:** A saved tracked model that drops out of the catalog stays visible as a synthetic saved-selection row and still launches as that tracked id
+ **Type:** scenario
+ **Disposition:** extend `test/e2e-browser/specs/settings-persistence-split.spec.ts`
+ **Harness:** browser interaction + programmatic state + output capture
+ **Preconditions:** Persist `agentChat.providers.freshclaude.modelSelection = { kind: 'tracked', modelId: 'haiku' }`. Intercept capabilities so the live catalog excludes `haiku` and contains only other live rows. Enable Freshclaude.
+ **Actions:** Reload, create a Freshclaude pane, open settings, inspect the selected model row, and inspect the sent `sdk.create` payload.
+ **Expected outcome:** Settings show `haiku (Saved selection)` with the explanatory saved-selection message. Freshell does not rewrite the saved tracked selection to provider-default or the nearest live row, and `sdk.create` still uses `model: 'haiku'` when no extra validation is required. Sources: `SoT1`, `SoT2`, `SoT3`, `SoT4`.
+ **Interactions:** settings rendering, pane creation, capability GET, websocket `sdk.create`, persisted provider settings.
+
+5. **Name:** Provider-default tracking and tracked live-model overrides persist across reload, and switching back to provider-default clears the override
+ **Type:** scenario
+ **Disposition:** existing `test/e2e-browser/specs/settings-persistence-split.spec.ts`
+ **Harness:** browser interaction + programmatic state + output capture
+ **Preconditions:** Intercept capabilities with provider-default `opus` and at least one tracked live option such as `opus[1m]`. Enable Freshclaude.
+ **Actions:** Create a Freshclaude pane, switch the model from provider-default to a tracked live option, reload, create a new pane, then switch back to `Provider default (track latest Opus)`, reload again, and create another pane.
+ **Expected outcome:** The tracked selection persists across reload and the corresponding `sdk.create` uses the tracked model id. Switching back to provider-default removes the saved override and future creates send `model: 'opus'` with no persisted tracked selection left behind. Sources: `SoT1`, `SoT2`, `SoT4`.
+ **Interactions:** model select, settings persistence, reload/rehydration, pane creation, websocket `sdk.create`.
+
+6. **Name:** A migrated legacy exact model stays visible, blocks create, and never silently migrates to a live alias
+ **Type:** scenario
+ **Disposition:** existing `test/e2e-browser/specs/settings-persistence-split.spec.ts` plus existing `test/e2e/agent-chat-capability-settings-flow.test.tsx`
+ **Harness:** browser interaction + programmatic state + output capture
+ **Preconditions:** Persist `modelSelection = { kind: 'exact', modelId: 'claude-opus-4-6' }`. Intercept capabilities so that exact id is absent from the live catalog.
+ **Actions:** Reload, create a Freshclaude pane, inspect the visible failure state, open settings, inspect the selected option, then switch to provider-default and retry.
+ **Expected outcome:** Freshell surfaces the unavailable exact row and explanatory message, blocks create while the unavailable exact model remains selected, sends no `sdk.create`, and only proceeds after the user explicitly chooses a launchable selection. Sources: `SoT1`, `SoT2`, `SoT3`, `SoT4`.
+ **Interactions:** create-time validation, settings UI, unavailable exact row rendering, retry flow, websocket `sdk.create`.
+
+7. **Name:** Opening settings refreshes stale capability cache and updates visible live options instead of trusting a session-lifetime snapshot
+ **Type:** scenario
+ **Disposition:** extend `test/e2e-browser/specs/agent-chat.spec.ts` and keep `test/e2e/agent-chat-capability-settings-flow.test.tsx`
+ **Harness:** browser interaction + output capture
+ **Preconditions:** Seed stale cached capabilities in client state or via fixture, then intercept the next `GET /api/agent-chat/capabilities/freshclaude` with a newer catalog that changes model rows and effort levels.
+ **Actions:** Open Freshclaude settings and wait for the refresh to complete, then inspect the visible model list, effort list, and screenshot artifact.
+ **Expected outcome:** Freshell revalidates stale capabilities when settings open, updates the visible model and effort options from the new catalog, and the settings surface screenshot reflects provider-default plus current live rows only. Sources: `SoT1`, `SoT2`, `SoT3`, `SoT4`.
+ **Interactions:** settings button, stale-capability freshness check, capability GET, screenshot baseline, settings model/effort rendering.
+
+8. **Name:** Capability fetch failures show an explicit retryable settings error, allow safe tracked creates, and block validation-dependent creates until retry succeeds
+ **Type:** scenario
+ **Disposition:** existing `test/e2e-browser/specs/settings.spec.ts` plus existing `test/e2e/agent-chat-capability-settings-flow.test.tsx`
+ **Harness:** browser interaction + programmatic state + output capture
+ **Preconditions:** Intercept initial capability fetch to fail with a typed error, and intercept refresh to succeed. Prepare one pane that can launch safely with a tracked model id and another that requires capability validation because it has an explicit effort or exact selection.
+ **Actions:** Open settings, inspect the alert and retry button, create the safe tracked pane, create the validation-dependent pane, retry the capability load, and retry the blocked create.
+ **Expected outcome:** The settings surface shows an explicit retryable error. Safe tracked creates continue to send the tracked `model` without waiting for live validation. Validation-dependent creates are blocked until a successful refresh provides the required capability data, after which retry succeeds with the validated payload. Sources: `SoT1`, `SoT2`, `SoT3`, `SoT4`.
+ **Interactions:** capability GET/refresh, error alert, retry button, create retry button, websocket `sdk.create`, capability-validation gate.
+
+9. **Name:** Shared settings and settings API round-trip tracked and exact selections, dynamic effort strings, clear sentinels, and legacy migration without coercion
+ **Type:** integration
+ **Disposition:** existing `test/unit/shared/settings.test.ts` plus existing `test/integration/server/settings-api.test.ts`
+ **Harness:** direct API harness
+ **Preconditions:** Real settings schema, settings router, and temp config-store directory.
+ **Actions:** Parse or patch representative tracked selections, exact selections, unfamiliar effort strings, `null` and empty-string clears, and legacy `defaultModel/defaultEffort` inputs.
+ **Expected outcome:** The shared schema accepts tracked and exact selections and non-empty dynamic effort strings, the API accepts explicit clears, clears stored selection and effort when requested, and migrates legacy `defaultModel/defaultEffort` into `exact` plus explicit `effort` without rewriting to `opus`. Sources: `SoT1`, `SoT2`, `SoT3`.
+ **Interactions:** shared Zod schemas, settings router normalization, config-store merge path, `/api/settings`.
+
+10. **Name:** Capability registry and capability router normalize runtime payloads, coalesce refreshes, honor TTL, and keep the last good catalog after failure
+ **Type:** integration
+ **Disposition:** existing `test/unit/server/agent-chat-capability-registry.test.ts` plus existing `test/integration/server/agent-chat-capabilities-router.test.ts`
+ **Harness:** direct API harness
+ **Preconditions:** Mocked SDK query factory and the real registry/router code.
+ **Actions:** Feed the registry mixed runtime payload shapes, concurrent refresh calls, stale-cache lookups, malformed payloads, timeout cases, and router GET/refresh requests.
+ **Expected outcome:** Runtime capability payloads normalize into the shared contract, one in-flight probe services concurrent refreshes, cached successes are reused within TTL, failed refreshes do not poison the last good catalog, malformed payloads yield typed errors, and the router returns typed success or failure payloads on the documented endpoints. Sources: `SoT2`, `SoT3`, `SoT5`.
+ **Interactions:** SDK probe query lifecycle, shared capability schema, registry cache, Express router, HTTP status mapping.
+
+11. **Name:** WebSocket and SDK bridge accept unfamiliar effort strings, stop broadcasting `sdk.models`, and avoid session-init capability discovery
+ **Type:** integration
+ **Disposition:** existing `test/unit/server/sdk-bridge.test.ts`, existing `test/unit/server/sdk-bridge-types.test.ts`, and existing `test/unit/server/ws-handler-sdk.test.ts`
+ **Harness:** direct API harness
+ **Preconditions:** Real websocket schema parsing and mocked SDK bridge/session plumbing.
+ **Actions:** Validate `sdk.create` with a non-enum effort string, route `sdk.create` and `sdk.set-model` through the websocket handler, and replay session-init flows.
+ **Expected outcome:** `sdk.create` accepts any non-empty effort string at the transport boundary, the handler forwards unfamiliar strings unchanged to the SDK bridge, `sdk.set-model` passes tracked ids through unchanged, and no code path emits `sdk.models` or calls `supportedModels()` during session init. Sources: `SoT1`, `SoT2`, `SoT3`.
+ **Interactions:** shared websocket schema, ws-handler ownership checks, sdk-bridge create/set-model, session-init replay.
+
+12. **Name:** Persisted layouts, pane payloads, and tab snapshots migrate legacy `model` and `effort` fields into selection strategies without losing reload or sync behavior
+ **Type:** integration
+ **Disposition:** extend `test/unit/client/store/persistedState.test.ts`, `test/unit/client/store/panesPersistence.test.ts`, `test/unit/client/lib/tab-registry-snapshot.test.ts`, `test/unit/client/components/TabsView.test.tsx`, and `test/unit/client/components/panes/PaneContainer.createContent.test.tsx`
+ **Harness:** programmatic state + jsdom interaction
+ **Preconditions:** Seed legacy persisted pane payloads and tab snapshots that still contain raw `model` and `effort` fields, plus current provider settings for new pane creation.
+ **Actions:** Load persisted panes, rehydrate tabs from snapshots, create a new Freshclaude pane from the pane container, and inspect normalized pane content after migration.
+ **Expected outcome:** Legacy `model` fields become exact selections, legacy non-empty `effort` strings survive as explicit overrides, current new-pane creation uses provider-default or saved strategy fields instead of stale dated ids, and reload or cross-device sync continues to hydrate the new strategy shape. Sources: `SoT2`, `SoT3`, `SoT4`.
+ **Interactions:** persisted layout parser, pane-tree validation, tab-registry snapshot serialization, TabsView rehydration, PaneContainer defaults.
+
+13. **Name:** The settings surface renders only provider-default, live capability rows, saved tracked rows, unavailable exact rows, and capability-derived effort controls on desktop and mobile
+ **Type:** scenario
+ **Disposition:** existing `test/unit/client/components/agent-chat/AgentChatSettings.test.tsx`, existing `test/unit/client/components/agent-chat/AgentChatSettings.mobile.test.tsx`, and existing `test/e2e/agent-chat-capability-settings-flow.test.tsx`
+ **Harness:** programmatic state + jsdom interaction + output capture
+ **Preconditions:** Capability states covering success, stale cache, failure, saved tracked selection missing from catalog, and unavailable exact selection.
+ **Actions:** Render the settings popover on desktop and mobile, open it, switch models between effort-supporting and non-effort-supporting capabilities, and trigger capability retry.
+ **Expected outcome:** The surface shows provider-default plus live rows only, adds a synthetic saved tracked row only when needed, adds an unavailable exact row only when needed, derives effort options exclusively from `supportedEffortLevels`, hides or replaces effort UI when the selected model has no effort support, and exposes accessible loading/error states and retry controls on both layouts. Sources: `SoT1`, `SoT2`, `SoT4`.
+ **Interactions:** AgentChatSettings rendering, AgentChatView on-open refresh, capability retry, responsive/mobile dialog layout.
+
+14. **Name:** Freshclaude runtime metadata and activity indicators remain correct while provider-default creates and tracked mid-session model changes occur
+ **Type:** regression
+ **Disposition:** existing `test/e2e/pane-header-runtime-meta-flow.test.tsx` and existing `test/e2e/pane-activity-indicator-flow.test.tsx`
+ **Harness:** programmatic state + jsdom interaction
+ **Preconditions:** Seed runtime metadata, capability catalogs, and active Freshclaude panes with visible header and activity indicators.
+ **Actions:** Let a provider-default Freshclaude pane create, then change the model mid-session to another tracked live model and inspect header/runtime metadata and tab activity styling.
+ **Expected outcome:** Provider-default create still launches with `model: 'opus'`, tracked mid-session changes send `sdk.set-model` with the tracked id, and header/runtime metadata plus activity indicator styling stay correct while settings persistence updates only the intended provider defaults. Sources: `SoT2`, `SoT3`, `SoT4`.
+ **Interactions:** AgentChatView, pane header/runtime metadata selectors, tab activity rendering, websocket `sdk.create`, websocket `sdk.set-model`.
+
+15. **Name:** The Playwright settings surface snapshot stays stable while showing capability-driven Freshclaude controls
+ **Type:** regression
+ **Disposition:** existing `test/e2e-browser/specs/agent-chat.spec.ts`
+ **Harness:** browser interaction + screenshot comparison
+ **Preconditions:** Intercept a representative capability catalog for Freshclaude and enable the provider in the picker.
+ **Actions:** Create a Freshclaude pane, open settings, and capture the existing screenshot baseline.
+ **Expected outcome:** The screenshot still shows the capability-driven surface with provider-default, live model rows, and dynamic effort rows, catching accidental UI regressions while the behavior-focused assertions cover the semantics. Sources: `SoT1`, `SoT2`, `SoT4`.
+ **Interactions:** pane picker, settings popover, screenshot baseline tooling.
+
+16. **Name:** A conditional live SDK probe still fits Freshell’s normalization and validation contract
+ **Type:** differential
+ **Disposition:** new non-CI probe adjacent to `test/unit/server/agent-chat-capability-registry.test.ts`
+ **Harness:** reference comparison harness
+ **Preconditions:** The installed Claude SDK is available in the test environment and the probe is marked opt-in so it does not block normal CI.
+ **Actions:** Invoke a short-lived live `supportedModels()` probe, pass the raw payload through the same normalization path the registry uses, and compare the result against Freshell’s shared capability schema and the helper assumptions used by the settings surface.
+ **Expected outcome:** The current live SDK payload still parses into Freshell’s capability contract without needing a new hardcoded model or effort table. If it does not, the differential probe fails with the raw payload shape preserved for diagnosis. Sources: `SoT1`, `SoT2`, `SoT3`, `SoT5`.
+ **Interactions:** live SDK query, capability normalization, shared schema parsing, optional diagnostics.
+
+17. **Name:** Large capability catalogs remain buildable without catastrophic option-building regressions
+ **Type:** invariant
+ **Disposition:** existing `test/unit/client/lib/agent-chat-capabilities.test.ts`
+ **Harness:** unit harness
+ **Preconditions:** A synthetic capability catalog large enough to represent worst-case upstream growth.
+ **Actions:** Build settings model options from the large catalog and measure the elapsed time with a generous threshold.
+ **Expected outcome:** Option building stays comfortably under the loose threshold, so any failure indicates a severe regression rather than normal test variance. Sources: `SoT1`, `SoT2`, `SoT4`.
+ **Interactions:** client capability helper logic, option-building path used by the settings surface.
+
+## Coverage Summary
+
+Covered action space:
+
+- Opening Freshclaude settings from the pane header.
+- Triggering stale-capability refresh on settings open.
+- Clicking `Retry model load` after capability fetch failure.
+- Selecting provider-default, tracked live, saved tracked, and unavailable exact model rows.
+- Selecting and clearing effort overrides, including unfamiliar dynamic strings.
+- Creating a Freshclaude pane from the pane picker and confirming the starting directory.
+- Retrying a failed Freshclaude create after changing settings.
+- Reloading the app and rehydrating persisted settings, pane payloads, and tab snapshots.
+- Persisting and clearing `agentChat.providers..modelSelection` and `effort` through `/api/settings`.
+- Fetching and refreshing `/api/agent-chat/capabilities/:provider`.
+- Sending `sdk.create` and `sdk.set-model` with resolved tracked or provider-default model ids.
+- Preserving header metadata and activity indicators while the model-selection contract changes underneath.
+
+Explicitly excluded per the approved strategy:
+
+- No paid or external Anthropic API calls in the primary acceptance suite. Risk: upstream capability metadata could change in a way only the optional differential probe sees.
+- No manual QA or human screenshot review. Risk: if a visual regression slips past the existing Playwright screenshot and DOM assertions, it will be caught later rather than by subjective inspection here.
+- No broad agent-chat coverage unrelated to this feature, such as session-lost recovery, split-pane choreography, or resume-history hydration outside the touched settings and create/model-change paths. Risk: an unrelated agent-chat regression would be caught by adjacent suites, not by this feature plan’s primary gates.
diff --git a/docs/plans/2026-04-18-freshclaude-capability-driven-model-controls.md b/docs/plans/2026-04-18-freshclaude-capability-driven-model-controls.md
new file mode 100644
index 00000000..005a7d60
--- /dev/null
+++ b/docs/plans/2026-04-18-freshclaude-capability-driven-model-controls.md
@@ -0,0 +1,775 @@
+# Freshclaude Capability-Driven Model Controls 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:** Replace Freshclaude’s stale hardcoded Claude model and thinking controls with capability-driven tracked selections that automatically pick up new Anthropic model improvements without breaking persisted settings, pane restore, or tab sync.
+
+**Architecture:** Treat live Claude runtime capabilities as the only source of truth for the live model catalog and effort levels. Persist provider settings and pane state as selection strategies: provider-default means Freshell’s stable `opus` track, tracked selections store opaque model IDs chosen from the live capability catalog, and legacy dated IDs migrate into explicit exact selections. If a persisted tracked or exact selection later disappears from the live catalog, render a synthetic saved/unavailable row so the UI preserves the current value instead of silently healing it. Add a refreshable server-side capability registry with a typed HTTP route. The registry must obtain capabilities by creating a short-lived Claude SDK query with the same sanitized environment and Claude executable settings that `server/sdk-bridge.ts` uses for real sessions, calling `query.supportedModels()`, normalizing the result, and immediately closing that probe query. Use the registry for UI/options/validation, and require the client to revalidate stale cached capabilities when the settings UI opens or when create-time validation needs fresh data. Keep create-time resolution simple: provider-default resolves to `opus`, tracked catalog IDs can launch directly, while explicit effort overrides and unavailable exact selections are validated against the live catalog before Freshell sends them.
+
+**Tech Stack:** TypeScript, React 18, Redux Toolkit, Express, Claude Agent SDK, Zod, Vitest, Playwright
+
+---
+
+## User-Visible Behavior
+
+Freshclaude and Kilroy must default to Freshell’s provider-default model track, which resolves to the Claude SDK alias `opus`. New Opus releases should take effect for new sessions without changing Freshell code again.
+
+The settings UI must show:
+- a provider-default option that clearly means “track latest Opus”
+- live model options from Claude runtime capabilities
+- a synthetic saved-selection row when the currently persisted tracked selection is missing from the latest capability catalog
+- effort options from the currently selected model capability only
+- migrated legacy exact model IDs as unavailable rows when they no longer exist in the live capability catalog
+
+Freshell must not render stale hardcoded dated Claude model IDs or a fixed `[low, medium, high, max]` effort table anywhere in the UI, shared schema, pane validation, or WebSocket contract.
+
+Freshclaude/Kilroy must stop hardcoding a provider-default effort override. When the user has not explicitly chosen an effort, Freshell should omit `effort` and let the selected model use its own default behavior.
+
+`sdk.create` must send `model: 'opus'` for provider-default Freshclaude/Kilroy behavior. It must not omit `model`, because omission would delegate to Claude’s global default rather than Freshell’s “latest Opus” product contract.
+
+Tracked model IDs chosen from the live capability catalog may be sent directly to `sdk.create` and `sdk.set-model`. Today those catalog IDs are alias-style values such as `opus`, `opus[1m]`, `haiku`, or `default`, but Freshell must treat them as opaque runtime data rather than a second hardcoded allowlist. Capability fetch is required for rendering options and validating explicit effort overrides or unavailable exact selections, not for blocking a plain tracked catalog ID that Freshell already persisted from the live catalog.
+
+Opening the settings UI must revalidate stale cached capabilities instead of treating the first fetched catalog as session-lifetime truth. Automatic model improvements should appear after TTL expiry without requiring a page reload or server restart.
+
+If a selected model does not support effort, Freshell must clear any stale explicit effort override and omit `effort` from `sdk.create`. When the stale effort still matches the current provider defaults that seed new panes, Freshell must also clear the persisted provider setting; when the stale effort only exists on a restored pane snapshot that no longer matches current provider defaults, Freshell must clear only pane state.
+
+Because persisted legacy settings do not record whether a saved `defaultEffort` came from an old Freshclaude default or from an explicit user choice, migration must preserve any stored legacy effort value as an explicit override string. Removing provider-default effort applies only to new unsaved defaults, not to already-persisted user config.
+
+Legacy saved exact model IDs such as `claude-opus-4-6` must remain visible and clearly marked unavailable when they are absent from the live capability catalog. Freshell must not silently rewrite them to `opus`, `default`, or a “closest” live option.
+
+If capability discovery fails, the settings UI must show an explicit error with retry. Create-time behavior must only block when Freshell cannot safely validate what it is about to send, for example an unavailable exact selection or an explicit effort override whose support is unknown.
+
+## Contracts And Invariants
+
+- Runtime-selectable model IDs and effort levels come only from normalized Claude runtime capabilities. Synthetic saved/unavailable rows exist only to faithfully represent persisted selections that are absent from the current catalog; they are not a second model catalog.
+- Freshell’s product default is a stable track alias, not a dated model ID. For this feature, Freshclaude and Kilroy both default to `opus`.
+- Provider defaults and pane state distinguish selection from resolution:
+ - no stored selection means provider default track
+ - tracked selection means an opaque model string chosen from the live capability catalog; Freshell does not maintain its own hardcoded allowlist of alias names
+ - exact selection means an explicit preserved model string that is not currently represented by the live capability catalog, used first for migrated legacy values and unavailable pins
+- If a tracked selection is persisted but absent from the current capability catalog, Freshell keeps treating it as a tracked opaque ID for create/set-model, and the settings UI renders a synthetic “Saved selection” row so the value does not disappear or get silently rewritten.
+- Pane state must carry selection strategy, not just a resolved string. Persisted layouts, tab snapshots, and restore flows must survive reload and cross-device sync with the new shape.
+- `sdk.create` and `sdk.set-model` operate on resolved strings. Provider-default resolves to `opus` for both create-time and mid-session model changes; tracked selections resolve to themselves; exact selections resolve to themselves only when still available.
+- `opus` is the only model ID Freshell hardcodes on purpose, because it is the product-defined provider-default track. Every other selectable model ID must come from the live capability catalog.
+- Explicit effort overrides are free-form non-empty strings at the storage/protocol layer and are validated only against the selected model’s live `supportedEffortLevels`. The Claude SDK currently types those levels as a closed union, so Freshell must re-declare the shared/storage schema as `string`, then narrow or cast only at the final SDK call boundary after capability validation. Do not hardcode the current level names into shared Zod schemas, pane validation, TypeScript unions, or transport payloads.
+- Capability discovery is an explicit probe, not an ambient side effect: the registry creates a short-lived SDK query, calls `supportedModels()`, caches only successful normalized results for a bounded TTL, and serializes concurrent refreshes onto a single in-flight probe.
+- The capability cache must be refreshable. Do not cache the catalog for the entire server lifetime with no invalidation path.
+- Client capability state must also honor staleness. Opening settings or performing validation with stale cached capabilities must trigger a refresh instead of trusting the stale client snapshot indefinitely.
+- No code path silently “heals” an unavailable exact selection to a tracked alias.
+- Clearing a saved override that still matches current provider defaults must remove it from persisted settings after the round trip; in-memory `undefined` alone is not sufficient. Cleanup for a stale pane-local snapshot that no longer matches provider defaults must not rewrite provider settings.
+
+## File Structure
+
+### Create
+
+- `shared/agent-chat-capabilities.ts`
+ Shared Zod schemas and TypeScript types for normalized Claude capabilities, tracked/exact selection strategies, capability fetch responses, and capability fetch errors.
+- `server/agent-chat-capability-registry.ts`
+ Server-side capability probe/cache abstraction around Claude SDK capability discovery, including the short-lived probe-query mechanism, normalization, in-flight refresh coalescing, TTL/refresh behavior, and explicit errors.
+- `server/agent-chat-capabilities-router.ts`
+ Typed HTTP route for reading and refreshing capabilities.
+- `src/lib/agent-chat-capabilities.ts`
+ Client helpers for provider-default resolution, tracked/exact selection handling, effort derivation, create-time validation, and UI option building.
+- `test/unit/server/agent-chat-capability-registry.test.ts`
+ Unit tests for normalization, refresh behavior, TTL caching, and failure handling.
+- `test/integration/server/agent-chat-capabilities-router.test.ts`
+ Contract coverage for the capability route.
+- `test/unit/client/lib/agent-chat-capabilities.test.ts`
+ Unit tests for selection resolution, effort derivation, and unavailable exact modeling.
+- `test/e2e/agent-chat-capability-settings-flow.test.tsx`
+ High-fidelity jsdom flow for capability fetch, settings rendering, persistence, and create preflight behavior.
+
+### Modify
+
+- `shared/settings.ts`
+ Persist model-selection strategies and explicit effort overrides instead of raw `defaultModel` plus fixed effort enums.
+- `shared/ws-protocol.ts`
+ Remove the obsolete `sdk.models` server message and stop hardcoding effort levels in SDK message schemas.
+- `server/config-store.ts`
+ Preserve the new settings shape through load/save/migration.
+- `server/index.ts`
+ Mount the capability router.
+- `server/settings-router.ts`
+ Normalize explicit clear sentinels for agent-chat selection/effort patches on the HTTP boundary.
+- `server/sdk-bridge.ts`
+ Remove session-scoped `sdk.models` broadcast ownership and accept non-enum effort strings.
+- `server/ws-handler.ts`
+ Keep create/set-model behavior aligned with resolved string semantics.
+- `src/lib/api.ts`
+ Add typed capability fetch and refresh helpers.
+- `src/lib/agent-chat-types.ts`
+ Change provider config from hardcoded `defaultModel`/`defaultEffort` to provider-default track metadata plus settings visibility.
+- `src/lib/agent-chat-utils.ts`
+ Express Freshclaude/Kilroy provider intent as “track `opus`” with no baked-in effort override.
+- `src/lib/session-type-utils.ts`
+ Resume/new-pane constructors must carry model-selection strategies and optional explicit effort overrides.
+- `src/lib/tab-registry-snapshot.ts`
+ Serialize the new selection-strategy pane payload for tab sync.
+- `src/lib/sdk-message-handler.ts`
+ Remove the obsolete `sdk.models` reducer path.
+- `src/store/agentChatTypes.ts`
+ Replace flat `availableModels` with provider-scoped capability state and selection types.
+- `src/store/agentChatSlice.ts`
+ Store capabilities, fetch status, and capability errors by provider.
+- `src/store/agentChatThunks.ts`
+ Fetch/retry capabilities through the new HTTP route.
+- `src/store/settingsThunks.ts`
+ Preserve explicit clear operations for model selection and effort overrides through serialization.
+- `src/store/persistedState.ts`
+ Version and migrate persisted pane payloads into the new selection-strategy shape.
+- `src/store/persistMiddleware.ts`
+ Persist the new pane payload shape with the updated schema version.
+- `src/store/paneTypes.ts`
+ Replace pane-local raw `model?: string` assumptions with selection-strategy semantics plus an optional explicit effort override string.
+- `src/store/panesSlice.ts`
+ Normalize/hydrate the new agent-chat pane shape.
+- `src/store/paneTreeValidation.ts`
+ Accept the new persisted pane-content shape without hardcoded effort enums.
+- `src/store/types.ts`
+ Remove the stale shared `AgentChatEffort` dependency from app-wide settings typings.
+- `src/components/panes/PaneContainer.tsx`
+ New agent-chat panes should inherit provider-default track or saved overrides, not inject dated model IDs or a hardcoded effort.
+- `src/components/TabsView.tsx`
+ Rehydrate agent-chat panes from registry snapshots using the new selection-strategy payload.
+- `src/components/agent-chat/AgentChatSettings.tsx`
+ Render provider-default track, live capabilities, unavailable exact selections, and capability-derived effort controls.
+- `src/components/agent-chat/AgentChatView.tsx`
+ Resolve create/model-change payloads correctly, fetch capabilities when validation is required, clear invalid effort overrides, and persist strategy changes.
+- `docs/index.html`
+ Update the mock if the visible Freshclaude settings affordances materially change.
+
+### Modify Tests
+
+- `test/unit/shared/settings.test.ts`
+- `test/integration/server/settings-api.test.ts`
+- `test/unit/server/config-store.test.ts`
+- `test/unit/server/sdk-bridge.test.ts`
+- `test/unit/server/sdk-bridge-types.test.ts`
+- `test/unit/server/ws-handler-sdk.test.ts`
+- `test/unit/client/agentChatSlice.test.ts`
+- `test/unit/client/store/persistedState.test.ts`
+- `test/unit/client/store/panesPersistence.test.ts`
+- `test/unit/client/store/agentChatThunks.test.ts`
+- `test/unit/client/store/settingsThunks.test.ts`
+- `test/unit/client/sdk-message-handler.test.ts`
+- `test/unit/client/lib/agent-chat-utils.test.ts`
+- `test/unit/client/lib/session-type-utils.test.ts`
+- `test/unit/client/lib/tab-registry-snapshot.test.ts`
+- `test/unit/client/components/panes/PaneContainer.createContent.test.tsx`
+- `test/unit/client/components/panes/PaneContainer.test.tsx`
+- `test/unit/client/components/TabsView.test.tsx`
+- `test/unit/client/components/agent-chat/AgentChatSettings.test.tsx`
+- `test/unit/client/components/agent-chat/AgentChatSettings.mobile.test.tsx`
+- `test/unit/client/components/agent-chat/AgentChatView.behavior.test.tsx`
+- `test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx`
+- `test/e2e/agent-chat-capability-settings-flow.test.tsx`
+- `test/e2e/pane-header-runtime-meta-flow.test.tsx`
+- `test/e2e/pane-activity-indicator-flow.test.tsx`
+- `test/e2e-browser/specs/agent-chat.spec.ts`
+- `test/e2e-browser/specs/settings.spec.ts`
+- `test/e2e-browser/specs/settings-persistence-split.spec.ts`
+
+## Strategy Gate
+
+The previous plan had seven real failure modes:
+
+1. It removed hardcoded effort choices from the UI but still left hardcoded effort enums in `shared/settings.ts`, `shared/ws-protocol.ts`, pane validation, and multiple client/server types. That would still break automatic adoption if Anthropic adds or renames effort levels.
+2. It used `defaultEffort` wording inside the new settings contract even though the intended behavior is “no provider-default effort; only explicit per-user overrides.” That naming would push the implementation back toward the stale design we are replacing.
+3. It changed settings persistence semantics without explicitly covering the existing `/api/settings` integration tests and client-side patch normalization that are responsible for null-sentinel clears.
+4. It removed `sdk.models` transport behavior without updating the dedicated schema tests that currently encode the old message and effort enum assumptions.
+5. It blocked exact effort-level future-proofing because the plan still let several transport/storage boundaries assume `'low' | 'medium' | 'high' | 'max'`.
+6. It was otherwise on the right architectural path, so the correct response is a focused rewrite, not a directional reset.
+7. It changed pane payload semantics without explicitly updating persisted-layout schema/versioning and migration coverage, which would make reload/localStorage restoration the likeliest silent regression path.
+
+The correct direction is:
+
+1. Persist tracked/exact/default selection strategy, not dated model IDs.
+2. Treat effort overrides as dynamic strings validated against live capabilities, not as a hardcoded enum.
+3. Use a refreshable capability probe as the source of truth for UI and validation.
+4. Keep provider-default and tracked aliases launchable without unnecessary capability fetch blocking.
+5. Remove hardcoded provider-default effort so thinking behavior can evolve with the selected model.
+
+## Task 1: Define Shared Selection, Dynamic Effort, And Settings Contracts
+
+**Files:**
+- Create: `shared/agent-chat-capabilities.ts`
+- Modify: `shared/settings.ts`
+- Modify: `server/settings-router.ts`
+- Modify: `test/unit/shared/settings.test.ts`
+- Modify: `test/integration/server/settings-api.test.ts`
+
+- [ ] **Step 1: Identify or write the failing tests**
+
+Add shared-contract and settings-API tests that prove:
+- agent-chat provider settings can store no selection, a tracked selection, or an exact selection
+- explicit effort override is optional and separate from model selection
+- effort overrides are stored as non-empty strings, not a fixed enum
+- settings patches can explicitly clear model selection and effort override through the `/api/settings` contract
+- the HTTP settings boundary normalizes null/empty clear sentinels for agent-chat selection and effort before patching config
+- legacy `defaultModel` values migrate into exact selections without coercion to `opus`
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/shared/settings.test.ts test/integration/server/settings-api.test.ts
+```
+
+Expected: FAIL because the shared selection schema, dynamic effort contract, and clear semantics do not exist yet.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/shared/settings.test.ts test/integration/server/settings-api.test.ts
+```
+
+Expected: FAIL with stale `defaultModel` and fixed-effort assumptions.
+
+- [ ] **Step 3: Write the minimal implementation**
+
+Implement:
+- `shared/agent-chat-capabilities.ts` with normalized capability schemas/types and tracked/exact selection schemas/types
+- `shared/settings.ts` storing `modelSelection` plus optional explicit `effort` override as a validated non-empty string
+- `server/settings-router.ts` normalization for explicit null/empty clear sentinels on agent-chat provider patches
+- migration from legacy `defaultModel` to exact selection without silent alias rewrite
+- `/api/settings` patch acceptance for explicit null clears of the new fields
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/shared/settings.test.ts test/integration/server/settings-api.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Refactor and verify**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/shared/settings.test.ts test/integration/server/settings-api.test.ts test/unit/server/config-store.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add shared/agent-chat-capabilities.ts shared/settings.ts server/settings-router.ts test/unit/shared/settings.test.ts test/integration/server/settings-api.test.ts test/unit/server/config-store.test.ts
+git commit -m "feat: add dynamic agent chat selection contracts"
+```
+
+## Task 2: Build A Refreshable Claude Capability Registry And HTTP Contract
+
+**Files:**
+- Create: `server/agent-chat-capability-registry.ts`
+- Create: `server/agent-chat-capabilities-router.ts`
+- Modify: `server/index.ts`
+- Modify: `server/sdk-bridge.ts`
+- Modify: `shared/ws-protocol.ts`
+- Create: `test/unit/server/agent-chat-capability-registry.test.ts`
+- Create: `test/integration/server/agent-chat-capabilities-router.test.ts`
+- Modify: `test/unit/server/sdk-bridge.test.ts`
+- Modify: `test/unit/server/sdk-bridge-types.test.ts`
+- Modify: `test/unit/server/ws-handler-sdk.test.ts`
+
+- [ ] **Step 1: Identify or write the failing tests**
+
+Add server tests that prove:
+- capability discovery normalizes full model info including effort/adaptive-thinking flags
+- capability discovery creates a short-lived SDK query probe, closes it after `supportedModels()`, and reuses one in-flight probe for concurrent refreshes
+- the registry caches successful results only for a bounded TTL and supports explicit refresh
+- malformed or incomplete SDK payloads fail clearly
+- the capability route returns a typed error payload on probe failure
+- websocket flows no longer depend on `sdk.models`
+- SDK transport/input schemas accept dynamic effort strings instead of a fixed enum
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/server/agent-chat-capability-registry.test.ts test/integration/server/agent-chat-capabilities-router.test.ts test/unit/server/sdk-bridge.test.ts test/unit/server/sdk-bridge-types.test.ts test/unit/server/ws-handler-sdk.test.ts
+```
+
+Expected: FAIL because the registry, route, websocket cleanup, and dynamic effort transport contract do not exist yet.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/server/agent-chat-capability-registry.test.ts test/integration/server/agent-chat-capabilities-router.test.ts test/unit/server/sdk-bridge.test.ts test/unit/server/sdk-bridge-types.test.ts test/unit/server/ws-handler-sdk.test.ts
+```
+
+Expected: FAIL.
+
+- [ ] **Step 3: Write the minimal implementation**
+
+Implement the server capability path:
+- build the probe abstraction around a short-lived Claude SDK query created solely for capability discovery; share the same env sanitization, `pathToClaudeCodeExecutable`, and MCP wiring rules that `server/sdk-bridge.ts` uses, then call `supportedModels()` and immediately close the probe query
+- coalesce concurrent refresh requests onto one in-flight probe and cache only successful normalized results; do not let a failed refresh poison the last known-good catalog
+- normalize and validate the SDK capability payload against shared schemas
+- expose GET and refresh semantics through the HTTP route
+- remove session-scoped `sdk.models` broadcast behavior
+- stop hardcoding current effort strings in `shared/ws-protocol.ts` and `server/sdk-bridge.ts`
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/server/agent-chat-capability-registry.test.ts test/integration/server/agent-chat-capabilities-router.test.ts test/unit/server/sdk-bridge.test.ts test/unit/server/sdk-bridge-types.test.ts test/unit/server/ws-handler-sdk.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Refactor and verify**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/server/agent-chat-capability-registry.test.ts test/integration/server/agent-chat-capabilities-router.test.ts test/unit/server/sdk-bridge.test.ts test/unit/server/sdk-bridge-types.test.ts test/unit/server/ws-handler-sdk.test.ts test/unit/server/config-store.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add server/agent-chat-capability-registry.ts server/agent-chat-capabilities-router.ts server/index.ts server/sdk-bridge.ts shared/ws-protocol.ts test/unit/server/agent-chat-capability-registry.test.ts test/integration/server/agent-chat-capabilities-router.test.ts test/unit/server/sdk-bridge.test.ts test/unit/server/sdk-bridge-types.test.ts test/unit/server/ws-handler-sdk.test.ts test/unit/server/config-store.test.ts
+git commit -m "feat: add refreshable claude capability registry"
+```
+
+## Task 3: Migrate Settings, Pane State, And Sync Payloads To Selection Strategies
+
+**Files:**
+- Modify: `server/config-store.ts`
+- Modify: `src/store/settingsThunks.ts`
+- Modify: `src/store/persistedState.ts`
+- Modify: `src/store/persistMiddleware.ts`
+- Modify: `src/lib/agent-chat-types.ts`
+- Modify: `src/lib/agent-chat-utils.ts`
+- Modify: `src/lib/session-type-utils.ts`
+- Modify: `src/lib/tab-registry-snapshot.ts`
+- Modify: `src/store/paneTypes.ts`
+- Modify: `src/store/panesSlice.ts`
+- Modify: `src/store/paneTreeValidation.ts`
+- Modify: `src/store/types.ts`
+- Modify: `src/components/panes/PaneContainer.tsx`
+- Modify: `src/components/TabsView.tsx`
+- Modify: `test/unit/server/config-store.test.ts`
+- Modify: `test/unit/client/store/persistedState.test.ts`
+- Modify: `test/unit/client/store/panesPersistence.test.ts`
+- Modify: `test/unit/client/store/settingsThunks.test.ts`
+- Modify: `test/unit/client/lib/agent-chat-utils.test.ts`
+- Modify: `test/unit/client/lib/session-type-utils.test.ts`
+- Modify: `test/unit/client/lib/tab-registry-snapshot.test.ts`
+- Modify: `test/unit/client/components/panes/PaneContainer.createContent.test.tsx`
+- Modify: `test/unit/client/components/panes/PaneContainer.test.tsx`
+- Modify: `test/unit/client/components/TabsView.test.tsx`
+
+- [ ] **Step 1: Identify or write the failing tests**
+
+Extend tests so they prove:
+- Freshclaude/Kilroy provider config expresses provider-default track `opus` with no baked-in effort override
+- settings thunk normalization sends null sentinels for cleared selection and cleared effort override
+- settings clears actually remove stored model selection and explicit effort override
+- persisted legacy `defaultEffort` values migrate into explicit overrides because their provenance is unknowable
+- persisted layout parsing and persistence migrate legacy `model`/`effort` pane fields into selection strategy plus optional explicit effort override
+- pane-schema versioning changes are explicit and old localStorage payloads still load into the new shape
+- pane creation, layout hydration, tab snapshot serialization, and TabsView rehydration preserve selection strategy
+- no path injects `claude-opus-4-6` or a hardcoded effort into new agent-chat panes
+- pane validation accepts any non-empty effort override string and rejects empty ones
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/server/config-store.test.ts test/unit/client/store/persistedState.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/store/settingsThunks.test.ts test/unit/client/lib/agent-chat-utils.test.ts test/unit/client/lib/session-type-utils.test.ts test/unit/client/lib/tab-registry-snapshot.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/TabsView.test.tsx
+```
+
+Expected: FAIL because provider config and pane payloads still assume raw model strings and fixed effort semantics.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/server/config-store.test.ts test/unit/client/store/persistedState.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/store/settingsThunks.test.ts test/unit/client/lib/agent-chat-utils.test.ts test/unit/client/lib/session-type-utils.test.ts test/unit/client/lib/tab-registry-snapshot.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/TabsView.test.tsx
+```
+
+Expected: FAIL.
+
+- [ ] **Step 3: Write the minimal implementation**
+
+Implement the strategy cutover across:
+- settings persistence and clear serialization
+- migration that preserves any persisted legacy `defaultEffort` as an explicit override while removing provider-level hardcoded defaults for new panes
+- persisted-layout schema/version bump plus legacy pane migration in localStorage
+- provider metadata
+- pane types and pane normalization
+- persisted pane-tree validation
+- tab snapshot serialization and rehydration
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/server/config-store.test.ts test/unit/client/store/persistedState.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/store/settingsThunks.test.ts test/unit/client/lib/agent-chat-utils.test.ts test/unit/client/lib/session-type-utils.test.ts test/unit/client/lib/tab-registry-snapshot.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/TabsView.test.tsx
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Refactor and verify**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/shared/settings.test.ts test/integration/server/settings-api.test.ts test/unit/server/config-store.test.ts test/unit/client/store/persistedState.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/store/settingsThunks.test.ts test/unit/client/lib/agent-chat-utils.test.ts test/unit/client/lib/session-type-utils.test.ts test/unit/client/lib/tab-registry-snapshot.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/TabsView.test.tsx
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add server/config-store.ts src/store/settingsThunks.ts src/store/persistedState.ts src/store/persistMiddleware.ts src/lib/agent-chat-types.ts src/lib/agent-chat-utils.ts src/lib/session-type-utils.ts src/lib/tab-registry-snapshot.ts src/store/paneTypes.ts src/store/panesSlice.ts src/store/paneTreeValidation.ts src/store/types.ts src/components/panes/PaneContainer.tsx src/components/TabsView.tsx test/unit/server/config-store.test.ts test/unit/client/store/persistedState.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/store/settingsThunks.test.ts test/unit/client/lib/agent-chat-utils.test.ts test/unit/client/lib/session-type-utils.test.ts test/unit/client/lib/tab-registry-snapshot.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/TabsView.test.tsx
+git commit -m "refactor: persist agent chat selections as strategies"
+```
+
+## Task 4: Add Client Capability State, Fetch Lifecycle, And Resolution Helpers
+
+**Files:**
+- Create: `src/lib/agent-chat-capabilities.ts`
+- Modify: `src/lib/api.ts`
+- Modify: `src/store/agentChatTypes.ts`
+- Modify: `src/store/agentChatSlice.ts`
+- Modify: `src/store/agentChatThunks.ts`
+- Modify: `src/lib/sdk-message-handler.ts`
+- Modify: `test/unit/client/lib/agent-chat-capabilities.test.ts`
+- Modify: `test/unit/client/agentChatSlice.test.ts`
+- Modify: `test/unit/client/store/agentChatThunks.test.ts`
+- Modify: `test/unit/client/sdk-message-handler.test.ts`
+
+- [ ] **Step 1: Identify or write the failing tests**
+
+Add client tests that prove:
+- capabilities are stored per provider with `idle/loading/succeeded/failed` status
+- provider-default resolves to tracked alias `opus`
+- tracked aliases resolve without dated-ID remapping
+- exact legacy selections become explicit unavailable state when absent from the live catalog
+- effort options come only from the resolved capability
+- the client never assumes a fixed set of effort strings
+- `sdk.models` reducer handling is gone
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/lib/agent-chat-capabilities.test.ts test/unit/client/agentChatSlice.test.ts test/unit/client/store/agentChatThunks.test.ts test/unit/client/sdk-message-handler.test.ts
+```
+
+Expected: FAIL because the client still stores one flat `availableModels` array.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/lib/agent-chat-capabilities.test.ts test/unit/client/agentChatSlice.test.ts test/unit/client/store/agentChatThunks.test.ts test/unit/client/sdk-message-handler.test.ts
+```
+
+Expected: FAIL.
+
+- [ ] **Step 3: Write the minimal implementation**
+
+Implement:
+- typed capability fetch helpers
+- provider-scoped capability state and retry behavior
+- shared selection-resolution and effort-validation helpers
+- removal of websocket `sdk.models`
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/lib/agent-chat-capabilities.test.ts test/unit/client/agentChatSlice.test.ts test/unit/client/store/agentChatThunks.test.ts test/unit/client/sdk-message-handler.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Refactor and verify**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/lib/agent-chat-capabilities.test.ts test/unit/client/agentChatSlice.test.ts test/unit/client/store/agentChatThunks.test.ts test/unit/client/sdk-message-handler.test.ts test/unit/client/components/agent-chat/AgentChatView.behavior.test.tsx
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/lib/agent-chat-capabilities.ts src/lib/api.ts src/store/agentChatTypes.ts src/store/agentChatSlice.ts src/store/agentChatThunks.ts src/lib/sdk-message-handler.ts test/unit/client/lib/agent-chat-capabilities.test.ts test/unit/client/agentChatSlice.test.ts test/unit/client/store/agentChatThunks.test.ts test/unit/client/sdk-message-handler.test.ts test/unit/client/components/agent-chat/AgentChatView.behavior.test.tsx
+git commit -m "feat: add capability-driven agent chat client state"
+```
+
+## Task 5: Resolve Create-Time And Mid-Session Model Changes Correctly
+
+**Files:**
+- Modify: `src/components/agent-chat/AgentChatView.tsx`
+- Modify: `server/ws-handler.ts`
+- Modify: `test/unit/client/components/agent-chat/AgentChatView.behavior.test.tsx`
+- Modify: `test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx`
+- Modify: `test/e2e/pane-header-runtime-meta-flow.test.tsx`
+- Modify: `test/e2e/pane-activity-indicator-flow.test.tsx`
+- Modify: `test/unit/server/ws-handler-sdk.test.ts`
+
+- [ ] **Step 1: Identify or write the failing tests**
+
+Add tests that prove:
+- provider-default Freshclaude/Kilroy create sends `model: 'opus'`
+- tracked aliases can create without a blocking capability fetch when no effort validation is needed
+- explicit effort overrides wait for capability validation and are cleared when unsupported
+- unavailable exact selections block create with a clear error
+- mid-session model changes send resolved strings and clear invalid effort overrides from persisted defaults only when the pane still matches current provider defaults
+- create-time and passive cleanup drop unsupported pane-local effort snapshots without rewriting provider defaults when the pane no longer matches current defaults
+- create and set-model paths pass through non-enum effort strings when the live capability list allows them
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/components/agent-chat/AgentChatView.behavior.test.tsx test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx test/e2e/pane-header-runtime-meta-flow.test.tsx test/e2e/pane-activity-indicator-flow.test.tsx test/unit/server/ws-handler-sdk.test.ts
+```
+
+Expected: FAIL because create/model-change flow still assumes raw pane `model` strings and hardcoded defaults.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/components/agent-chat/AgentChatView.behavior.test.tsx test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx test/e2e/pane-header-runtime-meta-flow.test.tsx test/e2e/pane-activity-indicator-flow.test.tsx test/unit/server/ws-handler-sdk.test.ts
+```
+
+Expected: FAIL.
+
+- [ ] **Step 3: Write the minimal implementation**
+
+Implement runtime resolution in `AgentChatView`:
+- send provider-default/tracked aliases directly when safe
+- fetch capabilities when required for effort validation or unavailable exact detection
+- omit `effort` when no explicit override is active
+- when invalid effort is cleared, compare the pane’s current selection/effort against current provider settings before deciding whether to persist a settings clear
+- keep `sdk.create` and `sdk.set-model` payloads consistent with resolved strings
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/components/agent-chat/AgentChatView.behavior.test.tsx test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx test/e2e/pane-header-runtime-meta-flow.test.tsx test/e2e/pane-activity-indicator-flow.test.tsx test/unit/server/ws-handler-sdk.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Refactor and verify**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/components/agent-chat/AgentChatView.behavior.test.tsx test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx test/e2e/pane-header-runtime-meta-flow.test.tsx test/e2e/pane-activity-indicator-flow.test.tsx test/unit/server/ws-handler-sdk.test.ts test/unit/client/lib/agent-chat-capabilities.test.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/components/agent-chat/AgentChatView.tsx server/ws-handler.ts test/unit/client/components/agent-chat/AgentChatView.behavior.test.tsx test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx test/e2e/pane-header-runtime-meta-flow.test.tsx test/e2e/pane-activity-indicator-flow.test.tsx test/unit/server/ws-handler-sdk.test.ts test/unit/client/lib/agent-chat-capabilities.test.ts
+git commit -m "feat: resolve freshclaude creates from tracked selections"
+```
+
+## Task 6: Rebuild The Settings UI Around Live Capabilities
+
+**Files:**
+- Modify: `src/components/agent-chat/AgentChatSettings.tsx`
+- Modify: `src/components/agent-chat/AgentChatView.tsx`
+- Modify: `src/lib/agent-chat-capabilities.ts`
+- Modify: `test/unit/client/components/agent-chat/AgentChatSettings.test.tsx`
+- Modify: `test/unit/client/components/agent-chat/AgentChatSettings.mobile.test.tsx`
+- Modify: `test/e2e/agent-chat-capability-settings-flow.test.tsx`
+
+- [ ] **Step 1: Identify or write the failing tests**
+
+Add UI tests that prove:
+- the model control shows provider-default track plus live capability rows, and adds a synthetic saved-selection row only when the currently selected tracked model is missing from the latest catalog
+- provider-default explains “track latest Opus”
+- opening settings revalidates stale cached capabilities instead of treating them as session-lifetime truth
+- a migrated unavailable exact model renders clearly and stays selected until the user changes it
+- effort options come from `supportedEffortLevels`
+- choosing a model without effort support hides/disables effort and clears any stale saved override
+- loading and error states are explicit and accessible on desktop and mobile
+- the rendered effort choices are whatever the capability payload says, not a locally defined canonical list
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/components/agent-chat/AgentChatSettings.test.tsx test/unit/client/components/agent-chat/AgentChatSettings.mobile.test.tsx test/e2e/agent-chat-capability-settings-flow.test.tsx
+```
+
+Expected: FAIL because the UI still renders hardcoded dated models and static effort options.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/components/agent-chat/AgentChatSettings.test.tsx test/unit/client/components/agent-chat/AgentChatSettings.mobile.test.tsx test/e2e/agent-chat-capability-settings-flow.test.tsx
+```
+
+Expected: FAIL.
+
+- [ ] **Step 3: Write the minimal implementation**
+
+Rebuild the settings UI so it renders:
+- provider-default track row
+- live capability rows
+- synthetic saved-selection row when the selected tracked model is missing from the latest catalog
+- unavailable exact row when needed
+- effort UI only from the selected capability
+- stale-cache refresh on settings open
+- explicit retryable load/error states
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/components/agent-chat/AgentChatSettings.test.tsx test/unit/client/components/agent-chat/AgentChatSettings.mobile.test.tsx test/e2e/agent-chat-capability-settings-flow.test.tsx
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Refactor and verify**
+
+Run:
+
+```bash
+npm run test:vitest -- test/unit/client/components/agent-chat/AgentChatSettings.test.tsx test/unit/client/components/agent-chat/AgentChatSettings.mobile.test.tsx test/e2e/agent-chat-capability-settings-flow.test.tsx test/unit/client/components/agent-chat/AgentChatView.behavior.test.tsx
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/components/agent-chat/AgentChatSettings.tsx src/components/agent-chat/AgentChatView.tsx src/lib/agent-chat-capabilities.ts test/unit/client/components/agent-chat/AgentChatSettings.test.tsx test/unit/client/components/agent-chat/AgentChatSettings.mobile.test.tsx test/e2e/agent-chat-capability-settings-flow.test.tsx test/unit/client/components/agent-chat/AgentChatView.behavior.test.tsx
+git commit -m "feat: render freshclaude settings from live capabilities"
+```
+
+## Task 7: Browser Coverage, Docs, And Final Verification
+
+**Files:**
+- Modify: `test/e2e-browser/specs/agent-chat.spec.ts`
+- Modify: `test/e2e-browser/specs/settings.spec.ts`
+- Modify: `test/e2e-browser/specs/settings-persistence-split.spec.ts`
+- Modify: `docs/index.html`
+- Modify: any touched files if final cleanup is required
+
+- [ ] **Step 1: Identify or write the failing browser tests**
+
+Extend browser coverage to prove:
+- a new Freshclaude pane defaults to provider-default latest-Opus tracking
+- default create sends `model: 'opus'`
+- switching to another tracked live model persists and survives reload
+- switching a saved override back to provider-default clears persistence and survives reload
+- a saved tracked selection that disappears from the latest catalog remains visible as a saved row instead of being silently rewritten
+- a saved legacy exact model is surfaced clearly after reload instead of being silently migrated
+- opening settings after the client-side capability cache goes stale refreshes and shows the latest catalog
+- capability fetch failure shows a visible settings error and only blocks create when validation is actually required
+- a capability payload with unfamiliar effort strings still renders and round-trips correctly
+
+Run:
+
+```bash
+npm run test:e2e -- test/e2e-browser/specs/agent-chat.spec.ts test/e2e-browser/specs/settings.spec.ts test/e2e-browser/specs/settings-persistence-split.spec.ts
+```
+
+Expected: FAIL because browser fixtures still assume hardcoded model tables, raw `defaultModel`, or fixed effort strings. `npm run test:e2e` is the correct Playwright runner for `test/e2e-browser/specs/*` in this repo.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run:
+
+```bash
+npm run test:e2e -- test/e2e-browser/specs/agent-chat.spec.ts test/e2e-browser/specs/settings.spec.ts test/e2e-browser/specs/settings-persistence-split.spec.ts
+```
+
+Expected: FAIL.
+
+- [ ] **Step 3: Write the minimal implementation**
+
+Finish the cutover:
+- update browser fixtures/mocks to use the capability HTTP route
+- update `docs/index.html` if the mock includes Freshclaude settings
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run:
+
+```bash
+npm run test:e2e -- test/e2e-browser/specs/agent-chat.spec.ts test/e2e-browser/specs/settings.spec.ts test/e2e-browser/specs/settings-persistence-split.spec.ts
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Run the full required verification**
+
+Run:
+
+```bash
+FRESHELL_TEST_SUMMARY="freshclaude capability tracks targeted" npm run test:vitest -- test/unit/shared/settings.test.ts test/integration/server/settings-api.test.ts test/unit/server/agent-chat-capability-registry.test.ts test/integration/server/agent-chat-capabilities-router.test.ts test/unit/server/config-store.test.ts test/unit/server/sdk-bridge.test.ts test/unit/server/sdk-bridge-types.test.ts test/unit/server/ws-handler-sdk.test.ts test/unit/client/lib/agent-chat-capabilities.test.ts test/unit/client/lib/agent-chat-utils.test.ts test/unit/client/lib/session-type-utils.test.ts test/unit/client/lib/tab-registry-snapshot.test.ts test/unit/client/agentChatSlice.test.ts test/unit/client/store/persistedState.test.ts test/unit/client/store/panesPersistence.test.ts test/unit/client/store/agentChatThunks.test.ts test/unit/client/store/settingsThunks.test.ts test/unit/client/sdk-message-handler.test.ts test/unit/client/components/panes/PaneContainer.createContent.test.tsx test/unit/client/components/panes/PaneContainer.test.tsx test/unit/client/components/TabsView.test.tsx test/unit/client/components/agent-chat/AgentChatSettings.test.tsx test/unit/client/components/agent-chat/AgentChatSettings.mobile.test.tsx test/unit/client/components/agent-chat/AgentChatView.behavior.test.tsx test/unit/client/components/agent-chat/AgentChatView.reload.test.tsx test/e2e/agent-chat-capability-settings-flow.test.tsx test/e2e/pane-header-runtime-meta-flow.test.tsx test/e2e/pane-activity-indicator-flow.test.tsx
+npm run typecheck
+npm run lint
+npm run test:e2e -- test/e2e-browser/specs/agent-chat.spec.ts test/e2e-browser/specs/settings.spec.ts test/e2e-browser/specs/settings-persistence-split.spec.ts
+FRESHELL_TEST_SUMMARY="freshclaude capability tracks full suite" npm test
+```
+
+Expected: all PASS.
+
+- [ ] **Step 6: Final refactor pass**
+
+Remove dead code:
+- stale hardcoded model and effort arrays
+- websocket `sdk.models` types, mocks, and reducers
+- comments that still describe omission-based or dated-ID defaults
+- any remaining fixed effort unions or validators
+
+Re-run the smallest relevant checks if cleanup changes behavior.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git status --short
+# Review the touched list, then stage explicit paths rather than using git add -A.
+# At minimum this task should stage the updated browser specs, docs/index.html if touched,
+# and any final cleanup edits from previously-touched Freshclaude capability files.
+git commit -m "chore: finalize capability-driven freshclaude model controls"
+```
+
+## Notes For Execution
+
+- Use only the implementation worktree: `/home/user/code/freshell/.worktrees/trycycle-freshclaude-capabilities`.
+- Keep commits small and aligned with the tasks above.
+- Do not reintroduce stale dated model tables, fixed effort option tables, or “closest model” migration heuristics.
+- Do not hardcode a provider-default effort override; explicit effort should remain opt-in and capability-validated.
+- Do not invent a second hardcoded alias allowlist. Aside from the deliberate provider-default `opus` contract, tracked model IDs must flow from the runtime capability catalog as opaque strings.
+- Do not silently drop or rewrite a tracked selection that is absent from the current catalog; represent it as a saved selection row and keep treating it as an opaque tracked ID unless validation specifically requires a live capability.
+- Do not hardcode the current effort level names into shared schemas, pane validators, or TypeScript unions; future upstream effort strings must round-trip without another Freshell code change.
+- Keep capability fetches refreshable and retryable; a server restart must not be the only way to see new capability metadata.
+- When invalid effort cleanup runs, rewrite provider defaults only if the pane still matches the current provider settings; stale pane-local snapshots should be sanitized locally without clobbering global defaults.
+- Do not block provider-default or tracked-alias creates unless Freshell genuinely lacks the information required to validate an explicit override.
diff --git a/server/agent-chat-capabilities-router.ts b/server/agent-chat-capabilities-router.ts
new file mode 100644
index 00000000..a08328c2
--- /dev/null
+++ b/server/agent-chat-capabilities-router.ts
@@ -0,0 +1,45 @@
+import { Router } from 'express'
+import { z } from 'zod'
+
+import { AgentChatCapabilitiesResponseSchema } from '../shared/agent-chat-capabilities.js'
+
+const ProviderSchema = z.string().trim().min(1)
+
+type AgentChatCapabilityRegistryLike = {
+ getCapabilities: (provider: string) => Promise
+ refreshCapabilities: (provider: string) => Promise
+}
+
+export function createAgentChatCapabilitiesRouter(
+ deps: { registry: AgentChatCapabilityRegistryLike },
+): Router {
+ const router = Router()
+
+ router.get('/:provider', async (req, res) => {
+ const parsedProvider = ProviderSchema.safeParse(req.params.provider)
+ if (!parsedProvider.success) {
+ return res.status(400).json({ error: 'Invalid provider' })
+ }
+
+ const result = AgentChatCapabilitiesResponseSchema.parse(
+ await deps.registry.getCapabilities(parsedProvider.data),
+ )
+
+ return res.status(result.ok ? 200 : 503).json(result)
+ })
+
+ router.post('/:provider/refresh', async (req, res) => {
+ const parsedProvider = ProviderSchema.safeParse(req.params.provider)
+ if (!parsedProvider.success) {
+ return res.status(400).json({ error: 'Invalid provider' })
+ }
+
+ const result = AgentChatCapabilitiesResponseSchema.parse(
+ await deps.registry.refreshCapabilities(parsedProvider.data),
+ )
+
+ return res.status(result.ok ? 200 : 503).json(result)
+ })
+
+ return router
+}
diff --git a/server/agent-chat-capability-registry.ts b/server/agent-chat-capability-registry.ts
new file mode 100644
index 00000000..bcd34379
--- /dev/null
+++ b/server/agent-chat-capability-registry.ts
@@ -0,0 +1,302 @@
+import {
+ query,
+ type Query as SdkQuery,
+} from '@anthropic-ai/claude-agent-sdk'
+
+import {
+ AGENT_CHAT_CAPABILITY_CACHE_TTL_MS,
+ AgentChatCapabilitiesResponseSchema,
+ AgentChatCapabilitiesSchema,
+ AgentChatCapabilityErrorSchema,
+ AgentChatModelCapabilitySchema,
+ type AgentChatCapabilitiesResponse,
+ type AgentChatModelCapability,
+} from '../shared/agent-chat-capabilities.js'
+import { formatModelDisplayName } from '../shared/format-model-name.js'
+import { createClaudeSdkOptions } from './sdk-bridge.js'
+import { logger } from './logger.js'
+
+const log = logger.child({ component: 'agent-chat-capability-registry' })
+const DEFAULT_PROBE_TIMEOUT_MS = 10_000
+
+type ProbeQuery = Pick
+
+type RegistryOptions = {
+ queryFactory?: typeof query
+ now?: () => number
+ ttlMs?: number
+ probeTimeoutMs?: number
+}
+
+type CachedCatalog = {
+ fetchedAt: number
+ models: AgentChatModelCapability[]
+}
+
+function invalidCapabilityPayload(message: string): Error & {
+ code: string
+ retryable: boolean
+} {
+ return AgentChatCapabilityRegistry.createError(
+ 'CAPABILITY_PAYLOAD_INVALID',
+ message,
+ false,
+ )
+}
+
+function normalizeStringList(value: unknown, fieldLabel: string, modelId: string): string[] {
+ if (value === undefined || value === null) {
+ return []
+ }
+
+ if (!Array.isArray(value)) {
+ throw invalidCapabilityPayload(
+ `Capability payload has invalid ${fieldLabel} for ${modelId}`,
+ )
+ }
+
+ return value.map((entry) => {
+ if (typeof entry !== 'string') {
+ throw invalidCapabilityPayload(
+ `Capability payload has invalid ${fieldLabel} for ${modelId}`,
+ )
+ }
+
+ const trimmed = entry.trim()
+ if (trimmed.length === 0) {
+ throw invalidCapabilityPayload(
+ `Capability payload has invalid ${fieldLabel} for ${modelId}`,
+ )
+ }
+
+ return trimmed
+ })
+}
+
+function formatCapabilityDisplayName(rawName: string): string {
+ const formatted = formatModelDisplayName(rawName)
+ if (formatted !== rawName) {
+ return formatted
+ }
+
+ const aliasMatch = rawName.match(/^([a-z]+)(\[[^\]]+\])?$/)
+ if (!aliasMatch) {
+ return rawName
+ }
+
+ const base = aliasMatch[1].charAt(0).toUpperCase() + aliasMatch[1].slice(1)
+ return aliasMatch[2] ? `${base} ${aliasMatch[2]}` : base
+}
+
+function readModelId(model: Record): string {
+ const value = typeof model.value === 'string' ? model.value.trim() : ''
+ if (value.length > 0) {
+ return value
+ }
+
+ const id = typeof model.id === 'string' ? model.id.trim() : ''
+ if (id.length > 0) {
+ return id
+ }
+
+ throw AgentChatCapabilityRegistry.createError(
+ 'CAPABILITY_PAYLOAD_INVALID',
+ 'Capability payload is missing a model id',
+ false,
+ )
+}
+
+function normalizeModelCapability(rawModel: unknown): AgentChatModelCapability {
+ if (!rawModel || typeof rawModel !== 'object' || Array.isArray(rawModel)) {
+ throw AgentChatCapabilityRegistry.createError(
+ 'CAPABILITY_PAYLOAD_INVALID',
+ 'Capability payload must contain model objects',
+ false,
+ )
+ }
+
+ const model = rawModel as Record
+ const id = readModelId(model)
+ const rawDisplayName = typeof model.displayName === 'string'
+ ? model.displayName.trim()
+ : typeof model.display_name === 'string'
+ ? model.display_name.trim()
+ : id
+ const supportedEffortLevels = normalizeStringList(
+ model.supportedEffortLevels
+ ?? model.supported_effort_levels
+ ?? model.effortLevels
+ ?? model.effort_levels,
+ 'supported effort levels',
+ id,
+ )
+ // The settings surface renders effort controls from the live levels list, so
+ // normalize the boolean to that same source of truth and avoid UI/runtime drift.
+ const supportsEffort = supportedEffortLevels.length > 0
+ const supportsAdaptiveThinking = typeof model.supportsAdaptiveThinking === 'boolean'
+ ? model.supportsAdaptiveThinking
+ : typeof model.supports_adaptive_thinking === 'boolean'
+ ? model.supports_adaptive_thinking
+ : false
+
+ return AgentChatModelCapabilitySchema.parse({
+ id,
+ displayName: formatCapabilityDisplayName(rawDisplayName || id),
+ description: typeof model.description === 'string' ? model.description : undefined,
+ supportsEffort,
+ supportedEffortLevels,
+ supportsAdaptiveThinking,
+ })
+}
+
+export function normalizeAgentChatCapabilityCatalog(rawModels: unknown): AgentChatModelCapability[] {
+ if (!Array.isArray(rawModels)) {
+ throw invalidCapabilityPayload('Capability payload must be an array of models')
+ }
+
+ return rawModels.map((rawModel) => normalizeModelCapability(rawModel))
+}
+
+export class AgentChatCapabilityRegistry {
+ private readonly queryFactory: typeof query
+ private readonly now: () => number
+ private readonly ttlMs: number
+ private readonly probeTimeoutMs: number
+ private cachedCatalog: CachedCatalog | null = null
+ private inFlightRefresh: Promise | null = null
+
+ constructor(options: RegistryOptions = {}) {
+ this.queryFactory = options.queryFactory ?? query
+ this.now = options.now ?? Date.now
+ this.ttlMs = options.ttlMs ?? AGENT_CHAT_CAPABILITY_CACHE_TTL_MS
+ this.probeTimeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS
+ }
+
+ static createError(code: string, message: string, retryable: boolean): Error & {
+ code: string
+ retryable: boolean
+ } {
+ return Object.assign(new Error(message), { code, retryable })
+ }
+
+ async getCapabilities(provider: string): Promise {
+ const cached = this.cachedCatalog
+ if (cached && this.now() - cached.fetchedAt <= this.ttlMs) {
+ return this.createSuccess(provider, cached)
+ }
+
+ try {
+ const catalog = await this.refreshCatalog()
+ return this.createSuccess(provider, catalog)
+ } catch (error) {
+ return this.createFailure(error)
+ }
+ }
+
+ async refreshCapabilities(provider: string): Promise {
+ try {
+ const catalog = await this.refreshCatalog()
+ return this.createSuccess(provider, catalog)
+ } catch (error) {
+ return this.createFailure(error)
+ }
+ }
+
+ private async refreshCatalog(): Promise {
+ if (this.inFlightRefresh) {
+ return this.inFlightRefresh
+ }
+
+ const task = this.probeCatalog()
+ .then((catalog) => {
+ this.cachedCatalog = catalog
+ return catalog
+ })
+ .finally(() => {
+ this.inFlightRefresh = null
+ })
+
+ this.inFlightRefresh = task
+ return task
+ }
+
+ private async probeCatalog(): Promise {
+ const abortController = new AbortController()
+ const probeQuery = this.queryFactory({
+ prompt: (async function* emptyPrompt() {})(),
+ options: createClaudeSdkOptions({
+ abortController,
+ stderr: (data: string) => {
+ log.warn({ data: data.trimEnd() }, 'Capability probe stderr')
+ },
+ }),
+ }) as ProbeQuery
+ let timeoutId: ReturnType | undefined
+
+ try {
+ const rawModels = await Promise.race([
+ Promise.resolve(probeQuery.supportedModels()),
+ new Promise((_, reject) => {
+ timeoutId = setTimeout(() => {
+ abortController.abort()
+ reject(AgentChatCapabilityRegistry.createError(
+ 'CAPABILITY_PROBE_FAILED',
+ `Capability probe timed out after ${this.probeTimeoutMs}ms`,
+ true,
+ ))
+ }, this.probeTimeoutMs)
+ }),
+ ])
+ return {
+ fetchedAt: this.now(),
+ models: normalizeAgentChatCapabilityCatalog(rawModels),
+ }
+ } catch (error) {
+ if (error instanceof Error && 'code' in error && 'retryable' in error) {
+ throw error
+ }
+ throw AgentChatCapabilityRegistry.createError(
+ 'CAPABILITY_PROBE_FAILED',
+ error instanceof Error ? error.message : String(error),
+ true,
+ )
+ } finally {
+ if (timeoutId !== undefined) {
+ clearTimeout(timeoutId)
+ }
+ try {
+ await Promise.resolve(probeQuery.close())
+ } catch (closeError) {
+ log.warn({ err: closeError }, 'Capability probe close failed')
+ }
+ }
+ }
+
+ private createSuccess(provider: string, catalog: CachedCatalog): AgentChatCapabilitiesResponse {
+ return AgentChatCapabilitiesResponseSchema.parse({
+ ok: true,
+ capabilities: AgentChatCapabilitiesSchema.parse({
+ provider,
+ fetchedAt: catalog.fetchedAt,
+ models: catalog.models,
+ }),
+ })
+ }
+
+ private createFailure(error: unknown): AgentChatCapabilitiesResponse {
+ const normalized = AgentChatCapabilityErrorSchema.parse({
+ code: typeof (error as { code?: unknown })?.code === 'string'
+ ? (error as { code: string }).code
+ : 'CAPABILITY_PROBE_FAILED',
+ message: error instanceof Error ? error.message : String(error),
+ retryable: typeof (error as { retryable?: unknown })?.retryable === 'boolean'
+ ? (error as { retryable: boolean }).retryable
+ : true,
+ })
+
+ return AgentChatCapabilitiesResponseSchema.parse({
+ ok: false,
+ error: normalized,
+ })
+ }
+}
diff --git a/server/index.ts b/server/index.ts
index 46e16845..a19e90f6 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -51,6 +51,8 @@ import { checkForUpdate, createCachedUpdateChecker } from './updater/version-che
import { SessionAssociationCoordinator } from './session-association-coordinator.js'
import { collectAppliedSessionAssociations } from './session-association-updates.js'
import { loadOrCreateServerInstanceId } from './instance-id.js'
+import { createAgentChatCapabilitiesRouter } from './agent-chat-capabilities-router.js'
+import { AgentChatCapabilityRegistry } from './agent-chat-capability-registry.js'
import { createSettingsRouter } from './settings-router.js'
import { createPerfRouter } from './perf-router.js'
import { createAiRouter } from './ai-router.js'
@@ -193,6 +195,7 @@ async function main() {
const sessionRepairService = getSessionRepairService({ skipDiscovery: true })
const serverInstanceId = await loadOrCreateServerInstanceId()
+ const agentChatCapabilityRegistry = new AgentChatCapabilityRegistry()
let sdkBridge: SdkBridge
@@ -437,6 +440,9 @@ async function main() {
applyDebugLogging,
validCliProviders: allCliNames,
}))
+ app.use('/api/agent-chat/capabilities', createAgentChatCapabilitiesRouter({
+ registry: agentChatCapabilityRegistry,
+ }))
// --- Network management endpoints ---
app.use('/api', createNetworkRouter({
diff --git a/server/sdk-bridge.ts b/server/sdk-bridge.ts
index 8a72d4b7..9a8b5ca3 100644
--- a/server/sdk-bridge.ts
+++ b/server/sdk-bridge.ts
@@ -9,11 +9,12 @@ import {
type SDKPartialAssistantMessage,
type SDKStatusMessage,
type Query as SdkQuery,
+ type Options as SdkOptions,
+ type CanUseTool,
} from '@anthropic-ai/claude-agent-sdk'
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
import { buildMcpServerCommandArgs } from './mcp/config-writer.js'
import { sanitizeAgentChatPluginPaths } from '../shared/agent-chat-plugins.js'
-import { formatModelDisplayName } from '../shared/format-model-name.js'
import { logger } from './logger.js'
import { synthesizeLiveMessageId } from './agent-timeline/ledger.js'
import type { AgentHistorySource } from './agent-timeline/history-source.js'
@@ -45,10 +46,66 @@ interface SessionProcess {
inputStream: InputStreamHandle
}
+type ClaudeSdkOptionsInput = {
+ cwd?: string
+ resumeSessionId?: string
+ model?: string
+ permissionMode?: string
+ effort?: string
+ plugins?: string[]
+ abortController?: AbortController
+ includePartialMessages?: boolean
+ stderr?: (data: string) => void
+ canUseTool?: CanUseTool
+ env?: NodeJS.ProcessEnv
+}
+
+export function createClaudeSdkCleanEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
+ const { CLAUDECODE: _, ANTHROPIC_API_KEY: __, ...cleanEnv } = env
+ return cleanEnv
+}
+
+export function createClaudeSdkMcpServers(env: NodeJS.ProcessEnv = process.env): NonNullable {
+ return {
+ freshell: {
+ command: 'node',
+ args: buildMcpServerCommandArgs(),
+ env: {
+ FRESHELL_URL: env.FRESHELL_URL || `http://localhost:${Number(env.PORT || 3001)}`,
+ FRESHELL_TOKEN: env.AUTH_TOKEN || '',
+ },
+ },
+ }
+}
+
+export function createClaudeSdkOptions(input: ClaudeSdkOptionsInput): SdkOptions {
+ const options: SdkOptions = {
+ cwd: input.cwd || undefined,
+ resume: input.resumeSessionId,
+ model: input.model,
+ permissionMode: input.permissionMode as any,
+ effort: input.effort as any,
+ pathToClaudeCodeExecutable: process.env.CLAUDE_CMD || undefined,
+ includePartialMessages: input.includePartialMessages,
+ abortController: input.abortController,
+ env: createClaudeSdkCleanEnv(input.env),
+ mcpServers: createClaudeSdkMcpServers(input.env),
+ stderr: input.stderr,
+ canUseTool: input.canUseTool,
+ settingSources: ['user', 'project', 'local'],
+ }
+
+ if (input.plugins !== undefined) {
+ options.plugins = sanitizeAgentChatPluginPaths(input.plugins)
+ .map((pluginPath) => ({ type: 'local' as const, path: pluginPath }))
+ }
+
+ return options
+}
+
export class SdkBridge extends EventEmitter {
private sessions = new Map()
private processes = new Map()
- private cachedModels: Array<{ value: string; displayName: string; description: string }> | null = null
constructor(private readonly agentHistorySource?: AgentHistorySource) {
super()
@@ -100,7 +157,7 @@ export class SdkBridge extends EventEmitter {
resumeSessionId?: string
model?: string
permissionMode?: string
- effort?: 'low' | 'medium' | 'high' | 'max'
+ effort?: string
plugins?: string[]
}): Promise {
const sessionId = nanoid()
@@ -126,34 +183,17 @@ export class SdkBridge extends EventEmitter {
const abortController = new AbortController()
const { iterable: inputIterable, handle: inputStream } = this.createInputStream()
- // Strip env vars that interfere with child Claude Code subprocess behaviour:
- // - CLAUDECODE: causes child to refuse startup ("nested session" error)
- // - ANTHROPIC_API_KEY: inherited keys override subscription/OAuth auth,
- // causing "Invalid API key" errors when the key is stale or invalid
- const { CLAUDECODE: _, ANTHROPIC_API_KEY: __, ...cleanEnv } = process.env
-
const sdkQuery = query({
prompt: inputIterable as AsyncIterable,
- options: {
- cwd: options.cwd || undefined,
- resume: options.resumeSessionId,
+ options: createClaudeSdkOptions({
+ cwd: options.cwd,
+ resumeSessionId: options.resumeSessionId,
model: options.model,
- permissionMode: options.permissionMode as any,
+ permissionMode: options.permissionMode,
effort: options.effort,
- pathToClaudeCodeExecutable: process.env.CLAUDE_CMD || undefined,
- includePartialMessages: true,
+ plugins: options.plugins,
abortController,
- env: cleanEnv,
- mcpServers: {
- freshell: {
- command: 'node',
- args: buildMcpServerCommandArgs(),
- env: {
- FRESHELL_URL: process.env.FRESHELL_URL || `http://localhost:${Number(process.env.PORT || 3001)}`,
- FRESHELL_TOKEN: process.env.AUTH_TOKEN || '',
- },
- },
- },
+ includePartialMessages: true,
stderr: (data: string) => {
log.warn({ sessionId, data: data.trimEnd() }, 'SDK subprocess stderr')
},
@@ -168,19 +208,7 @@ export class SdkBridge extends EventEmitter {
}
return this.handlePermissionRequest(sessionId, toolName, input as Record, ctx)
},
- settingSources: ['user', 'project', 'local'],
- // Explicit plugins remain supported for non-Freshell Claude SDK bundles.
- // Freshell orchestration itself is provided by the MCP server above.
- ...((() => {
- if (options.plugins !== undefined) {
- return {
- plugins: sanitizeAgentChatPluginPaths(options.plugins)
- .map(p => ({ type: 'local' as const, path: p })),
- }
- }
- return {}
- })()),
- },
+ }),
})
this.processes.set(sessionId, {
@@ -349,9 +377,6 @@ export class SdkBridge extends EventEmitter {
cwd: state.cwd,
tools: state.tools,
})
-
- // Fetch available models and broadcast to client
- this.fetchAndBroadcastModels(sessionId)
} else if (msg.subtype === 'status') {
const statusMsg = msg as SDKStatusMessage
if (statusMsg.status === 'compacting') {
@@ -750,41 +775,6 @@ export class SdkBridge extends EventEmitter {
return true
}
- private fetchAndBroadcastModels(sessionId: string): void {
- // Use cache if available
- if (this.cachedModels) {
- this.broadcastToSession(sessionId, {
- type: 'sdk.models',
- sessionId,
- models: this.cachedModels,
- })
- return
- }
-
- const sp = this.processes.get(sessionId)
- if (!sp) return
-
- sp.query.supportedModels().then((models) => {
- const mapped = models.map((m: any) => {
- const value = m.value ?? m.id ?? String(m)
- const rawName = m.displayName ?? m.display_name ?? value
- return {
- value,
- displayName: formatModelDisplayName(rawName),
- description: m.description ?? '',
- }
- })
- this.cachedModels = mapped
- this.broadcastToSession(sessionId, {
- type: 'sdk.models',
- sessionId,
- models: mapped,
- })
- }).catch((err) => {
- log.warn({ sessionId, err }, 'Failed to fetch supported models')
- })
- }
-
close(): void {
for (const [sessionId] of this.processes) {
this.killSession(sessionId)
diff --git a/server/settings-router.ts b/server/settings-router.ts
index c584aa76..fc12db4f 100644
--- a/server/settings-router.ts
+++ b/server/settings-router.ts
@@ -69,6 +69,24 @@ export const normalizeSettingsPatch = (patch: Record) => {
}
}
+ if (patch.agentChat?.providers && typeof patch.agentChat.providers === 'object') {
+ for (const providerPatch of Object.values(patch.agentChat.providers)) {
+ if (!providerPatch || typeof providerPatch !== 'object' || Array.isArray(providerPatch)) {
+ continue
+ }
+ const providerPatchRecord = providerPatch as Record
+ if (Object.prototype.hasOwnProperty.call(providerPatchRecord, 'modelSelection') && providerPatchRecord.modelSelection === null) {
+ providerPatchRecord.modelSelection = undefined
+ }
+ if (Object.prototype.hasOwnProperty.call(providerPatchRecord, 'effort')) {
+ const raw = providerPatchRecord.effort
+ if (raw === null || (typeof raw === 'string' && raw.trim() === '')) {
+ providerPatchRecord.effort = undefined
+ }
+ }
+ }
+ }
+
return patch
}
diff --git a/shared/agent-chat-capabilities.ts b/shared/agent-chat-capabilities.ts
new file mode 100644
index 00000000..3f64ced4
--- /dev/null
+++ b/shared/agent-chat-capabilities.ts
@@ -0,0 +1,60 @@
+import { z } from 'zod'
+
+export const AGENT_CHAT_CAPABILITY_CACHE_TTL_MS = 5 * 60 * 1000
+
+export const AgentChatOpaqueStringSchema = z.string().trim().min(1)
+
+export const AgentChatTrackedModelSelectionSchema = z.object({
+ kind: z.literal('tracked'),
+ modelId: AgentChatOpaqueStringSchema,
+}).strict()
+
+export const AgentChatExactModelSelectionSchema = z.object({
+ kind: z.literal('exact'),
+ modelId: AgentChatOpaqueStringSchema,
+}).strict()
+
+export const AgentChatModelSelectionSchema = z.discriminatedUnion('kind', [
+ AgentChatTrackedModelSelectionSchema,
+ AgentChatExactModelSelectionSchema,
+])
+
+export const AgentChatModelCapabilitySchema = z.object({
+ id: AgentChatOpaqueStringSchema,
+ displayName: AgentChatOpaqueStringSchema,
+ description: z.string().optional(),
+ supportsEffort: z.boolean(),
+ supportedEffortLevels: z.array(AgentChatOpaqueStringSchema),
+ supportsAdaptiveThinking: z.boolean(),
+}).strict()
+
+export const AgentChatCapabilitiesSchema = z.object({
+ provider: AgentChatOpaqueStringSchema,
+ fetchedAt: z.number().int().nonnegative(),
+ models: z.array(AgentChatModelCapabilitySchema),
+}).strict()
+
+export const AgentChatCapabilityErrorSchema = z.object({
+ code: AgentChatOpaqueStringSchema,
+ message: AgentChatOpaqueStringSchema,
+ retryable: z.boolean().optional(),
+}).strict()
+
+export const AgentChatCapabilitiesResponseSchema = z.discriminatedUnion('ok', [
+ z.object({
+ ok: z.literal(true),
+ capabilities: AgentChatCapabilitiesSchema,
+ }).strict(),
+ z.object({
+ ok: z.literal(false),
+ error: AgentChatCapabilityErrorSchema,
+ }).strict(),
+])
+
+export type AgentChatModelSelection = z.infer
+export type AgentChatTrackedModelSelection = z.infer
+export type AgentChatExactModelSelection = z.infer
+export type AgentChatModelCapability = z.infer
+export type AgentChatCapabilities = z.infer
+export type AgentChatCapabilityError = z.infer
+export type AgentChatCapabilitiesResponse = z.infer
diff --git a/shared/settings.ts b/shared/settings.ts
index 450aaa8c..71ce9532 100644
--- a/shared/settings.ts
+++ b/shared/settings.ts
@@ -1,5 +1,10 @@
import { z } from 'zod'
+import {
+ AgentChatModelSelectionSchema,
+ AgentChatOpaqueStringSchema,
+ type AgentChatModelSelection,
+} from './agent-chat-capabilities.js'
import { sanitizeAgentChatPluginPaths } from './agent-chat-plugins.js'
import { DEFAULT_ENABLED_CLI_PROVIDERS } from './coding-cli-defaults.js'
import { normalizeTrimmedStringList } from './string-list.js'
@@ -27,7 +32,6 @@ export const CODEX_SANDBOX_VALUES = ['read-only', 'workspace-write', 'danger-ful
export const CLAUDE_PERMISSION_MODE_VALUES = ['default', 'plan', 'acceptEdits', 'bypassPermissions'] as const
const EXTERNAL_EDITOR_VALUES = ['auto', 'cursor', 'code', 'custom'] as const
const NETWORK_HOST_VALUES = ['127.0.0.1', '0.0.0.0'] as const
-const AGENT_CHAT_EFFORT_VALUES = ['low', 'medium', 'high', 'max'] as const
const UI_SCALE_MIN = 0.75
const UI_SCALE_MAX = 1.5
const TERMINAL_FONT_SIZE_MIN = 12
@@ -81,7 +85,7 @@ export type CodexSandboxMode = (typeof CODEX_SANDBOX_VALUES)[number]
export type ClaudePermissionMode = (typeof CLAUDE_PERMISSION_MODE_VALUES)[number]
export type ExternalEditor = (typeof EXTERNAL_EDITOR_VALUES)[number]
export type NetworkHost = (typeof NETWORK_HOST_VALUES)[number]
-export type AgentChatEffort = (typeof AGENT_CHAT_EFFORT_VALUES)[number]
+export type AgentChatEffort = string
export type DeepPartial = T extends readonly (infer U)[]
? U[]
@@ -105,9 +109,9 @@ export type CodingCliSettings = {
}
export type AgentChatProviderDefaults = {
- defaultModel?: string
+ modelSelection?: AgentChatModelSelection
defaultPermissionMode?: string
- defaultEffort?: AgentChatEffort
+ effort?: AgentChatEffort
}
export type ServerSettings = {
@@ -231,7 +235,6 @@ const AttentionDismissSchema = z.enum(ATTENTION_DISMISS_VALUES)
const SessionOpenModeSchema = z.enum(SESSION_OPEN_MODE_VALUES)
const ExternalEditorSchema = z.enum(EXTERNAL_EDITOR_VALUES)
const NetworkHostSchema = z.enum(NETWORK_HOST_VALUES)
-const AgentChatEffortSchema = z.enum(AGENT_CHAT_EFFORT_VALUES)
function hasOwn(value: T | undefined | null, key: PropertyKey): boolean {
return !!value && Object.prototype.hasOwnProperty.call(value, key)
@@ -542,12 +545,22 @@ function createCodingCliProviderConfigPatchSchema() {
.strict()
}
+function createAgentChatProviderDefaultsSchema() {
+ return z
+ .object({
+ modelSelection: AgentChatModelSelectionSchema.optional(),
+ defaultPermissionMode: z.string().optional(),
+ effort: AgentChatOpaqueStringSchema.optional(),
+ })
+ .strict()
+}
+
function createAgentChatProviderDefaultsPatchSchema() {
return z
.object({
- defaultModel: z.string().optional(),
+ modelSelection: AgentChatModelSelectionSchema.nullable().optional(),
defaultPermissionMode: z.string().optional(),
- defaultEffort: AgentChatEffortSchema.optional(),
+ effort: z.union([AgentChatOpaqueStringSchema, z.literal('')]).nullable().optional(),
})
.strict()
}
@@ -584,7 +597,7 @@ export function buildServerSettingsSchema(validCliProviders?: readonly string[])
agentChat: z.object({
initialSetupDone: z.boolean().optional(),
defaultPlugins: z.array(z.string()),
- providers: z.record(z.string(), createAgentChatProviderDefaultsPatchSchema()),
+ providers: z.record(z.string(), createAgentChatProviderDefaultsSchema()),
}).strict(),
extensions: z.object({
disabled: z.array(z.string()),
@@ -897,13 +910,29 @@ function sanitizeServerSettingsPatch(patch: ServerSettingsPatch): ServerSettings
if (isRecord(candidate.agentChat.providers)) {
const providers: NonNullable['providers'] = {}
for (const [providerName, providerPatch] of Object.entries(candidate.agentChat.providers)) {
+ const normalizedProviderPatchInput = normalizeLegacyAgentChatProviderDefaultsInput(providerPatch)
const parsed = agentChatProviderDefaultsPatchSchema.safeParse(
- isRecord(providerPatch)
- ? pickKeys(providerPatch, ['defaultModel', 'defaultPermissionMode', 'defaultEffort'])
- : providerPatch,
+ normalizedProviderPatchInput,
)
- if (parsed.success && Object.keys(parsed.data).length > 0) {
- providers[providerName] = parsed.data
+ if (
+ parsed.success
+ && isRecord(normalizedProviderPatchInput)
+ && Object.keys(normalizedProviderPatchInput).length > 0
+ ) {
+ const normalizedProviderPatch: AgentChatProviderDefaults = {}
+ if (hasOwn(normalizedProviderPatchInput, 'modelSelection')) {
+ normalizedProviderPatch.modelSelection = parsed.data.modelSelection ?? undefined
+ }
+ if (hasOwn(normalizedProviderPatchInput, 'defaultPermissionMode')) {
+ normalizedProviderPatch.defaultPermissionMode = parsed.data.defaultPermissionMode
+ }
+ if (hasOwn(normalizedProviderPatchInput, 'effort')) {
+ const parsedEffort = parsed.data.effort
+ normalizedProviderPatch.effort = typeof parsedEffort === 'string' && parsedEffort.trim().length === 0
+ ? undefined
+ : parsedEffort ?? undefined
+ }
+ providers[providerName] = normalizedProviderPatch
}
}
if (Object.keys(providers).length > 0) {
@@ -946,10 +975,46 @@ function sanitizeServerSettingsPatch(patch: ServerSettingsPatch): ServerSettings
return sanitized
}
+function normalizeLegacyAgentChatProviderDefaultsInput(
+ providerPatch: unknown,
+): Record | unknown {
+ if (!isRecord(providerPatch)) {
+ return providerPatch
+ }
+
+ const normalized = pickOwnKeysPreservingUndefined(
+ providerPatch,
+ ['modelSelection', 'defaultPermissionMode', 'effort'],
+ )
+
+ if (
+ !hasOwn(normalized, 'modelSelection')
+ && typeof providerPatch.defaultModel === 'string'
+ && providerPatch.defaultModel.trim().length > 0
+ ) {
+ normalized.modelSelection = {
+ kind: 'exact',
+ modelId: providerPatch.defaultModel,
+ }
+ }
+
+ if (
+ !hasOwn(normalized, 'effort')
+ && typeof providerPatch.defaultEffort === 'string'
+ && providerPatch.defaultEffort.trim().length > 0
+ ) {
+ normalized.effort = providerPatch.defaultEffort
+ }
+
+ return normalized
+}
+
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
return {
...base,
@@ -981,11 +1046,11 @@ export function mergeServerSettings(base: ServerSettings, patch: ServerSettingsP
},
editor: mergeDefined(base.editor, normalizedPatch.editor),
agentChat: {
- ...mergeDefined(base.agentChat, agentChatPatch),
- defaultPlugins: hasOwn(agentChatPatch, 'defaultPlugins')
- ? sanitizeAgentChatPluginPaths(agentChatPatch?.defaultPlugins)
+ ...mergeDefined(base.agentChat, normalizedAgentChatPatch),
+ defaultPlugins: hasOwn(normalizedAgentChatPatch, 'defaultPlugins')
+ ? sanitizeAgentChatPluginPaths(normalizedAgentChatPatch?.defaultPlugins)
: base.agentChat.defaultPlugins,
- providers: mergeRecordOfObjects(base.agentChat.providers, agentChatPatch?.providers),
+ providers: mergeRecordOfObjects(base.agentChat.providers, normalizedAgentChatProvidersPatch),
},
extensions: {
disabled: hasOwn(normalizedPatch.extensions, 'disabled')
diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts
index e4f39f30..4dbf1efb 100644
--- a/shared/ws-protocol.ts
+++ b/shared/ws-protocol.ts
@@ -323,7 +323,7 @@ export const SdkCreateSchema = z.object({
resumeSessionId: z.string().optional(),
model: z.string().optional(),
permissionMode: z.string().optional(),
- effort: z.enum(['low', 'medium', 'high', 'max']).optional(),
+ effort: z.string().trim().min(1).optional(),
plugins: z.array(z.string()).optional(),
})
@@ -687,7 +687,6 @@ export type SdkServerMessage =
| { type: 'sdk.error'; sessionId: string; message: string; code?: string }
| { type: 'sdk.exit'; sessionId: string; exitCode?: number }
| { type: 'sdk.killed'; sessionId: string; success: boolean }
- | { type: 'sdk.models'; sessionId: string; models: Array<{ value: string; displayName: string; description: string }> }
| { type: 'sdk.question.request'; sessionId: string; requestId: string; questions: Array<{ question: string; header: string; options: Array<{ label: string; description: string }>; multiSelect: boolean }> }
// -- Extensions --
diff --git a/src/components/TabsView.tsx b/src/components/TabsView.tsx
index a7750316..8dbb535a 100644
--- a/src/components/TabsView.tsx
+++ b/src/components/TabsView.tsx
@@ -26,7 +26,12 @@ import { copyText } from '@/lib/clipboard'
import { cn } from '@/lib/utils'
import { ContextMenu } from '@/components/context-menu/ContextMenu'
import type { MenuItem } from '@/components/context-menu/context-menu-types'
-import type { PaneContentInput, SessionLocator } from '@/store/paneTypes'
+import {
+ normalizeAgentChatEffortOverride,
+ normalizeAgentChatModelSelection,
+ type PaneContentInput,
+ type SessionLocator,
+} from '@/store/paneTypes'
import type { CodingCliProviderName, TabMode } from '@/store/types'
import type { AgentChatProviderName } from '@/lib/agent-chat-types'
@@ -135,9 +140,9 @@ function sanitizePaneSnapshot(
resumeSessionId: sameServer ? resumeSessionId : undefined,
sessionRef,
initialCwd: payload.initialCwd as string | undefined,
- model: payload.model as string | undefined,
+ modelSelection: normalizeAgentChatModelSelection(payload.modelSelection, payload.model),
permissionMode: payload.permissionMode as string | undefined,
- effort: payload.effort as 'low' | 'medium' | 'high' | 'max' | undefined,
+ effort: normalizeAgentChatEffortOverride(payload.effort),
plugins: payload.plugins as string[] | undefined,
}
}
diff --git a/src/components/agent-chat/AgentChatSettings.tsx b/src/components/agent-chat/AgentChatSettings.tsx
index f5508027..8155ea03 100644
--- a/src/components/agent-chat/AgentChatSettings.tsx
+++ b/src/components/agent-chat/AgentChatSettings.tsx
@@ -1,14 +1,17 @@
-import { useCallback, useEffect, useId, useRef, useState } from 'react'
+import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { Settings } from 'lucide-react'
-import { cn } from '@/lib/utils'
+
import { Switch } from '@/components/ui/switch'
import { useMobile } from '@/hooks/useMobile'
import { useKeyboardInset } from '@/hooks/useKeyboardInset'
-import type { AgentChatPaneContent } from '@/store/paneTypes'
+import type { AgentChatSettingsModelOption } from '@/lib/agent-chat-capabilities'
import type { AgentChatProviderConfig } from '@/lib/agent-chat-types'
-import { formatModelDisplayName } from '../../../shared/format-model-name'
+import { cn } from '@/lib/utils'
+import type { AgentChatPaneContent } from '@/store/paneTypes'
+import type { AgentChatProviderCapabilitiesState } from '@/store/agentChatTypes'
-type SettingsFields = Pick & {
+type SettingsFields = Pick & {
+ model?: string
showThinking?: boolean
showTools?: boolean
showTimecodes?: boolean
@@ -23,32 +26,22 @@ interface AgentChatSettingsProps {
showTimecodes: boolean
sessionStarted: boolean
defaultOpen?: boolean
- modelOptions?: Array<{ value: string; displayName: string }>
+ modelOptions?: AgentChatSettingsModelOption[]
+ effortOptions?: string[]
+ capabilitiesStatus?: AgentChatProviderCapabilitiesState['status']
+ capabilityError?: AgentChatProviderCapabilitiesState['error']
settingsVisibility?: AgentChatProviderConfig['settingsVisibility']
onChange: (changes: Partial) => void
onDismiss?: () => void
+ onRetryCapabilities?: () => void
+ onOpenChange?: (open: boolean) => void
}
-const MODEL_OPTIONS = [
- { value: 'claude-opus-4-6', label: 'Opus 4.6' },
- { value: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },
- { value: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' },
- { value: 'claude-sonnet-4-5-20250929', label: 'Sonnet 4.5' },
- { value: 'claude-opus-4-5', label: 'Opus 4.5' },
-]
-
const PERMISSION_OPTIONS = [
{ value: 'bypassPermissions', label: 'Skip permissions' },
{ value: 'default', label: 'Default (ask)' },
]
-const EFFORT_OPTIONS = [
- { value: 'low', label: 'Low' },
- { value: 'medium', label: 'Medium' },
- { value: 'high', label: 'High' },
- { value: 'max', label: 'Max' },
-]
-
export default function AgentChatSettings({
model,
permissionMode,
@@ -59,9 +52,14 @@ export default function AgentChatSettings({
sessionStarted,
defaultOpen = false,
modelOptions,
+ effortOptions,
+ capabilitiesStatus = 'idle',
+ capabilityError,
settingsVisibility,
onChange,
onDismiss,
+ onRetryCapabilities,
+ onOpenChange,
}: AgentChatSettingsProps) {
const instanceId = useId()
const isMobile = useMobile()
@@ -70,12 +68,14 @@ export default function AgentChatSettings({
const popoverRef = useRef(null)
const buttonRef = useRef(null)
- // Sync open state when defaultOpen changes (e.g. after settings load,
- // or when initialSetupDone arrives after the 2s timeout fallback)
useEffect(() => {
setOpen(defaultOpen)
}, [defaultOpen])
+ useEffect(() => {
+ onOpenChange?.(open)
+ }, [onOpenChange, open])
+
const handleClose = useCallback(() => {
setOpen(false)
onDismiss?.()
@@ -84,76 +84,49 @@ export default function AgentChatSettings({
const handleToggle = useCallback(() => {
if (open) {
handleClose()
- } else {
- setOpen(true)
+ return
}
- }, [open, handleClose])
+ setOpen(true)
+ }, [handleClose, open])
- // Close on click outside
useEffect(() => {
if (!open) return
- const handleMouseDown = (e: MouseEvent) => {
- const target = e.target as Node
+
+ const handleMouseDown = (event: MouseEvent) => {
+ const target = event.target as Node
if (
- popoverRef.current && !popoverRef.current.contains(target) &&
- buttonRef.current && !buttonRef.current.contains(target)
+ popoverRef.current && !popoverRef.current.contains(target)
+ && buttonRef.current && !buttonRef.current.contains(target)
) {
handleClose()
}
}
+
document.addEventListener('mousedown', handleMouseDown)
return () => document.removeEventListener('mousedown', handleMouseDown)
- }, [open, handleClose])
+ }, [handleClose, open])
- // Close on Escape key — uses document listener so it works regardless of focus location
useEffect(() => {
if (!open) return
- const handleEscape = (e: KeyboardEvent) => {
- if (e.key === 'Escape') {
- e.stopPropagation()
- handleClose()
- }
- }
- document.addEventListener('keydown', handleEscape)
- return () => document.removeEventListener('keydown', handleEscape)
- }, [open, handleClose])
- // Start with the hardcoded list, then append any dynamic models not already present.
- // When the SDK provides a model whose normalized label matches a hardcoded entry
- // (e.g. a newer dated ID like claude-sonnet-4-5-20251101 → "Sonnet 4.5"), replace
- // the hardcoded value so users get the current model ID without duplicates.
- const resolvedModelOptions = (() => {
- if (!modelOptions) return MODEL_OPTIONS
-
- // Map normalized label → SDK value for models with full claude-* IDs.
- // When multiple dated IDs map to the same label, keep the latest one.
- const sdkByLabel = new Map()
- for (const m of modelOptions) {
- if (m.value.startsWith('claude-')) {
- const label = formatModelDisplayName(m.displayName)
- const existing = sdkByLabel.get(label)
- if (!existing || m.value > existing) {
- sdkByLabel.set(label, m.value)
- }
- }
+ const handleEscape = (event: KeyboardEvent) => {
+ if (event.key !== 'Escape') return
+ event.stopPropagation()
+ handleClose()
}
- // Replace hardcoded values with SDK values when labels match
- const usedLabels = new Set()
- const base = MODEL_OPTIONS.map((m) => {
- usedLabels.add(m.label)
- const sdkValue = sdkByLabel.get(m.label)
- return sdkValue ? { value: sdkValue, label: m.label } : m
- })
-
- // Append genuinely new SDK models not matching any hardcoded label
- const extras = modelOptions
- .filter((m) => m.value.startsWith('claude-'))
- .map((m) => ({ value: m.value, label: formatModelDisplayName(m.displayName) }))
- .filter((m) => !usedLabels.has(m.label))
+ document.addEventListener('keydown', handleEscape)
+ return () => document.removeEventListener('keydown', handleEscape)
+ }, [handleClose, open])
- return [...base, ...extras]
- })()
+ const resolvedModelOptions = modelOptions ?? []
+ const selectedModelOption = useMemo(
+ () => resolvedModelOptions.find((option) => option.value === model),
+ [model, resolvedModelOptions],
+ )
+ const resolvedEffortOptions = effortOptions ?? []
+ const showCapabilityControls = capabilitiesStatus !== 'failed'
+ const showEffortControl = resolvedEffortOptions.length > 0
return (