From 1671ebb62e5e1fb0aad612fa174b9db88a1ff84f Mon Sep 17 00:00:00 2001 From: Sasha Sobol Date: Sun, 8 Mar 2026 20:16:19 +0000 Subject: [PATCH 1/2] Add reentry plans and local Node bootstrap --- .envrc | 2 + .gitignore | 2 + .node-version | 1 + .plans/17-project-reentry-engine.md | 378 ++++++++++++++++++ .../18-attention-inbox-smart-notifications.md | 216 ++++++++++ .../19-claude-code-support-design-review.md | 120 ++++++ .plans/20-monitor-visuals-design-review.md | 116 ++++++ .plans/21-chat-widgets-design-review.md | 137 +++++++ .plans/README.md | 8 + scripts/dev-local-desktop.sh | 4 + scripts/dev-local.sh | 4 + scripts/install-local-node.sh | 56 +++ scripts/with-local-node.sh | 11 + 13 files changed, 1055 insertions(+) create mode 100644 .envrc create mode 100644 .node-version create mode 100644 .plans/17-project-reentry-engine.md create mode 100644 .plans/18-attention-inbox-smart-notifications.md create mode 100644 .plans/19-claude-code-support-design-review.md create mode 100644 .plans/20-monitor-visuals-design-review.md create mode 100644 .plans/21-chat-widgets-design-review.md create mode 100755 scripts/dev-local-desktop.sh create mode 100755 scripts/dev-local.sh create mode 100755 scripts/install-local-node.sh create mode 100755 scripts/with-local-node.sh diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..564585a6e --- /dev/null +++ b/.envrc @@ -0,0 +1,2 @@ +# Repo-local Node runtime installed by ./scripts/install-local-node.sh +PATH_add .local/node/bin diff --git a/.gitignore b/.gitignore index 8478eb6fc..6493ad28e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ release/ apps/web/.playwright apps/web/playwright-report apps/web/src/components/__screenshots__ +.direnv +.local/ diff --git a/.node-version b/.node-version new file mode 100644 index 000000000..32f8c50de --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.13.1 diff --git a/.plans/17-project-reentry-engine.md b/.plans/17-project-reentry-engine.md new file mode 100644 index 000000000..758670aaf --- /dev/null +++ b/.plans/17-project-reentry-engine.md @@ -0,0 +1,378 @@ +# Plan: Add a Project Reentry Engine (Capture → Memory → Presentation) + +## Summary + +Add a server-authoritative reentry system that helps users get back into shape after a context switch. + +The system should support three different reentry windows: + +- **Hot handoff** — after minutes or hours away, answer “what was I doing?” +- **Warm recap** — after days or weeks away, answer “what changed, what is blocked, what should I do next?” +- **Cold-start brief** — after months or a year away, answer “what is this project, why does it exist, and where should I restart?” + +The implementation should keep deterministic facts separate from model-written narrative. T3 Code already has the right raw ingredients — orchestration snapshots, thread activities, proposed plans, turn diffs, git state, and provider session recovery — but it does not yet have a project-level memory model or a dedicated reentry surface. + +## Goals + +- Let users reenter work efficiently after `20m`, `1w`, and `1y` gaps. +- Keep reentry state server-authoritative and recoverable across restarts. +- Separate raw capture from derived memory and UI presentation. +- Distinguish **thread/task memory** from **project memory**. +- Use LLMs to write readable recaps, while keeping facts and triggers deterministic. +- Make Codex the default recap writer, with optional Gemini API and Claude Code backends. + +## Non-Goals + +- Replacing the full transcript or diff views. +- Treating model output as canonical truth. +- Building a full issue tracker or project-management suite. +- Adding cross-device push as part of the first slice. + +## Reentry Windows + +### Hot handoff + +For short gaps, preserve precision: + +- active thread +- branch / worktree +- latest turn status +- last changed files +- next exact step +- pending approvals / user input + +### Warm recap + +For days or weeks away, compress the current arc: + +- project gist +- recent progress +- open loops +- blockers / waiting state +- suggested next move + +### Cold-start brief + +For months or a year away, emphasize orientation: + +- what the project is +- why it matters +- key architecture and conventions +- major recent changes +- safe first place to read / act + +## Three Layers + +### 1. Capture Layer + +The Capture layer converts runtime signals into structured evidence. + +Primary sources already available in the codebase: + +- orchestration snapshot and domain events from `apps/server/src/orchestration` +- thread activities / plans / checkpoints in the projection repositories +- provider session runtime and resume state +- git branch / worktree / PR status surfaces +- future external adapters for CI, issues, and reviews + +Responsibilities: + +- collect raw signals +- normalize them into stable event envelopes +- tag each signal with scope (`thread`, `project`, `external`) +- maintain source anchors for later drill-down +- remain deterministic and cheap + +Proposed server modules: + +- `apps/server/src/reentry/Services/CapturePipeline.ts` +- `apps/server/src/reentry/Services/ProjectSignalCollector.ts` +- `apps/server/src/reentry/Services/ThreadSignalCollector.ts` +- `apps/server/src/reentry/Services/ExternalSignalCollector.ts` + +### 2. Memory Layer + +The Memory layer turns captured evidence into durable, user-facing memory objects. + +This is where the new recap and attention objects live: + +- `EpisodeRecap` +- `ProjectArcRecap` +- `ProjectCanon` +- `OpenLoopRegistry` +- `AttentionInbox` + +Responsibilities: + +- persist recap objects with versions and provenance +- update open loops incrementally +- track which recap freshness tier is current +- support re-generation when the project goes stale +- support drill-down from recap bullet → source event / thread / diff + +Proposed server modules: + +- `apps/server/src/reentry/Services/ReentryMemory.ts` +- `apps/server/src/reentry/Services/EpisodeRecapStore.ts` +- `apps/server/src/reentry/Services/ProjectArcRecapStore.ts` +- `apps/server/src/reentry/Services/ProjectCanonStore.ts` +- `apps/server/src/reentry/Services/OpenLoopRegistry.ts` + +### 3. Presentation Layer + +The Presentation layer renders reentry state as an inbox and layered project views. + +Responsibilities: + +- show project cards sorted by attention +- render hot / warm / cold recap variants +- show “since last visit” and “next best action” affordances +- let users expand into timeline, thread, diff, and file views +- make recap objects readable on web and desktop without requiring chat scroll archaeology + +Proposed web modules: + +- `apps/web/src/routes/_chat.reentry.tsx` +- `apps/web/src/components/ReentryInbox.tsx` +- `apps/web/src/components/ProjectRecapCard.tsx` +- `apps/web/src/components/ProjectCanonPanel.tsx` +- `apps/web/src/components/OpenLoopsPanel.tsx` +- `apps/web/src/lib/reentryReactQuery.ts` + +The recap and inbox data should be exposed through dedicated ws queries instead of being appended to the main orchestration snapshot. The current root route re-syncs the full snapshot on domain events, so recap payload growth inside the main snapshot would add avoidable cost and churn. + +## Memory Objects + +### `EpisodeRecap` + +Task- or thread-scoped recap for a bounded work episode. + +Suggested fields: + +- `id` +- `projectId` +- `threadId` +- `turnRange` +- `branch` +- `worktreePath` +- `status` +- `gist` +- `whatChanged[]` +- `decisions[]` +- `blockedBy[]` +- `nextStep` +- `importantFiles[]` +- `sourceAnchors[]` +- `generatedAt` +- `generatedBy` +- `freshnessTier` (`hot`, `warm`, `cold`) + +### `ProjectArcRecap` + +Project-level recap that compresses the current initiative across multiple episodes. + +Suggested fields: + +- `id` +- `projectId` +- `title` +- `status` +- `currentGoal` +- `recentProgress[]` +- `openLoops[]` +- `blockers[]` +- `waitingOn[]` +- `nextBestAction` +- `relatedEpisodeIds[]` +- `sourceAnchors[]` +- `generatedAt` + +### `ProjectCanon` + +Long-lived project identity and orientation memory. + +Suggested fields: + +- `projectId` +- `projectGist` +- `whyItExists` +- `architectureSummary` +- `keyFiles[]` +- `importantCommands[]` +- `gotchas[]` +- `glossary[]` +- `validatedAt` +- `sourceAnchors[]` + +`ProjectCanon` should change slowly and be explicitly refreshable when the recap writer detects drift. + +### `OpenLoopRegistry` + +Canonical registry of unresolved work. + +Suggested fields per loop: + +- `id` +- `projectId` +- `threadId?` +- `kind` (`blocked`, `waiting`, `decision`, `review`, `bug`, `cleanup`, `investigate`) +- `summary` +- `owner` (`human`, `agent`, `external`, `unknown`) +- `priority` +- `state` (`open`, `snoozed`, `resolved`, `dropped`) +- `dueAt?` +- `suggestedAction?` +- `sourceAnchors[]` +- `openedAt` +- `updatedAt` +- `resolvedAt?` + +## Model-Driven Writing + +The recap writer should consume structured evidence packets, not ad-hoc prompts over raw chat history. + +Proposed services: + +- `apps/server/src/reentry/Services/ReentryWriter.ts` +- `apps/server/src/reentry/Services/ReentryModelBroker.ts` +- `apps/server/src/reentry/Services/ReentryPromptBuilder.ts` + +### Default behavior + +- use **Codex** as the default recap writer +- run generation jobs after stable boundaries (turn settled, thread idle, project stale) +- require schema-validated outputs from the model writer + +### Optional backends + +- **Gemini API** for direct recap generation when configured +- **Claude Code** once provider support exists in T3 Code + +The broker should choose a backend based on: + +- availability +- cost / latency preferences +- stale age +- task type (`hot`, `warm`, `cold`, canon-refresh) + +## Refresh Strategy + +Do not fully rewrite every recap on every event. + +### Event-driven updates + +Cheap updates should happen continuously: + +- open loop state +- pending approval state +- session failures +- git / PR status +- last touched files + +### Boundary-driven recap generation + +Generate or refresh recaps at meaningful boundaries: + +- latest turn settles +- thread becomes idle for `15–30m` +- project is opened after stale gap +- project crosses `7d`, `30d`, `90d` stale thresholds +- PR merged / closed +- explicit user “refresh recap” action + +## Proposed Changes + +### Contracts + +Add new contracts package modules: + +- `packages/contracts/src/reentry.ts` +- `packages/contracts/src/attention.ts` + +Add schema-only types for: + +- `EpisodeRecap` +- `ProjectArcRecap` +- `ProjectCanon` +- `OpenLoop` +- `AttentionItem` +- `ReentryFreshnessTier` +- recap generation job requests / results + +### Persistence + +Add projection-backed storage for recap and attention objects: + +- `apps/server/src/persistence/Services/ProjectionEpisodeRecaps.ts` +- `apps/server/src/persistence/Services/ProjectionProjectArcRecaps.ts` +- `apps/server/src/persistence/Services/ProjectionProjectCanon.ts` +- `apps/server/src/persistence/Services/ProjectionOpenLoops.ts` +- `apps/server/src/persistence/Services/ProjectionAttentionInbox.ts` + +Add matching migrations after the existing projection tables. + +### Server orchestration + +Add new reentry services and feed them from: + +- `ProjectionSnapshotQuery` +- `ProviderRuntimeIngestion` +- `ProjectionPipeline` +- git status queries and future external adapters + +### Web UI + +Add a dedicated reentry/inbox surface and a compact entry point in the existing sidebar. + +Suggested first UX slice: + +- project card with gist + attention chips +- “since last visit” summary +- `Open loops` panel +- `Next action` button +- `Read more` path into related thread and diff views + +## Implementation Phases + +### Phase 1 — Contracts + storage + +- define recap / open-loop / attention schemas +- add persistence repositories and migrations +- expose read APIs in the existing ws protocol +- keep recap reads separate from `orchestration.getSnapshot` + +### Phase 2 — Deterministic capture + +- build signal collectors from projection state +- derive open loops without LLM dependency +- compute stale windows and refresh triggers + +### Phase 3 — Recap generation + +- add model broker and schema-validated writers +- generate `EpisodeRecap`, then `ProjectArcRecap` +- support manual refresh and stale-triggered refresh + +### Phase 4 — Presentation + +- reentry inbox +- project recap panels +- drill-down links into existing thread / diff views + +### Phase 5 — Canon + long-gap recovery + +- add `ProjectCanon` +- add drift detection and explicit canon refresh +- add year-away cold-start brief UX + +## Risks + +- recap drift or hallucinated claims if prompts are allowed to over-interpret +- churn if model writers rewrite stable summaries too often +- stale canon if refresh triggers are too weak +- cost if cold/warm recap jobs run too aggressively +- overfitting to Codex session semantics before Claude/Gemini are introduced + +## Recommendation + +Build the Capture and Memory layers first, keep them deterministic, then add model-written recap surfaces on top. That keeps the feature valuable even before Codex/Gemini/Claude recap generation is fully polished. diff --git a/.plans/18-attention-inbox-smart-notifications.md b/.plans/18-attention-inbox-smart-notifications.md new file mode 100644 index 000000000..acc97c561 --- /dev/null +++ b/.plans/18-attention-inbox-smart-notifications.md @@ -0,0 +1,216 @@ +# Plan: Add an Attention Inbox and Smart Notifications + +## Summary + +Add an `AttentionInbox` that turns open loops and deterministic signals into a ranked list of projects and threads that need user attention. + +Notifications should be downstream of the inbox, not a separate ad-hoc system. Every alert should answer three questions: + +- what changed +- why it matters now +- what the user should do next + +## Goals + +- rank projects by actionability, not alphabetically +- notify only when attention meaningfully changes +- keep the inbox server-authoritative +- support web toasts and desktop notifications from the same source object +- use models to improve wording and prioritization hints, not to replace deterministic state + +This is especially important because the current unread / completion signaling in the sidebar is partly client-local state. The new inbox should survive hydration and reconnects without depending on browser-only visit markers. + +## Non-Goals + +- sending every event as a notification +- shipping email / SMS / mobile push in the first slice +- depending on a model call to decide whether an approval or session failure is real + +## Core Object: `AttentionItem` + +Suggested fields: + +- `id` +- `projectId` +- `threadId?` +- `scope` (`project`, `thread`, `external`) +- `kind` +- `score` +- `urgency` +- `state` (`active`, `dismissed`, `snoozed`, `resolved`) +- `headline` +- `reason` +- `suggestedAction` +- `sourceAnchors[]` +- `dedupeKey` +- `createdAt` +- `updatedAt` +- `lastNotifiedAt?` +- `snoozedUntil?` + +## Deterministic Signal Sources + +### Existing in T3 Code + +- pending approvals and pending user input +- session start failures / runtime errors +- interrupted turns with unresolved follow-up +- latest turn completed and unseen +- branch / PR state from git status +- worktree orphan cleanup candidates +- stale projects with unresolved open loops + +### Future external adapters + +- PR review comments +- CI failures / recoveries +- issue assignment / mention events +- deploy failures + +## Smart Notification Rules + +Only create or re-notify items when actionability changes. + +High-value examples: + +- a thread is blocked and waiting on human input +- a long-running agent finished a meaningful episode recap +- CI failed on an active branch +- a PR review arrived on the branch tied to an active thread +- a project has gone stale while open loops remain unresolved +- a project canon likely drifted and needs refresh + +Low-value examples that should **not** notify by default: + +- every tool call completion +- every assistant delta +- every git status refresh +- every recap regeneration + +## Scoring Model + +Start deterministic. + +Example score inputs: + +- base severity by `kind` +- recency bonus + +- stale-age multiplier +- open-loop count multiplier +- “waiting on human” bonus +- active branch / active PR match bonus +- snooze penalty + +Then optionally add a model-generated short explanation, but never let the model invent the score from scratch. + +## Stale Review Jobs + +Add scheduled review passes that promote neglected projects back into the inbox. + +Recommended thresholds: + +- `24h` — thread-level warm review +- `7d` — project-level stale review +- `30d` — cold-start brief refresh +- `90d` — deep reacquaintance review + +Each scheduled job should: + +- re-evaluate open loops +- refresh recap freshness state if needed +- add or update an `AttentionItem` only if there is a clear next action or unresolved risk + +## Notification Channels + +### In-app + +- inbox cards +- compact badges in the sidebar +- toasts for immediate attention transitions + +### Desktop + +Add a desktop notification bridge in the Electron app for high-value events only. + +Suggested additions: + +- `apps/desktop/src/main.ts` — notification dispatch +- `apps/desktop/src/preload.ts` — optional acknowledge / focus bridge +- web app surfaces remain driven by the same inbox object + +## Model Assistance + +Use Codex by default, with Gemini API and Claude Code as optional backends, for: + +- writing the one-line `reason` +- suggesting a `next best action` +- classifying recap refresh type (`hot`, `warm`, `cold`) +- collapsing multiple related signals into one human-readable alert + +Do **not** use models for: + +- detecting pending approvals +- deciding whether a session failed +- determining whether a PR exists or CI is failing + +## Proposed Changes + +### Contracts + +Add attention schemas and ws endpoints: + +- `packages/contracts/src/attention.ts` +- `packages/contracts/src/ws.ts` additions for inbox reads / state changes + +### Persistence + +Add projection repositories: + +- `apps/server/src/persistence/Services/ProjectionAttentionInbox.ts` +- `apps/server/src/persistence/Services/ProjectionNotificationState.ts` + +### Server services + +Add: + +- `apps/server/src/reentry/Services/AttentionScorer.ts` +- `apps/server/src/reentry/Services/AttentionInboxProjector.ts` +- `apps/server/src/reentry/Services/NotificationDispatcher.ts` +- `apps/server/src/reentry/Services/StaleReviewScheduler.ts` + +### Web UI + +Add: + +- `apps/web/src/components/AttentionInbox.tsx` +- `apps/web/src/components/AttentionBadge.tsx` +- `apps/web/src/lib/attentionReactQuery.ts` + +## Rollout + +### Phase 1 + +- deterministic inbox items only +- web inbox + sidebar badges + toasts + +### Phase 2 + +- stale review jobs +- desktop notifications + +### Phase 3 + +- model-assisted alert wording +- grouped / deduped recap alerts + +## Risks + +- notification spam if re-scoring is too eager +- stale alerts if resolution detection lags +- user distrust if model-written reasons overstate certainty +- duplicated effort if inbox state diverges from the open-loop registry + +## Recommendation + +Make `AttentionInbox` the single source of truth, and make notifications a view over it. That keeps the system debuggable and makes “why did I get this alert?” answerable from stored state. diff --git a/.plans/19-claude-code-support-design-review.md b/.plans/19-claude-code-support-design-review.md new file mode 100644 index 000000000..75862772d --- /dev/null +++ b/.plans/19-claude-code-support-design-review.md @@ -0,0 +1,120 @@ +# Design Review: Claude Code Support + +## Summary + +T3 Code is clearly being shaped toward multi-provider support, but the current implementation remains codex-first in both contracts and runtime assumptions. Claude Code support should land as a real provider adapter, not as a UI-only toggle. + +## What Already Helps + +- provider service / adapter architecture already exists under `apps/server/src/provider` +- provider session directory and resume binding already exist +- runtime events are normalized through canonical provider runtime ingestion +- the web UI already gestures at unavailable providers in the picker + +These are strong foundations. + +## Where Codex Still Leaks Through + +### Contracts + +`ProviderKind` is still effectively locked to Codex, so any provider abstraction above it is narrower than it appears. + +Areas to widen first: + +- `packages/contracts/src/orchestration.ts` +- `packages/contracts/src/provider.ts` +- `packages/contracts/src/model.ts` + +### Runtime protocol assumptions + +Codex-specific semantics are still embedded in: + +- `apps/server/src/codexAppServerManager.ts` +- `apps/server/src/provider/Layers/CodexAdapter.ts` +- collaboration mode / effort settings +- model selection and account handling +- resume semantics and turn lifecycle mapping + +### UI assumptions + +The UI already lists `claudeCode` as unavailable, but provider capabilities are not yet modeled deeply enough for different approval semantics, tool event shapes, or model option behavior. + +## Recommended Direction + +Treat Claude Code support as a canonical provider implementation with a first-class adapter and a capability matrix. + +### Phase 1 — widen contracts + +- expand `ProviderKind` to include `claudeCode` +- add provider capability contracts: + - model switch mode + - approval support + - user input support + - collaboration / planning support + - resume support level + +### Phase 2 — add adapter + +Introduce: + +- `apps/server/src/provider/Layers/ClaudeCodeAdapter.ts` +- `apps/server/src/provider/Services/ClaudeCodeAdapter.ts` + +This adapter should be responsible for: + +- session startup +- send turn +- interrupt / stop +- request/approval response mapping +- event normalization into canonical `ProviderRuntimeEvent` + +### Phase 3 — runtime normalization + +Ensure `ProviderRuntimeIngestion` stays provider-agnostic by consuming canonical runtime events only. + +If Claude requires provider-specific preprocessing, keep that inside the adapter layer. + +### Phase 4 — UI enablement + +- enable Claude in the provider picker only after the adapter is usable +- gate unsupported features off capability flags, not hardcoded provider checks + +## Capability Matrix to Add + +Every provider should declare at least: + +- `sessionModelSwitch` +- `supportsApprovals` +- `supportsUserInput` +- `supportsResume` +- `supportsCollaborationMode` +- `supportsImageInputs` +- `supportsReasoningEffort` +- `supportsServiceTier` + +This avoids future branching scattered across `ChatView.tsx`. + +## Reentry Feature Implications + +Claude support should plug into the proposed reentry engine in two ways: + +1. as a runtime signal source through canonical provider events +2. as an optional recap writer backend once the provider is stable + +The recap system should not assume Codex-only event fields or model option semantics. + +## Risks + +- resume behavior may not match Codex thread recovery semantics +- approval and tool event taxonomies may be materially different +- collaboration mode may not have a direct Claude equivalent +- provider-specific prompt tuning for recap generation could leak into server orchestration if not isolated + +## Recommendation + +Do **not** bolt Claude Code onto `CodexAppServerManager`. Instead: + +- keep Codex-specific runtime logic in Codex modules +- add provider capability contracts first +- ship Claude through the existing adapter / registry / service architecture +- keep recap generation behind a separate model broker so runtime provider and recap writer provider can differ diff --git a/.plans/20-monitor-visuals-design-review.md b/.plans/20-monitor-visuals-design-review.md new file mode 100644 index 000000000..f97f1b209 --- /dev/null +++ b/.plans/20-monitor-visuals-design-review.md @@ -0,0 +1,116 @@ +# Design Review: Port Visual Patterns from `~/wf/monitor` + +## Summary + +T3 Code’s current UI is strong for chat, diffs, and thread lists, but it is still relatively weak at rendering structured logs and JSON-rich state. `~/wf/monitor` already contains useful patterns that are a better fit for recap, diagnostics, and attention surfaces than plain markdown or raw `
` blocks.
+
+Most useful source inspirations:
+
+- `~/wf/monitor/ui/src/components/file-viewer/JsonSmartView.tsx`
+- `~/wf/monitor/ui/src/core/logs/LogStreamView.tsx`
+- `~/wf/monitor/ui/src/core/logs/components/LogEntryCard.tsx`
+- `~/wf/monitor/ui/src/core/logs/JsonlViewer.tsx`
+- `~/wf/monitor/ui/src/core/widgets/ChatWidgetBlock.tsx`
+
+## What Looks Worth Reusing
+
+### `JsonSmartView`
+
+Best idea: render structured payloads as cards, grids, badges, progress bars, and collapsible sections rather than always as monospaced JSON.
+
+Why it fits T3 Code:
+
+- provider runtime payloads are often structured
+- recap evidence packets will be structured
+- attention items, canon, and open loops want semantic display rather than raw JSON
+
+### `LogStreamView`
+
+Best idea: treat logs as a navigable stream with compact cards, level coloring, and source-aware scanning, rather than raw terminal scroll only.
+
+Why it fits T3 Code:
+
+- orchestration activities already resemble a timeline log
+- provider runtime diagnostics need more visual hierarchy
+- recap drill-down wants rich “why did this happen?” views
+
+### `ChatWidgetBlock`
+
+Best idea: allow certain structured payloads to become inline visual blocks.
+
+Why it fits T3 Code:
+
+- recap cards inside chat become feasible
+- open loop tables and KPI summaries become glanceable
+- model-generated review artifacts can stay structured
+
+## Recommended Adoption Strategy
+
+### 1. Port semantics, not styles verbatim
+
+T3 Code already has its own component primitives and visual language. Port the rendering ideas and interaction model, but adapt them to the existing `apps/web/src/components/ui/*` system.
+
+### 2. Start with three new renderers
+
+- `StructuredValueView` for JSON-like payloads
+- `EventLogPanel` for provider/orchestration activity streams
+- `WidgetBlockView` for safe, typed mini-widgets
+
+When porting these patterns, preserve monitor’s lazy-loading approach for diff/markdown/JSON-heavy surfaces. That is one of the cleaner aspects of the source design and helps keep the default T3 Code path light.
+
+### 3. Use progressive disclosure
+
+Good defaults:
+
+- compact summary first
+- expand for details
+- keep raw JSON available as fallback
+
+That fits recap and debug workflows better than forcing a single visual mode.
+
+## Proposed Landing Areas in T3 Code
+
+### Chat and work log
+
+- `apps/web/src/components/ChatView.tsx`
+- new helper components under `apps/web/src/components/structured/`
+
+### Diff / inspector side panels
+
+- `apps/web/src/components/DiffPanel.tsx`
+- future project recap or inbox panels
+
+### Project reentry surfaces
+
+The planned reentry inbox is the ideal place to adopt richer cards for:
+
+- project gist
+- open loop tables
+- health KPIs
+- external signal summaries
+
+## Suggested Components to Add
+
+- `apps/web/src/components/structured/StructuredValueView.tsx`
+- `apps/web/src/components/structured/JsonSmartView.tsx`
+- `apps/web/src/components/structured/EventLogCard.tsx`
+- `apps/web/src/components/structured/EventLogPanel.tsx`
+- `apps/web/src/components/widgets/WidgetBlockView.tsx`
+
+## Risks
+
+- importing large monitor ideas directly could overcomplicate T3’s currently fast and compact UI
+- raw JSON and markdown fallbacks must remain available for reliability and debugging
+- log surfaces can become noisy if every activity gets equal visual weight
+
+## Recommendation
+
+Port the structured rendering concepts in small slices:
+
+1. smart JSON rendering
+2. compact event log cards
+3. typed widget blocks
+
+The fastest high-value port is `JsonSmartView` + the log stream/card stack + JSONL auto-upgrade behavior, not the full monitor shell.
+
+That gives the planned recap and attention features a much better visual vocabulary without destabilizing the existing app.
diff --git a/.plans/21-chat-widgets-design-review.md b/.plans/21-chat-widgets-design-review.md
new file mode 100644
index 000000000..d49e8ae40
--- /dev/null
+++ b/.plans/21-chat-widgets-design-review.md
@@ -0,0 +1,137 @@
+# Design Review: Add Widgets in Chat
+
+## Summary
+
+T3 Code should support safe, typed widgets embedded in chat and work-log surfaces. This would let recap and attention features render compact, glanceable UI blocks instead of long markdown lists.
+
+Good widget targets:
+
+- KPI card
+- status card
+- open-loop table
+- simple chart
+- structured JSON summary
+- “next actions” list
+
+## Why Widgets Matter Here
+
+The recap system wants layered information density:
+
+- 10-second skim
+- 1-minute recap
+- deep drill-down
+
+Widgets are a good fit for the top two layers because they are scan-friendly and can link into existing thread, diff, and file views.
+
+## Recommended Protocol
+
+Keep widgets explicit and typed.
+
+### Do
+
+- define a schema-backed widget protocol
+- validate widgets before rendering
+- support a small set of widget types first
+- fall back to plain markdown or code blocks if validation fails
+
+### Do not
+
+- allow arbitrary HTML
+- let provider text implicitly become UI without validation
+- tie widget rendering to one provider only
+
+## Proposed Widget Types
+
+### Initial set
+
+- `kpi`
+- `table`
+- `status-list`
+- `chart` (simple bar or line)
+- `structured-json`
+
+### Optional later
+
+- timeline widget
+- branch / PR summary widget
+- recap card widget
+- approval queue widget
+
+## Transport Options
+
+### Option A — explicit widget payloads in canonical events
+
+Best long-term option.
+
+Add a canonical event or message attachment shape for widgets so the UI does not need to scrape markdown for JSON.
+
+This also fits T3 Code’s existing architecture better: durable widgets can become dedicated timeline rows or typed attachments instead of being hidden inside `ChatMarkdown`.
+
+### Option B — markdown fenced JSON widget blocks
+
+Good bootstrap option.
+
+Example idea:
+
+~~~text
+```widget
+{ ...schema validated payload... }
+```
+~~~
+
+The web app parses, validates, and upgrades known blocks into widgets.
+
+Even as a bootstrap path, this should be treated as transitional. The better long-term fit is a typed row or attachment model because `ChatView` already merges structured timeline kinds and because markdown parsing is not the natural home for rich widgets.
+
+## Suggested Contract Additions
+
+- `packages/contracts/src/widgets.ts`
+- widget attachment or event schema additions in canonical protocol types
+
+## Suggested Web Changes
+
+- `apps/web/src/components/widgets/WidgetBlockView.tsx`
+- `apps/web/src/components/widgets/KpiWidget.tsx`
+- `apps/web/src/components/widgets/TableWidget.tsx`
+- `apps/web/src/components/widgets/StatusListWidget.tsx`
+- widget parsing and validation integrated into `ChatView.tsx`
+
+Recommended UI placement order:
+
+- compact widgets attached to assistant replies
+- dedicated typed timeline rows for durable recap artifacts
+- heavy diff/log/json exploration in side panels or drawers rather than inline transcript expansion
+
+## Suggested Server Changes
+
+For the reentry engine:
+
+- let recap generation output typed widgets as a secondary view over recap data
+- let `AttentionInbox` cards reuse the same rendering primitives
+- keep raw structured state persisted separately from widget presentation
+
+## Provider Strategy
+
+Widgets should be provider-agnostic.
+
+- Codex can generate them first
+- Gemini API can generate them as an alternative writer
+- Claude Code can generate them once provider support exists
+
+The server should normalize and validate widget payloads before the UI renders them.
+
+## Risks
+
+- widget sprawl if too many widget types land at once
+- degraded readability if widgets interrupt normal conversation flow too often
+- brittle parsing if markdown scraping is used for too long
+
+## Recommendation
+
+Start with a tiny widget protocol and render only high-signal structured artifacts:
+
+- recap cards
+- open-loop tables
+- simple KPI / status widgets
+
+Make widgets an enhancement layer over canonical stored recap data, not the source of truth.
diff --git a/.plans/README.md b/.plans/README.md
index 7bb69a3b9..7b63285bd 100644
--- a/.plans/README.md
+++ b/.plans/README.md
@@ -10,3 +10,11 @@
 8. `08-precommit-format-and-lint.md`
 9. `09-event-state-test-expansion.md`
 10. `10-unify-process-session-abstraction.md`
+
+## Recent additions
+
+- `17-project-reentry-engine.md`
+- `18-attention-inbox-smart-notifications.md`
+- `19-claude-code-support-design-review.md`
+- `20-monitor-visuals-design-review.md`
+- `21-chat-widgets-design-review.md`
diff --git a/scripts/dev-local-desktop.sh b/scripts/dev-local-desktop.sh
new file mode 100755
index 000000000..73c92bcce
--- /dev/null
+++ b/scripts/dev-local-desktop.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+exec "$ROOT_DIR/scripts/with-local-node.sh" bun run dev:desktop "$@"
diff --git a/scripts/dev-local.sh b/scripts/dev-local.sh
new file mode 100755
index 000000000..faa9c8802
--- /dev/null
+++ b/scripts/dev-local.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+exec "$ROOT_DIR/scripts/with-local-node.sh" bun run dev "$@"
diff --git a/scripts/install-local-node.sh b/scripts/install-local-node.sh
new file mode 100755
index 000000000..56b109835
--- /dev/null
+++ b/scripts/install-local-node.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+VERSION_RAW="$(tr -d '[:space:]' < "$ROOT_DIR/.node-version")"
+VERSION="${VERSION_RAW#v}"
+DIST_VERSION="v${VERSION}"
+
+case "$(uname -s)" in
+  Linux) OS="linux" ;;
+  Darwin) OS="darwin" ;;
+  *) echo "Unsupported OS: $(uname -s)" >&2; exit 1 ;;
+esac
+
+case "$(uname -m)" in
+  x86_64) ARCH="x64" ;;
+  arm64|aarch64) ARCH="arm64" ;;
+  *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
+esac
+
+FILE="node-${DIST_VERSION}-${OS}-${ARCH}.tar.xz"
+BASE_URL="https://nodejs.org/dist/${DIST_VERSION}"
+DOWNLOAD_DIR="$ROOT_DIR/.local/downloads"
+INSTALL_DIR="$ROOT_DIR/.local/node-${DIST_VERSION}-${OS}-${ARCH}"
+SYMLINK_PATH="$ROOT_DIR/.local/node"
+ARCHIVE_PATH="$DOWNLOAD_DIR/$FILE"
+SHASUMS_PATH="$DOWNLOAD_DIR/SHASUMS256.txt"
+
+mkdir -p "$DOWNLOAD_DIR"
+
+if [ ! -f "$ARCHIVE_PATH" ]; then
+  curl -L --fail -o "$ARCHIVE_PATH" "$BASE_URL/$FILE"
+fi
+
+if [ ! -f "$SHASUMS_PATH" ]; then
+  curl -L --fail -o "$SHASUMS_PATH" "$BASE_URL/SHASUMS256.txt"
+fi
+
+if command -v sha256sum >/dev/null 2>&1; then
+  (cd "$DOWNLOAD_DIR" && grep "  $FILE$" SHASUMS256.txt | sha256sum -c -)
+elif command -v shasum >/dev/null 2>&1; then
+  EXPECTED="$(grep "  $FILE$" "$SHASUMS_PATH" | awk '{print $1}')"
+  ACTUAL="$(shasum -a 256 "$ARCHIVE_PATH" | awk '{print $1}')"
+  if [ "$EXPECTED" != "$ACTUAL" ]; then
+    echo "Checksum mismatch for $FILE" >&2
+    exit 1
+  fi
+else
+  echo "Warning: no sha256 tool found; skipping checksum verification" >&2
+fi
+
+rm -rf "$INSTALL_DIR"
+tar -xJf "$ARCHIVE_PATH" -C "$ROOT_DIR/.local"
+ln -sfn "$(basename "$INSTALL_DIR")" "$SYMLINK_PATH"
+
+"$SYMLINK_PATH/bin/node" -v
diff --git a/scripts/with-local-node.sh b/scripts/with-local-node.sh
new file mode 100755
index 000000000..ace958c05
--- /dev/null
+++ b/scripts/with-local-node.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+set -euo pipefail
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+NODE_BIN_DIR="$ROOT_DIR/.local/node/bin"
+if [ ! -x "$NODE_BIN_DIR/node" ]; then
+  echo "Local Node runtime not found at $NODE_BIN_DIR/node" >&2
+  echo "Run ./scripts/install-local-node.sh" >&2
+  exit 1
+fi
+export PATH="$NODE_BIN_DIR:$PATH"
+exec "$@"

From 34dc87db13f9bb52d81b185bab3fb10f21cc6ed6 Mon Sep 17 00:00:00 2001
From: Sasha Sobol 
Date: Mon, 9 Mar 2026 02:31:21 +0000
Subject: [PATCH 2/2] Add Claude Code provider integration

---
 .../19-claude-code-support-design-review.md   |  218 +-
 REMOTE.md                                     |   20 +
 .../TestProviderAdapter.integration.ts        |    7 +-
 apps/server/package.json                      |    4 +-
 apps/server/src/main.ts                       |    7 +-
 .../Layers/CheckpointReactor.test.ts          |    3 +-
 .../Layers/ProviderCommandReactor.test.ts     |   62 +-
 .../Layers/ProviderCommandReactor.ts          |   76 +-
 .../Layers/ProviderRuntimeIngestion.test.ts   |    7 +-
 .../src/provider/Layers/ClaudeCodeAdapter.ts  | 1956 +++++++++++++++++
 .../src/provider/Layers/CodexAdapter.ts       |    5 +-
 .../Layers/ProviderAdapterRegistry.test.ts    |   32 +-
 .../Layers/ProviderAdapterRegistry.ts         |    3 +-
 .../src/provider/Layers/ProviderHealth.ts     |  202 +-
 .../provider/Layers/ProviderService.test.ts   |    3 +-
 .../src/provider/Layers/ProviderService.ts    |    8 +-
 .../Layers/ProviderSessionDirectory.ts        |    2 +-
 .../provider/Services/ClaudeCodeAdapter.ts    |   21 +
 .../src/provider/Services/ProviderAdapter.ts  |   10 +-
 apps/server/src/serverLayers.ts               |    5 +
 apps/server/src/wsServer.test.ts              |    4 +-
 apps/web/src/appSettings.ts                   |   14 +
 apps/web/src/clipboard.ts                     |    7 +
 apps/web/src/components/ChatView.browser.tsx  |   10 +
 .../ChatView.providerOptions.test.ts          |   57 +
 .../components/ChatView.providerOptions.ts    |   33 +
 apps/web/src/components/ChatView.tsx          |  444 +++-
 apps/web/src/components/Sidebar.tsx           |   11 +-
 apps/web/src/composerDraftStore.ts            |   35 +-
 apps/web/src/lib/utils.test.ts                |   43 +-
 apps/web/src/lib/utils.ts                     |   29 +-
 apps/web/src/providerAuthGuidance.test.ts     |   23 +
 apps/web/src/providerAuthGuidance.ts          |   27 +
 apps/web/src/routes/_chat.settings.tsx        |  134 +-
 apps/web/src/session-logic.test.ts            |   10 +-
 apps/web/src/session-logic.ts                 |    9 +-
 apps/web/src/store.test.ts                    |    5 +-
 apps/web/src/store.ts                         |   35 +-
 apps/web/src/wsNativeApi.test.ts              |    2 +
 apps/web/vite.config.ts                       |   28 +-
 bun.lock                                      |   14 +-
 packages/contracts/src/index.ts               |    1 +
 packages/contracts/src/model.ts               |   26 +-
 packages/contracts/src/orchestration.test.ts  |   28 +
 packages/contracts/src/orchestration.ts       |   11 +-
 packages/contracts/src/provider.test.ts       |   52 +
 packages/contracts/src/provider.ts            |   60 +-
 packages/contracts/src/providerOptions.ts     |   24 +
 packages/contracts/src/providerRuntime.ts     |    6 +
 packages/contracts/src/server.ts              |    2 +
 packages/shared/package.json                  |    4 +
 packages/shared/src/host.test.ts              |   39 +
 packages/shared/src/host.ts                   |   37 +
 packages/shared/src/model.test.ts             |   13 +
 packages/shared/src/model.ts                  |   35 +-
 scripts/dev-branch.sh                         |   42 +
 scripts/dev-runner.test.ts                    |   22 +
 scripts/dev-runner.ts                         |   29 +-
 turbo.json                                    |    1 +
 59 files changed, 3670 insertions(+), 387 deletions(-)
 create mode 100644 apps/server/src/provider/Layers/ClaudeCodeAdapter.ts
 create mode 100644 apps/server/src/provider/Services/ClaudeCodeAdapter.ts
 create mode 100644 apps/web/src/clipboard.ts
 create mode 100644 apps/web/src/components/ChatView.providerOptions.test.ts
 create mode 100644 apps/web/src/components/ChatView.providerOptions.ts
 create mode 100644 apps/web/src/providerAuthGuidance.test.ts
 create mode 100644 apps/web/src/providerAuthGuidance.ts
 create mode 100644 packages/contracts/src/providerOptions.ts
 create mode 100644 packages/shared/src/host.test.ts
 create mode 100644 packages/shared/src/host.ts
 create mode 100755 scripts/dev-branch.sh

diff --git a/.plans/19-claude-code-support-design-review.md b/.plans/19-claude-code-support-design-review.md
index 75862772d..461fa4016 100644
--- a/.plans/19-claude-code-support-design-review.md
+++ b/.plans/19-claude-code-support-design-review.md
@@ -1,120 +1,120 @@
-# Design Review: Claude Code Support
+# Claude Code Support — Execution Plan
 
-## Summary
+## Goal
 
-T3 Code is clearly being shaped toward multi-provider support, but the current implementation remains codex-first in both contracts and runtime assumptions. Claude Code support should land as a real provider adapter, not as a UI-only toggle.
+Ship `claudeCode` as a first-class provider in the current `apps/server` + `apps/web` stack, with predictable lifecycle behavior, canonical runtime events, and capability-driven UI gating.
 
-## What Already Helps
+## Architecture Fit
 
-- provider service / adapter architecture already exists under `apps/server/src/provider`
-- provider session directory and resume binding already exist
-- runtime events are normalized through canonical provider runtime ingestion
-- the web UI already gestures at unavailable providers in the picker
+This track is intentionally aligned to the current codebase, not legacy `apps/renderer` assumptions.
 
-These are strong foundations.
+### Server runtime path
 
-## Where Codex Still Leaks Through
-
-### Contracts
-
-`ProviderKind` is still effectively locked to Codex, so any provider abstraction above it is narrower than it appears.
+- `apps/server/src/provider/Layers/ClaudeCodeAdapter.ts`
+- `apps/server/src/provider/Services/ClaudeCodeAdapter.ts`
+- `apps/server/src/provider/Layers/ProviderAdapterRegistry.ts`
+- `apps/server/src/provider/Layers/ProviderService.ts`
+- `apps/server/src/provider/Layers/ProviderSessionDirectory.ts`
+- `apps/server/src/provider/Layers/ProviderHealth.ts`
+- `apps/server/src/serverLayers.ts`
+- `apps/server/src/wsServer.ts`
 
-Areas to widen first:
+### Shared contracts / model path
 
 - `packages/contracts/src/orchestration.ts`
 - `packages/contracts/src/provider.ts`
+- `packages/contracts/src/providerRuntime.ts`
 - `packages/contracts/src/model.ts`
-
-### Runtime protocol assumptions
-
-Codex-specific semantics are still embedded in:
-
-- `apps/server/src/codexAppServerManager.ts`
-- `apps/server/src/provider/Layers/CodexAdapter.ts`
-- collaboration mode / effort settings
-- model selection and account handling
-- resume semantics and turn lifecycle mapping
-
-### UI assumptions
-
-The UI already lists `claudeCode` as unavailable, but provider capabilities are not yet modeled deeply enough for different approval semantics, tool event shapes, or model option behavior.
-
-## Recommended Direction
-
-Treat Claude Code support as a canonical provider implementation with a first-class adapter and a capability matrix.
-
-### Phase 1 — widen contracts
-
-- expand `ProviderKind` to include `claudeCode`
-- add provider capability contracts:
-  - model switch mode
-  - approval support
-  - user input support
-  - collaboration / planning support
-  - resume support level
-
-### Phase 2 — add adapter
-
-Introduce:
-
-- `apps/server/src/provider/Layers/ClaudeCodeAdapter.ts`
-- `apps/server/src/provider/Services/ClaudeCodeAdapter.ts`
-
-This adapter should be responsible for:
-
-- session startup
-- send turn
-- interrupt / stop
-- request/approval response mapping
-- event normalization into canonical `ProviderRuntimeEvent`
-
-### Phase 3 — runtime normalization
-
-Ensure `ProviderRuntimeIngestion` stays provider-agnostic by consuming canonical runtime events only.
-
-If Claude requires provider-specific preprocessing, keep that inside the adapter layer.
-
-### Phase 4 — UI enablement
-
-- enable Claude in the provider picker only after the adapter is usable
-- gate unsupported features off capability flags, not hardcoded provider checks
-
-## Capability Matrix to Add
-
-Every provider should declare at least:
-
-- `sessionModelSwitch`
-- `supportsApprovals`
-- `supportsUserInput`
-- `supportsResume`
-- `supportsCollaborationMode`
-- `supportsImageInputs`
-- `supportsReasoningEffort`
-- `supportsServiceTier`
-
-This avoids future branching scattered across `ChatView.tsx`.
-
-## Reentry Feature Implications
-
-Claude support should plug into the proposed reentry engine in two ways:
-
-1. as a runtime signal source through canonical provider events
-2. as an optional recap writer backend once the provider is stable
-
-The recap system should not assume Codex-only event fields or model option semantics.
-
-## Risks
-
-- resume behavior may not match Codex thread recovery semantics
-- approval and tool event taxonomies may be materially different
-- collaboration mode may not have a direct Claude equivalent
-- provider-specific prompt tuning for recap generation could leak into server orchestration if not isolated
-
-## Recommendation
-
-Do **not** bolt Claude Code onto `CodexAppServerManager`. Instead:
-
-- keep Codex-specific runtime logic in Codex modules
-- add provider capability contracts first
-- ship Claude through the existing adapter / registry / service architecture
-- keep recap generation behind a separate model broker so runtime provider and recap writer provider can differ
+- `packages/contracts/src/server.ts`
+- `packages/shared/src/model.ts`
+
+### Web path
+
+- `apps/web/src/components/ChatView.tsx`
+- `apps/web/src/composerDraftStore.ts`
+- `apps/web/src/store.ts`
+- `apps/web/src/session-logic.ts`
+- `apps/web/src/appSettings.ts`
+- `apps/web/src/routes/_chat.settings.tsx`
+- `apps/web/src/wsNativeApi.ts`
+
+## Execution Tracks
+
+### 1. Contracts and capability matrix
+
+- Widen `ProviderKind` to include `claudeCode`.
+- Define provider capability contracts in `packages/contracts/src/provider.ts`:
+  - `sessionModelSwitch`
+  - `supportsApprovals`
+  - `supportsUserInput`
+  - `supportsResume`
+  - `supportsCollaborationMode`
+  - `supportsImageInputs`
+  - `supportsReasoningEffort`
+  - `supportsServiceTier`
+  - `supportsConversationRollback`
+- Extend provider model options in `packages/contracts/src/model.ts`:
+  - Codex: `reasoningEffort`, `fastMode`
+  - Claude Code: `effort`
+- Extend runtime raw-source contracts in `packages/contracts/src/providerRuntime.ts` for Claude-native stream events.
+- Surface provider capabilities through `packages/contracts/src/server.ts` so `server.getConfig` becomes the single source of truth for provider availability + feature gating.
+
+### 2. Server adapter and session lifecycle
+
+- Add a dedicated `ClaudeCodeAdapter` under `apps/server/src/provider`.
+- Keep Codex-specific logic isolated in Codex modules.
+- Use the Claude runtime adapter to own:
+  - session startup / resume
+  - turn dispatch
+  - interrupt / stop
+  - canonical event emission
+  - capability reporting
+- Preserve `ProviderService` as the cross-provider routing layer.
+- Preserve `ProviderSessionDirectory` as the persisted thread → provider binding / resume binding layer.
+- Register Claude in `ProviderAdapterRegistryLive` and `makeServerProviderLayer()`.
+
+### 3. Health checks and WebSocket/API surface
+
+- Extend `ProviderHealthLive` to probe Claude install/auth status alongside Codex.
+- Keep `server.getConfig` as the main transport surface for provider status + capability data.
+- Ensure `server.configUpdated` pushes continue to carry the latest provider status objects.
+- Do not add a parallel Claude-specific RPC surface unless the orchestration path cannot express a required operation.
+
+### 4. Web provider parity
+
+- Drive provider availability from `server.getConfig().providers`, not hardcoded placeholders.
+- Keep `PROVIDER_OPTIONS` as the UI label list, but compute selectable / unavailable providers from server status.
+- Extend settings to support custom Claude model slugs.
+- Extend the composer model / effort controls so they respect provider capabilities.
+- Gate unsupported features via capabilities instead of provider-name checks:
+  - image inputs
+  - plan/default interaction mode
+  - service tier
+  - conversation rollback
+- Keep event rendering provider-agnostic by consuming orchestration projections only.
+
+### 5. Reliability requirements
+
+- Resume / reconnect must rely on persisted provider session bindings, not fragile UI state.
+- Provider restarts must keep behavior deterministic:
+  - no silent provider swapping
+  - no hidden capability fallback without an explicit runtime warning
+- Partial stream handling must continue to produce stable timeline state if a turn is interrupted mid-stream.
+- Session stop / interrupt flows must settle orchestration thread state instead of leaving it ambiguous.
+
+## Current implementation notes
+
+This implementation track favors shared abstractions over one-off branching:
+
+- provider capabilities are shared through contracts and reused by both server and web
+- provider status and capability data flow through `server.getConfig`
+- UI gating uses capability data instead of hardcoded `provider === "codex"` checks where possible
+- Claude support is added through the existing adapter / registry / service architecture instead of special-casing `wsServer`
+
+## Follow-up work after this track
+
+- Persist a thread-level preferred provider so idle threads do not need model-based provider inference.
+- Add richer Claude permission / elicitation bridging if the adapter surface stabilizes enough to expose it predictably.
+- Add Claude image-input support once the runtime path supports structured non-text turn inputs cleanly.
+- Revisit rollback / checkpoint parity if Claude exposes reversible conversation history primitives.
+- Add targeted integration tests for reconnect, restart, and interrupted partial-stream recovery on Claude sessions.
diff --git a/REMOTE.md b/REMOTE.md
index 8bbd481de..9601c20e3 100644
--- a/REMOTE.md
+++ b/REMOTE.md
@@ -63,3 +63,23 @@ Open from any device in your tailnet:
 `http://:3773`
 
 You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure.
+
+## 3) Tailnet access in `dev` / `dev-branch`
+
+For the dev runner, prefer an explicit Tailnet host so the injected Vite URL, HMR socket, and app WebSocket URL all use the same remote-friendly address.
+
+```bash
+T3CODE_HOST="$(tailscale ip -4)" \
+T3CODE_NO_BROWSER=1 \
+T3CODE_PORT_OFFSET=20 \
+T3CODE_STATE_DIR=~/.t3/dev-claude-branch \
+./scripts/dev-branch.sh
+```
+
+Then open from another device in your tailnet:
+
+`http://:5753`
+
+If you prefer to land on the server port first, open:
+
+`http://:3793`
diff --git a/apps/server/integration/TestProviderAdapter.integration.ts b/apps/server/integration/TestProviderAdapter.integration.ts
index 82c08da3e..0888d3353 100644
--- a/apps/server/integration/TestProviderAdapter.integration.ts
+++ b/apps/server/integration/TestProviderAdapter.integration.ts
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
 import {
   ApprovalRequestId,
   EventId,
+  PROVIDER_CAPABILITIES_BY_PROVIDER,
   ProviderApprovalDecision,
   ProviderRuntimeEvent,
   RuntimeSessionId,
@@ -35,7 +36,7 @@ export interface TestTurnResponse {
 export type FixtureProviderRuntimeEvent = {
   readonly type: string;
   readonly eventId: EventId;
-  readonly provider: "codex";
+  readonly provider: "codex" | "claudeCode";
   readonly createdAt: string;
   readonly threadId: string;
   readonly turnId?: string | undefined;
@@ -474,9 +475,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter
 
   const adapter: ProviderAdapterShape = {
     provider,
-    capabilities: {
-      sessionModelSwitch: "in-session",
-    },
+    capabilities: PROVIDER_CAPABILITIES_BY_PROVIDER[provider],
     startSession,
     sendTurn,
     interruptTurn,
diff --git a/apps/server/package.json b/apps/server/package.json
index 96413bde5..33262c42a 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -22,13 +22,15 @@
     "test": "vitest run"
   },
   "dependencies": {
+    "@anthropic-ai/claude-agent-sdk": "^0.2.71",
     "@effect/platform-node": "catalog:",
     "@effect/sql-sqlite-bun": "catalog:",
     "@pierre/diffs": "^1.1.0-beta.16",
     "effect": "catalog:",
     "node-pty": "^1.1.0",
     "open": "^10.1.0",
-    "ws": "^8.18.0"
+    "ws": "^8.18.0",
+    "zod": "^4.3.6"
   },
   "devDependencies": {
     "@effect/language-service": "catalog:",
diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
index 0a33be0cb..be136a70c 100644
--- a/apps/server/src/main.ts
+++ b/apps/server/src/main.ts
@@ -9,6 +9,7 @@
 import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect";
 import { Command, Flag } from "effect/unstable/cli";
 import { NetService } from "@t3tools/shared/Net";
+import { formatHostForUrl, isWildcardHost } from "@t3tools/shared/host";
 import {
   DEFAULT_PORT,
   resolveStaticDir,
@@ -204,12 +205,6 @@ const LayerLive = (input: CliInput) =>
     Layer.provideMerge(ServerConfigLive(input)),
   );
 
-const isWildcardHost = (host: string | undefined): boolean =>
-  host === "0.0.0.0" || host === "::" || host === "[::]";
-
-const formatHostForUrl = (host: string): string =>
-  host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
-
 export const recordStartupHeartbeat = Effect.gen(function* () {
   const analytics = yield* AnalyticsService;
   const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
index d675c85ff..54346ba4a 100644
--- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
+++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
@@ -10,6 +10,7 @@ import {
   EventId,
   MessageId,
   ProjectId,
+  PROVIDER_CAPABILITIES_BY_PROVIDER,
   ThreadId,
   TurnId,
 } from "@t3tools/contracts";
@@ -89,7 +90,7 @@ function createProviderServiceHarness(
     respondToUserInput: () => unsupported(),
     stopSession: () => unsupported(),
     listSessions,
-    getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
+    getCapabilities: () => Effect.succeed(PROVIDER_CAPABILITIES_BY_PROVIDER[providerName]),
     rollbackConversation,
     streamEvents: Stream.fromPubSub(runtimeEventPubSub),
   };
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
index 4f352435f..71f7084be 100644
--- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
+++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
@@ -10,6 +10,7 @@ import {
   EventId,
   MessageId,
   ProjectId,
+  PROVIDER_CAPABILITIES_BY_PROVIDER,
   ThreadId,
   TurnId,
 } from "@t3tools/contracts";
@@ -18,7 +19,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
 
 import { ServerConfig } from "../../config.ts";
 import { TextGenerationError } from "../../git/Errors.ts";
-import { ProviderAdapterRequestError } from "../../provider/Errors.ts";
+import {
+  ProviderAdapterProcessError,
+  ProviderAdapterRequestError,
+} from "../../provider/Errors.ts";
 import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
 import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
 import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
@@ -87,7 +91,7 @@ describe("ProviderCommandReactor", () => {
     const runtimeEventPubSub = Effect.runSync(PubSub.unbounded());
     let nextSessionIndex = 1;
     const runtimeSessions: Array = [];
-    const startSession = vi.fn((_: unknown, input: unknown) => {
+    const startSession = vi.fn((_: unknown, input: unknown) => {
       const sessionIndex = nextSessionIndex++;
       const provider =
         typeof input === "object" &&
@@ -184,9 +188,7 @@ describe("ProviderCommandReactor", () => {
       stopSession: stopSession as ProviderServiceShape["stopSession"],
       listSessions: () => Effect.succeed(runtimeSessions),
       getCapabilities: (provider) =>
-        Effect.succeed({
-          sessionModelSwitch: provider === "codex" ? "in-session" : "in-session",
-        }),
+        Effect.succeed(PROVIDER_CAPABILITIES_BY_PROVIDER[provider]),
       rollbackConversation: () => unsupported(),
       streamEvents: Stream.fromPubSub(runtimeEventPubSub),
     };
@@ -794,6 +796,56 @@ describe("ProviderCommandReactor", () => {
     expect(resolvedActivity).toBeUndefined();
   });
 
+  it("projects provider turn-start failures into thread session error state", async () => {
+    const harness = await createHarness();
+    const now = new Date().toISOString();
+    harness.startSession.mockImplementationOnce(() =>
+      Effect.fail(
+        new ProviderAdapterProcessError({
+          provider: "claudeCode",
+          threadId: ThreadId.makeUnsafe("thread-1"),
+          detail: "Timed out while waiting for Claude Code session initialization.",
+        }),
+      ),
+    );
+
+    await Effect.runPromise(
+      harness.engine.dispatch({
+        type: "thread.turn.start",
+        commandId: CommandId.makeUnsafe("cmd-turn-start-failure"),
+        threadId: ThreadId.makeUnsafe("thread-1"),
+        message: {
+          messageId: asMessageId("msg-start-failure"),
+          role: "user",
+          text: "Hello Claude",
+          attachments: [],
+        },
+        provider: "claudeCode",
+        model: "claude-sonnet-4-6",
+        runtimeMode: "approval-required",
+        interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
+        createdAt: now,
+      }),
+    );
+
+    await waitFor(async () => {
+      const readModel = await Effect.runPromise(harness.engine.getReadModel());
+      const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
+      return thread?.session?.status === "error";
+    });
+
+    const readModel = await Effect.runPromise(harness.engine.getReadModel());
+    const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
+    expect(thread?.session?.status).toBe("error");
+    expect(thread?.session?.providerName).toBe("claudeCode");
+    expect(thread?.session?.lastError).toContain(
+      "Timed out while waiting for Claude Code session initialization.",
+    );
+    expect(
+      thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed"),
+    ).toBe(true);
+  });
+
   it("reacts to thread.session.stop by stopping provider session and clearing thread session state", async () => {
     const harness = await createHarness();
     const now = new Date().toISOString();
diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
index 2a72d5902..64c6766fc 100644
--- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
+++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
@@ -5,8 +5,9 @@ import {
   type OrchestrationEvent,
   type ProviderModelOptions,
   type ProviderKind,
+  type ProviderSessionStartInput,
   type ProviderServiceTier,
-  type ProviderStartOptions,
+  PROVIDER_KIND_VALUES,
   type OrchestrationSession,
   ThreadId,
   type ProviderSession,
@@ -145,7 +146,7 @@ const make = Effect.gen(function* () {
       ),
     );
 
-  const threadProviderOptions = new Map();
+  const threadProviderOptions = new Map();
 
   const appendProviderFailureActivity = (input: {
     readonly threadId: ThreadId;
@@ -193,6 +194,27 @@ const make = Effect.gen(function* () {
       createdAt: input.createdAt,
     });
 
+  const setThreadSessionError = (input: {
+    readonly threadId: ThreadId;
+    readonly providerName: ProviderKind | null;
+    readonly runtimeMode: RuntimeMode;
+    readonly detail: string;
+    readonly createdAt: string;
+  }) =>
+    setThreadSession({
+      threadId: input.threadId,
+      session: {
+        threadId: input.threadId,
+        status: "error",
+        providerName: input.providerName,
+        runtimeMode: input.runtimeMode,
+        activeTurnId: null,
+        lastError: input.detail,
+        updatedAt: input.createdAt,
+      },
+      createdAt: input.createdAt,
+    });
+
   const resolveThread = Effect.fnUntraced(function* (threadId: ThreadId) {
     const readModel = yield* orchestrationEngine.getReadModel();
     return readModel.threads.find((entry) => entry.id === threadId);
@@ -205,8 +227,8 @@ const make = Effect.gen(function* () {
       readonly provider?: ProviderKind;
       readonly model?: string;
       readonly modelOptions?: ProviderModelOptions;
+      readonly providerOptions?: ProviderSessionStartInput["providerOptions"];
       readonly serviceTier?: ProviderServiceTier | null;
-      readonly providerOptions?: ProviderStartOptions;
     },
   ) {
     const readModel = yield* orchestrationEngine.getReadModel();
@@ -217,7 +239,10 @@ const make = Effect.gen(function* () {
 
     const desiredRuntimeMode = thread.runtimeMode;
     const currentProvider: ProviderKind | undefined =
-      thread.session?.providerName === "codex" ? thread.session.providerName : undefined;
+      thread.session?.providerName &&
+      PROVIDER_KIND_VALUES.includes(thread.session.providerName as ProviderKind)
+        ? (thread.session.providerName as ProviderKind)
+        : undefined;
     const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider;
     const desiredModel = options?.model ?? thread.model;
     const effectiveCwd = resolveThreadWorkspaceCwd({
@@ -330,7 +355,7 @@ const make = Effect.gen(function* () {
     readonly model?: string;
     readonly serviceTier?: ProviderServiceTier | null;
     readonly modelOptions?: ProviderModelOptions;
-    readonly providerOptions?: ProviderStartOptions;
+    readonly providerOptions?: ProviderSessionStartInput["providerOptions"];
     readonly interactionMode?: "default" | "plan";
     readonly createdAt: string;
   }) {
@@ -482,10 +507,39 @@ const make = Effect.gen(function* () {
       ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
       ...(event.payload.serviceTier !== undefined ? { serviceTier: event.payload.serviceTier } : {}),
       ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}),
-      ...(event.payload.providerOptions !== undefined ? { providerOptions: event.payload.providerOptions } : {}),
+      ...(event.payload.providerOptions !== undefined
+        ? { providerOptions: event.payload.providerOptions }
+        : {}),
       interactionMode: event.payload.interactionMode,
       createdAt: event.payload.createdAt,
-    });
+    }).pipe(
+      Effect.catchCause((cause) =>
+        Effect.gen(function* () {
+          const error = Cause.squash(cause);
+          const detail = toErrorMessage(error);
+          yield* setThreadSessionError({
+            threadId: event.payload.threadId,
+            providerName:
+              event.payload.provider ??
+              (thread.session?.providerName &&
+              PROVIDER_KIND_VALUES.includes(thread.session.providerName as ProviderKind)
+                ? (thread.session.providerName as ProviderKind)
+                : null),
+            runtimeMode: thread.session?.runtimeMode ?? thread.runtimeMode,
+            detail,
+            createdAt: event.payload.createdAt,
+          });
+          yield* appendProviderFailureActivity({
+            threadId: event.payload.threadId,
+            kind: "provider.turn.start.failed",
+            summary: "Provider turn start failed",
+            detail,
+            turnId: null,
+            createdAt: event.payload.createdAt,
+          });
+        }),
+      ),
+    );
   });
 
   const processTurnInterruptRequested = Effect.fnUntraced(function* (
@@ -639,9 +693,11 @@ const make = Effect.gen(function* () {
             return;
           }
           const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId);
-          yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, {
-            ...(cachedProviderOptions !== undefined ? { providerOptions: cachedProviderOptions } : {}),
-          });
+          yield* ensureSessionForThread(
+            event.payload.threadId,
+            event.occurredAt,
+            cachedProviderOptions !== undefined ? { providerOptions: cachedProviderOptions } : {},
+          );
           return;
         }
         case "thread.turn-start-requested":
diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
index 24409655e..4884ac432 100644
--- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
+++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
@@ -2,7 +2,7 @@ import fs from "node:fs";
 import os from "node:os";
 import path from "node:path";
 
-import type { OrchestrationReadModel, ProviderRuntimeEvent } from "@t3tools/contracts";
+import type { OrchestrationReadModel, ProviderKind, ProviderRuntimeEvent } from "@t3tools/contracts";
 import {
   ApprovalRequestId,
   CommandId,
@@ -10,6 +10,7 @@ import {
   EventId,
   MessageId,
   ProjectId,
+  PROVIDER_CAPABILITIES_BY_PROVIDER,
   ProviderItemId,
   ThreadId,
   TurnId,
@@ -45,7 +46,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
 type LegacyProviderRuntimeEvent = {
   readonly type: string;
   readonly eventId: EventId;
-  readonly provider: "codex";
+  readonly provider: ProviderKind;
   readonly createdAt: string;
   readonly threadId: ThreadId;
   readonly turnId?: string | undefined;
@@ -67,7 +68,7 @@ function createProviderServiceHarness() {
     respondToUserInput: () => unsupported(),
     stopSession: () => unsupported(),
     listSessions: () => Effect.succeed([]),
-    getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
+    getCapabilities: () => Effect.succeed(PROVIDER_CAPABILITIES_BY_PROVIDER.codex),
     rollbackConversation: () => unsupported(),
     streamEvents: Stream.fromPubSub(runtimeEventPubSub),
   };
diff --git a/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts
new file mode 100644
index 000000000..f1b3457c7
--- /dev/null
+++ b/apps/server/src/provider/Layers/ClaudeCodeAdapter.ts
@@ -0,0 +1,1956 @@
+/**
+ * ClaudeCodeAdapterLive - Scoped live implementation for the Claude Code provider adapter.
+ *
+ * Uses the Claude Agent SDK to manage a long-lived session per thread, stream
+ * structured runtime activity, and bridge permission / elicitation prompts
+ * back into the shared provider abstraction.
+ *
+ * @module ClaudeCodeAdapterLive
+ */
+import { randomUUID } from "node:crypto";
+
+import {
+  ApprovalRequestId,
+  type ClaudeCodeReasoningEffort,
+  EventId,
+  PROVIDER_CAPABILITIES_BY_PROVIDER,
+  ProviderItemId,
+  RuntimeItemId,
+  RuntimeRequestId,
+  RuntimeTaskId,
+  ThreadId,
+  TurnId,
+  type ProviderRuntimeEvent,
+  type ProviderSendTurnInput,
+  type ProviderSession,
+  type ProviderSessionStartInput,
+  type ProviderTurnStartResult,
+  type RuntimeErrorClass,
+} from "@t3tools/contracts";
+import {
+  query as createClaudeQuery,
+  type ElicitationRequest,
+  type ElicitationResult,
+  type PermissionResult,
+  type Query as ClaudeQuery,
+  type SDKMessage,
+  type SDKUserMessage,
+} from "@anthropic-ai/claude-agent-sdk";
+import { normalizeModelSlug } from "@t3tools/shared/model";
+import { Effect, Layer, Queue, Stream } from "effect";
+import {
+  ProviderAdapterRequestError,
+  ProviderAdapterSessionNotFoundError,
+  ProviderAdapterValidationError,
+} from "../Errors.ts";
+import type { ProviderThreadTurnSnapshot } from "../Services/ProviderAdapter.ts";
+import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts";
+import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
+
+const PROVIDER = "claudeCode" as const;
+const START_SESSION_TIMEOUT_MS = 30_000;
+const STARTUP_MESSAGE_LABEL_LIMIT = 4;
+const UNKNOWN_PENDING_APPROVAL_REQUEST = "Unknown pending approval request.";
+const UNKNOWN_PENDING_USER_INPUT_REQUEST = "Unknown pending user input request.";
+
+type ClaudePermissionMode = "default" | "acceptEdits" | "bypassPermissions" | "plan" | "dontAsk";
+
+type PendingPermissionRequest = {
+  readonly requestId: ApprovalRequestId;
+  readonly toolName: string;
+  readonly toolUseId: string;
+  readonly detail?: string;
+  readonly args?: Record;
+  readonly suggestions?: ReadonlyArray;
+  readonly resolve: (value: PermissionResult) => void;
+  readonly reject: (error: unknown) => void;
+};
+
+type PendingUserInputRequest = {
+  readonly requestId: ApprovalRequestId;
+  readonly request: ElicitationRequest;
+  readonly resolve: (value: ElicitationResult) => void;
+  readonly reject: (error: unknown) => void;
+};
+
+type Deferred = {
+  readonly promise: Promise;
+  resolve: (value: T) => void;
+  reject: (error: unknown) => void;
+  settled: boolean;
+};
+
+type ClaudeSessionState = {
+  session: ProviderSession;
+  snapshot: {
+    threadId: ThreadId;
+    turns: Array;
+  };
+  inputQueue: AsyncPushQueue;
+  query: ClaudeQuery;
+  abortController: AbortController;
+  started: Deferred;
+  readerDone: Promise;
+  pendingPermissions: Map;
+  pendingUserInputs: Map;
+  toolNamesByUseId: Map;
+  completedToolUseIds: Set;
+  activeTurnId: TurnId | null;
+  activeAssistantItemId: string | null;
+  activeAssistantHasStreamedText: boolean;
+  activeAssistantCompleted: boolean;
+  currentEffort: ClaudeCodeReasoningEffort | null;
+  currentPermissionMode: ClaudePermissionMode;
+  binaryPath?: string;
+  homePath?: string;
+  startupMessageCount: number;
+  startupMessageLabels: string[];
+  sdkStarted: boolean;
+  stopRequested: boolean;
+};
+
+export interface ClaudeCodeAdapterLiveOptions {
+  readonly nativeEventLogPath?: string;
+  readonly nativeEventLogger?: EventNdjsonLogger;
+  readonly createQuery?: typeof createClaudeQuery;
+}
+
+class AsyncPushQueue implements AsyncIterable {
+  private readonly values: T[] = [];
+  private readonly resolvers: Array<(value: IteratorResult) => void> = [];
+  private ended = false;
+
+  push(value: T): void {
+    if (this.ended) {
+      throw new Error("AsyncPushQueue is closed");
+    }
+
+    const nextResolver = this.resolvers.shift();
+    if (nextResolver) {
+      nextResolver({ done: false, value });
+      return;
+    }
+
+    this.values.push(value);
+  }
+
+  end(): void {
+    if (this.ended) {
+      return;
+    }
+    this.ended = true;
+    while (this.resolvers.length > 0) {
+      const nextResolver = this.resolvers.shift();
+      nextResolver?.({ done: true, value: undefined });
+    }
+  }
+
+  [Symbol.asyncIterator](): AsyncIterator {
+    return {
+      next: () => {
+        const queuedValue = this.values.shift();
+        if (queuedValue !== undefined) {
+          return Promise.resolve({ done: false, value: queuedValue });
+        }
+        if (this.ended) {
+          return Promise.resolve({ done: true, value: undefined });
+        }
+        return new Promise((resolve) => {
+          this.resolvers.push(resolve);
+        });
+      },
+    };
+  }
+}
+
+function nowIso(): string {
+  return new Date().toISOString();
+}
+
+function toMessage(error: unknown, fallback: string): string {
+  if (error instanceof Error && error.message.trim().length > 0) {
+    return error.message;
+  }
+  if (typeof error === "string" && error.trim().length > 0) {
+    return error;
+  }
+  return fallback;
+}
+
+function createDeferred(): Deferred {
+  let resolve!: (value: T) => void;
+  let reject!: (error: unknown) => void;
+  const deferred: Deferred = {
+    promise: new Promise((nextResolve, nextReject) => {
+      resolve = (value) => {
+        deferred.settled = true;
+        nextResolve(value);
+      };
+      reject = (error) => {
+        deferred.settled = true;
+        nextReject(error);
+      };
+    }),
+    resolve: (value) => resolve(value),
+    reject: (error) => reject(error),
+    settled: false,
+  };
+  return deferred;
+}
+
+function isRecord(value: unknown): value is Record {
+  return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+function asString(value: unknown): string | undefined {
+  return typeof value === "string" ? value : undefined;
+}
+
+function asArray(value: unknown): T[] {
+  return Array.isArray(value) ? (value as T[]) : [];
+}
+
+function stringifyJson(value: unknown): string | undefined {
+  try {
+    const serialized = JSON.stringify(value);
+    return serialized && serialized !== "{}" ? serialized : undefined;
+  } catch {
+    return undefined;
+  }
+}
+
+function truncateDetail(value: string | undefined, limit = 240): string | undefined {
+  if (!value) {
+    return undefined;
+  }
+  const trimmed = value.trim();
+  if (trimmed.length === 0) {
+    return undefined;
+  }
+  return trimmed.length > limit ? `${trimmed.slice(0, limit - 3)}...` : trimmed;
+}
+
+function permissionModeFromRuntimeMode(
+  runtimeMode: ProviderSessionStartInput["runtimeMode"],
+  interactionMode?: ProviderSendTurnInput["interactionMode"],
+): ClaudePermissionMode {
+  if (interactionMode === "plan") {
+    return "plan";
+  }
+  return runtimeMode === "approval-required" ? "default" : "bypassPermissions";
+}
+
+function requestTypeForTool(
+  toolName: string,
+):
+  | "exec_command_approval"
+  | "file_change_approval"
+  | "file_read_approval"
+  | "dynamic_tool_call"
+  | "unknown" {
+  switch (toolName) {
+    case "Bash":
+      return "exec_command_approval";
+    case "Write":
+    case "Edit":
+    case "MultiEdit":
+      return "file_change_approval";
+    case "Read":
+    case "Glob":
+    case "Grep":
+    case "LS":
+      return "file_read_approval";
+    case "Task":
+    case "Agent":
+      return "dynamic_tool_call";
+    default:
+      return "unknown";
+  }
+}
+
+function itemTypeForTool(
+  toolName: string,
+):
+  | "command_execution"
+  | "file_change"
+  | "dynamic_tool_call"
+  | "collab_agent_tool_call"
+  | "web_search"
+  | "image_view"
+  | "unknown" {
+  switch (toolName) {
+    case "Bash":
+      return "command_execution";
+    case "Write":
+    case "Edit":
+    case "MultiEdit":
+      return "file_change";
+    case "Task":
+    case "Agent":
+      return "collab_agent_tool_call";
+    case "WebFetch":
+    case "WebSearch":
+      return "web_search";
+    case "ViewImage":
+      return "image_view";
+    default:
+      return "dynamic_tool_call";
+  }
+}
+
+function readClaudeResumeSessionId(resumeCursor: unknown): string | undefined {
+  if (typeof resumeCursor === "string" && resumeCursor.trim().length > 0) {
+    return resumeCursor;
+  }
+  if (!isRecord(resumeCursor)) {
+    return undefined;
+  }
+  const direct = asString(resumeCursor.sessionId) ?? asString(resumeCursor.providerSessionId);
+  return direct?.trim().length ? direct : undefined;
+}
+
+function readClaudeBinaryPath(input: ProviderSessionStartInput): string | undefined {
+  const binaryPath = input.providerOptions?.claudeCode?.binaryPath;
+  return binaryPath?.trim().length ? binaryPath.trim() : undefined;
+}
+
+function readClaudeHomePath(input: ProviderSessionStartInput): string | undefined {
+  const homePath = input.providerOptions?.claudeCode?.homePath;
+  return homePath?.trim().length ? homePath.trim() : undefined;
+}
+
+function readClaudeEffort(
+  modelOptions: ProviderSessionStartInput["modelOptions"] | ProviderSendTurnInput["modelOptions"],
+): ClaudeCodeReasoningEffort | null {
+  const effort = modelOptions?.claudeCode?.effort;
+  return effort ?? null;
+}
+
+function sdkMessageLabel(message: SDKMessage): string {
+  const subtype = "subtype" in message && typeof message.subtype === "string" ? message.subtype : null;
+  return subtype ? `${message.type}:${subtype}` : message.type;
+}
+
+function recordStartupMessage(state: ClaudeSessionState, message: SDKMessage): void {
+  if (state.started.settled) {
+    return;
+  }
+
+  state.startupMessageCount += 1;
+  if (state.startupMessageLabels.length >= STARTUP_MESSAGE_LABEL_LIMIT) {
+    return;
+  }
+
+  state.startupMessageLabels.push(sdkMessageLabel(message));
+}
+
+function formatClaudeStartupTimeoutDetail(state: ClaudeSessionState): string {
+  const parts = [
+    `Timed out after ${Math.round(START_SESSION_TIMEOUT_MS / 1000)}s while waiting for Claude Code session initialization.`,
+  ];
+
+  if (state.session.model) {
+    parts.push(`model=${state.session.model}`);
+  }
+  if (state.currentEffort) {
+    parts.push(`effort=${state.currentEffort}`);
+  }
+  if (state.binaryPath) {
+    parts.push(`binary=${state.binaryPath}`);
+  }
+  if (state.homePath) {
+    parts.push(`CLAUDE_CONFIG_DIR=${state.homePath}`);
+  }
+  const resumeSessionId = readClaudeResumeSessionId(state.session.resumeCursor);
+  if (resumeSessionId) {
+    parts.push(`resumeSessionId=${resumeSessionId}`);
+  }
+  if (state.startupMessageCount > 0) {
+    const labels = state.startupMessageLabels.join(", ");
+    parts.push(
+      labels.length > 0
+        ? `startupMessages=${labels}${state.startupMessageCount > state.startupMessageLabels.length ? ", ..." : ""}`
+        : `startupMessageCount=${state.startupMessageCount}`,
+    );
+  } else {
+    parts.push("no startup messages received from Claude SDK");
+  }
+  if (state.homePath) {
+    parts.push("Verify the configured Claude config directory contains valid Claude auth/config.");
+  }
+
+  return parts.join(" ");
+}
+
+function buildSdkUserMessage(state: ClaudeSessionState, content: string): SDKUserMessage {
+  return {
+    type: "user",
+    session_id: readClaudeResumeSessionId(state.session.resumeCursor) ?? "",
+    parent_tool_use_id: null,
+    message: {
+      role: "user",
+      content,
+    },
+  };
+}
+
+function extractAssistantText(message: unknown): string | undefined {
+  const messageRecord = isRecord(message) ? message : undefined;
+  const content = asArray(messageRecord?.content);
+  const textBlocks = content
+    .flatMap((entry) => {
+      const block = isRecord(entry) ? entry : undefined;
+      return block?.type === "text" ? [asString(block.text) ?? ""] : [];
+    })
+    .join("");
+
+  return truncateDetail(textBlocks.length > 0 ? textBlocks : undefined, 24_000);
+}
+
+function extractStreamTextDelta(message: SDKMessage):
+  | { streamKind: "assistant_text" | "reasoning_text"; delta: string }
+  | null {
+  if (message.type !== "stream_event") {
+    return null;
+  }
+  const rawEvent = isRecord(message.event) ? message.event : undefined;
+  if (rawEvent?.type !== "content_block_delta") {
+    return null;
+  }
+  const delta = isRecord(rawEvent.delta) ? rawEvent.delta : undefined;
+  if (!delta) {
+    return null;
+  }
+  if (delta.type === "text_delta") {
+    const text = asString(delta.text);
+    return text ? { streamKind: "assistant_text", delta: text } : null;
+  }
+  if (delta.type === "thinking_delta") {
+    const thinking = asString(delta.thinking);
+    return thinking ? { streamKind: "reasoning_text", delta: thinking } : null;
+  }
+  return null;
+}
+
+function extractToolUseFromStreamStart(message: SDKMessage): { toolUseId: string; toolName: string } | null {
+  if (message.type !== "stream_event") {
+    return null;
+  }
+  const rawEvent = isRecord(message.event) ? message.event : undefined;
+  if (rawEvent?.type !== "content_block_start") {
+    return null;
+  }
+  const contentBlock = isRecord(rawEvent.content_block) ? rawEvent.content_block : undefined;
+  if (contentBlock?.type !== "tool_use") {
+    return null;
+  }
+  const toolName = asString(contentBlock.name);
+  const toolUseId = asString(contentBlock.id) ?? asString(contentBlock.tool_use_id);
+  return toolName && toolUseId ? { toolUseId, toolName } : null;
+}
+
+function isAbortLikeError(error: unknown): boolean {
+  if (!(error instanceof Error)) {
+    return false;
+  }
+  const message = error.message.toLowerCase();
+  return message.includes("abort") || message.includes("cancel") || message.includes("closed");
+}
+
+function buildPermissionDetail(input: {
+  readonly toolName: string;
+  readonly decisionReason?: string;
+  readonly blockedPath?: string;
+  readonly args?: Record;
+}): string | undefined {
+  const detailParts = [
+    input.decisionReason,
+    input.blockedPath ? `Blocked path: ${input.blockedPath}` : undefined,
+    input.args ? stringifyJson(input.args) : undefined,
+  ]
+    .map((part) => truncateDetail(part))
+    .filter((part): part is string => typeof part === "string" && part.length > 0);
+
+  if (detailParts.length === 0) {
+    return truncateDetail(`Claude Code requested permission for ${input.toolName}.`);
+  }
+
+  return truncateDetail(`${input.toolName}: ${detailParts.join(" • ")}`);
+}
+
+function finalizePendingPermissionRequests(state: ClaudeSessionState): void {
+  for (const pending of state.pendingPermissions.values()) {
+    pending.resolve({
+      behavior: "deny",
+      message: "Claude Code session closed before the permission request was answered.",
+      interrupt: true,
+      toolUseID: pending.toolUseId,
+    });
+  }
+  state.pendingPermissions.clear();
+}
+
+function finalizePendingUserInputs(state: ClaudeSessionState): void {
+  for (const pending of state.pendingUserInputs.values()) {
+    pending.resolve({ action: "cancel" });
+  }
+  state.pendingUserInputs.clear();
+}
+
+function buildElicitationQuestions(request: ElicitationRequest) {
+  const schema = isRecord(request.requestedSchema) ? request.requestedSchema : undefined;
+  const properties = isRecord(schema?.properties) ? schema.properties : undefined;
+
+  if (request.mode === "url") {
+    return [
+      {
+        id: "action",
+        header: request.serverName,
+        question: truncateDetail(
+          [request.message, request.url].filter((part): part is string => Boolean(part)).join("\n\n"),
+          4_000,
+        ) ?? "Complete the requested Claude Code authentication step.",
+        options: [
+          {
+            label: "Continue",
+            description: "I completed the requested step and want to continue.",
+          },
+          {
+            label: "Decline",
+            description: "Cancel this Claude Code request.",
+          },
+        ],
+      },
+    ] as const;
+  }
+
+  if (properties) {
+    const questions = Object.entries(properties)
+      .map(([key, value]) => {
+        const property = isRecord(value) ? value : undefined;
+        const title = truncateDetail(asString(property?.title) ?? key, 160);
+        const description = truncateDetail(
+          asString(property?.description) ?? `Provide a value for ${key}.`,
+          1_000,
+        );
+        const options = asArray(property?.enum)
+          .map((option) => asString(option))
+          .filter((option): option is string => typeof option === "string" && option.trim().length > 0)
+          .map((option) => ({ label: option, description: option }));
+
+        return title && description
+          ? {
+              id: key,
+              header: request.serverName,
+              question: description,
+              options,
+            }
+          : null;
+      })
+      .filter(
+        (
+          entry,
+        ): entry is {
+          id: string;
+          header: string;
+          question: string;
+          options: Array<{ label: string; description: string }>;
+        } => entry !== null,
+      );
+
+    if (questions.length > 0) {
+      return questions;
+    }
+  }
+
+  return [
+    {
+      id: "response",
+      header: request.serverName,
+      question:
+        truncateDetail(request.message, 4_000) ?? "Claude Code requested additional user input.",
+      options: [],
+    },
+  ] as const;
+}
+
+function createRuntimeEvent(input: {
+  readonly state: ClaudeSessionState;
+  readonly type: ProviderRuntimeEvent["type"];
+  readonly payload: unknown;
+  readonly rawSource:
+    | "claude-code.system"
+    | "claude-code.assistant"
+    | "claude-code.user"
+    | "claude-code.result"
+    | "claude-code.stream-event"
+    | "claude-code.stderr";
+  readonly rawPayload?: unknown;
+  readonly messageType?: string;
+  readonly turnId?: TurnId | null;
+  readonly itemId?: string;
+  readonly requestId?: ApprovalRequestId;
+}): ProviderRuntimeEvent {
+  const resolvedTurnId = input.turnId === null ? undefined : (input.turnId ?? input.state.activeTurnId ?? undefined);
+  const providerRefs = {
+    ...(input.itemId ? { providerItemId: ProviderItemId.makeUnsafe(input.itemId) } : {}),
+    ...(input.requestId ? { providerRequestId: input.requestId } : {}),
+  } satisfies NonNullable;
+
+  return {
+    eventId: EventId.makeUnsafe(randomUUID()),
+    provider: PROVIDER,
+    threadId: input.state.session.threadId,
+    createdAt: nowIso(),
+    ...(resolvedTurnId ? { turnId: resolvedTurnId } : {}),
+    ...(input.itemId ? { itemId: RuntimeItemId.makeUnsafe(input.itemId) } : {}),
+    ...(input.requestId ? { requestId: RuntimeRequestId.makeUnsafe(input.requestId) } : {}),
+    ...(Object.keys(providerRefs).length > 0 ? { providerRefs } : {}),
+    type: input.type,
+    payload: input.payload as never,
+    raw: {
+      source: input.rawSource,
+      ...(input.messageType ? { messageType: input.messageType } : {}),
+      payload: input.rawPayload ?? input.payload,
+    },
+  } as ProviderRuntimeEvent;
+}
+
+function updateSession(
+  state: ClaudeSessionState,
+  patch: Partial,
+): ProviderSession {
+  state.session = {
+    ...state.session,
+    ...patch,
+    updatedAt: nowIso(),
+  };
+  return state.session;
+}
+
+function ensureTurnSnapshot(state: ClaudeSessionState, turnId: TurnId): void {
+  const existing = state.snapshot.turns.find((turn) => turn.id === turnId);
+  if (existing) {
+    return;
+  }
+  state.snapshot.turns.push({ id: turnId, items: [] });
+}
+
+function appendTurnItem(state: ClaudeSessionState, turnId: TurnId, item: unknown): void {
+  ensureTurnSnapshot(state, turnId);
+  state.snapshot.turns = state.snapshot.turns.map((turn) =>
+    turn.id === turnId ? { ...turn, items: [...turn.items, item] } : turn,
+  );
+}
+
+const makeClaudeCodeAdapter = (options?: ClaudeCodeAdapterLiveOptions) =>
+  Effect.gen(function* () {
+    const createQueryImpl = options?.createQuery ?? createClaudeQuery;
+    const nativeEventLogger =
+      options?.nativeEventLogger ??
+      (options?.nativeEventLogPath !== undefined
+        ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, {
+            stream: "native",
+          })
+        : undefined);
+
+    const sessions = new Map();
+    const runtimeEventQueue = yield* Queue.unbounded();
+
+    const writeNativeEvent = async (threadId: ThreadId, event: unknown): Promise => {
+      if (!nativeEventLogger) {
+        return;
+      }
+      await Effect.runPromise(nativeEventLogger.write(event, threadId));
+    };
+
+    const emitRuntimeEvent = async (event: ProviderRuntimeEvent): Promise => {
+      await Effect.runPromise(Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid));
+    };
+
+    const completeToolUse = async (
+      state: ClaudeSessionState,
+      input: {
+        toolUseId: string;
+        status: "completed" | "failed" | "stopped";
+        detail?: string | undefined;
+      },
+    ) => {
+      if (state.completedToolUseIds.has(input.toolUseId)) {
+        return;
+      }
+      state.completedToolUseIds.add(input.toolUseId);
+      const toolName = state.toolNamesByUseId.get(input.toolUseId) ?? "Tool";
+      await emitRuntimeEvent(
+        createRuntimeEvent({
+          state,
+          type: "item.completed",
+          itemId: input.toolUseId,
+          rawSource: "claude-code.system",
+          messageType: "tool.completed",
+          payload: {
+            itemType: itemTypeForTool(toolName),
+            status:
+              input.status === "completed"
+                ? "completed"
+                : input.status === "failed"
+                  ? "failed"
+                  : "declined",
+            title: toolName,
+            ...(input.detail ? { detail: input.detail } : {}),
+          },
+        }),
+      );
+    };
+
+    const handleSdkMessage = async (state: ClaudeSessionState, message: SDKMessage): Promise => {
+      recordStartupMessage(state, message);
+      await writeNativeEvent(state.session.threadId, message);
+
+      if (message.type === "system" && message.subtype === "init") {
+        const resumeCursor = { sessionId: message.session_id };
+        updateSession(state, {
+          status: "ready",
+          model: normalizeModelSlug(message.model, PROVIDER) ?? message.model,
+          cwd: message.cwd,
+          resumeCursor,
+          lastError: undefined,
+        });
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "session.started",
+            rawSource: "claude-code.system",
+            messageType: "init",
+            payload: {
+              message: "Claude Code session started",
+              resume: resumeCursor,
+            },
+            rawPayload: message,
+            turnId: null,
+          }),
+        );
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "session.configured",
+            rawSource: "claude-code.system",
+            messageType: "init",
+            payload: {
+              config: {
+                model: message.model,
+                cwd: message.cwd,
+                permissionMode: message.permissionMode,
+                claudeCodeVersion: message.claude_code_version,
+                tools: message.tools,
+                mcpServers: message.mcp_servers,
+                slashCommands: message.slash_commands,
+                skills: message.skills,
+              },
+            },
+            rawPayload: message,
+            turnId: null,
+          }),
+        );
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "thread.started",
+            rawSource: "claude-code.system",
+            messageType: "init",
+            payload: {
+              providerThreadId: message.session_id,
+            },
+            rawPayload: message,
+            turnId: null,
+          }),
+        );
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "account.updated",
+            rawSource: "claude-code.system",
+            messageType: "init",
+            payload: {
+              account: {
+                apiKeySource: message.apiKeySource,
+                fastModeState: message.fast_mode_state ?? null,
+              },
+            },
+            rawPayload: message,
+            turnId: null,
+          }),
+        );
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "mcp.status.updated",
+            rawSource: "claude-code.system",
+            messageType: "init",
+            payload: {
+              status: message.mcp_servers,
+            },
+            rawPayload: message,
+            turnId: null,
+          }),
+        );
+        if (!state.started.settled) {
+          state.started.resolve(undefined);
+        }
+        return;
+      }
+
+      if (message.type === "auth_status") {
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "auth.status",
+            rawSource: "claude-code.system",
+            messageType: "auth_status",
+            payload: {
+              isAuthenticating: message.isAuthenticating,
+              output: message.output,
+              ...(message.error ? { error: message.error } : {}),
+            },
+            rawPayload: message,
+            turnId: null,
+          }),
+        );
+        return;
+      }
+
+      if (message.type === "rate_limit_event") {
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "account.rate-limits.updated",
+            rawSource: "claude-code.system",
+            messageType: "rate_limit_event",
+            payload: {
+              rateLimits: message.rate_limit_info,
+            },
+            rawPayload: message,
+            turnId: null,
+          }),
+        );
+        return;
+      }
+
+      if (message.type === "system" && message.subtype === "files_persisted") {
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "files.persisted",
+            rawSource: "claude-code.system",
+            messageType: "files_persisted",
+            payload: {
+              files: message.files,
+              failed: message.failed.length > 0 ? message.failed : undefined,
+            },
+            rawPayload: message,
+          }),
+        );
+        return;
+      }
+
+      if (message.type === "system" && message.subtype === "hook_started") {
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "hook.started",
+            rawSource: "claude-code.system",
+            messageType: "hook_started",
+            payload: {
+              hookId: message.hook_id,
+              hookName: message.hook_name,
+              hookEvent: message.hook_event,
+            },
+            rawPayload: message,
+          }),
+        );
+        return;
+      }
+
+      if (message.type === "system" && message.subtype === "hook_progress") {
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "hook.progress",
+            rawSource: "claude-code.system",
+            messageType: "hook_progress",
+            payload: {
+              hookId: message.hook_id,
+              output: message.output,
+              stdout: message.stdout,
+              stderr: message.stderr,
+            },
+            rawPayload: message,
+          }),
+        );
+        return;
+      }
+
+      if (message.type === "system" && message.subtype === "hook_response") {
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "hook.completed",
+            rawSource: "claude-code.system",
+            messageType: "hook_response",
+            payload: {
+              hookId: message.hook_id,
+              outcome: message.outcome,
+              output: message.output,
+              stdout: message.stdout,
+              stderr: message.stderr,
+              ...(typeof message.exit_code === "number" ? { exitCode: message.exit_code } : {}),
+            },
+            rawPayload: message,
+          }),
+        );
+        return;
+      }
+
+      if (message.type === "tool_progress") {
+        state.toolNamesByUseId.set(message.tool_use_id, message.tool_name);
+        if (!state.completedToolUseIds.has(message.tool_use_id)) {
+          await emitRuntimeEvent(
+            createRuntimeEvent({
+              state,
+              type: "item.started",
+              itemId: message.tool_use_id,
+              rawSource: "claude-code.system",
+              messageType: "tool_progress",
+              payload: {
+                itemType: itemTypeForTool(message.tool_name),
+                status: "inProgress",
+                title: message.tool_name,
+              },
+              rawPayload: message,
+            }),
+          );
+        }
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "tool.progress",
+            itemId: message.tool_use_id,
+            rawSource: "claude-code.system",
+            messageType: "tool_progress",
+            payload: {
+              toolUseId: message.tool_use_id,
+              toolName: message.tool_name,
+              elapsedSeconds: message.elapsed_time_seconds,
+            },
+            rawPayload: message,
+          }),
+        );
+        return;
+      }
+
+      if (message.type === "tool_use_summary") {
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "tool.summary",
+            rawSource: "claude-code.system",
+            messageType: "tool_use_summary",
+            payload: {
+              summary: message.summary,
+              precedingToolUseIds: message.preceding_tool_use_ids,
+            },
+            rawPayload: message,
+          }),
+        );
+        await Promise.all(
+          message.preceding_tool_use_ids.map((toolUseId) =>
+            completeToolUse(state, {
+              toolUseId,
+              status: "completed",
+              ...(truncateDetail(message.summary) ? { detail: truncateDetail(message.summary)! } : {}),
+            }),
+          ),
+        );
+        return;
+      }
+
+      if (message.type === "system" && message.subtype === "task_started") {
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "task.started",
+            rawSource: "claude-code.system",
+            messageType: "task_started",
+            payload: {
+              taskId: RuntimeTaskId.makeUnsafe(message.task_id),
+              ...(message.description ? { description: message.description } : {}),
+              ...(message.task_type ? { taskType: message.task_type } : {}),
+            },
+            rawPayload: message,
+          }),
+        );
+        return;
+      }
+
+      if (message.type === "system" && message.subtype === "task_progress") {
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "task.progress",
+            rawSource: "claude-code.system",
+            messageType: "task_progress",
+            payload: {
+              taskId: RuntimeTaskId.makeUnsafe(message.task_id),
+              description: message.description,
+              usage: message.usage,
+              ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}),
+            },
+            rawPayload: message,
+          }),
+        );
+        return;
+      }
+
+      if (message.type === "system" && message.subtype === "task_notification") {
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "task.completed",
+            rawSource: "claude-code.system",
+            messageType: "task_notification",
+            payload: {
+              taskId: RuntimeTaskId.makeUnsafe(message.task_id),
+              status: message.status,
+              ...(message.summary ? { summary: message.summary } : {}),
+              ...(message.usage ? { usage: message.usage } : {}),
+            },
+            rawPayload: message,
+          }),
+        );
+        if (message.tool_use_id) {
+          await completeToolUse(state, {
+            toolUseId: message.tool_use_id,
+            status: message.status,
+            ...(truncateDetail(message.summary) ? { detail: truncateDetail(message.summary)! } : {}),
+          });
+        }
+        return;
+      }
+
+      if (message.type === "system" && message.subtype === "local_command_output") {
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "item.completed",
+            itemId: message.uuid,
+            rawSource: "claude-code.system",
+            messageType: "local_command_output",
+            payload: {
+              itemType: "assistant_message",
+              status: "completed",
+              ...(truncateDetail(message.content, 24_000) ? { detail: truncateDetail(message.content, 24_000)! } : {}),
+              data: message,
+            },
+            rawPayload: message,
+          }),
+        );
+        return;
+      }
+
+      if (message.type === "system" && message.subtype === "status") {
+        state.currentPermissionMode = message.permissionMode ?? state.currentPermissionMode;
+        return;
+      }
+
+      if (state.activeTurnId === null) {
+        return;
+      }
+
+      const streamDelta = extractStreamTextDelta(message);
+      if (streamDelta) {
+        state.activeAssistantItemId =
+          state.activeAssistantItemId ?? message.uuid ?? `assistant:${String(state.activeTurnId)}`;
+        state.activeAssistantHasStreamedText = true;
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "content.delta",
+            itemId: state.activeAssistantItemId,
+            rawSource: "claude-code.stream-event",
+            messageType: "content_block_delta",
+            payload: {
+              streamKind: streamDelta.streamKind,
+              delta: streamDelta.delta,
+            },
+            rawPayload: message,
+          }),
+        );
+        return;
+      }
+
+      const toolStart = extractToolUseFromStreamStart(message);
+      if (toolStart) {
+        state.toolNamesByUseId.set(toolStart.toolUseId, toolStart.toolName);
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "item.started",
+            itemId: toolStart.toolUseId,
+            rawSource: "claude-code.stream-event",
+            messageType: "content_block_start",
+            payload: {
+              itemType: itemTypeForTool(toolStart.toolName),
+              status: "inProgress",
+              title: toolStart.toolName,
+              data: message,
+            },
+            rawPayload: message,
+          }),
+        );
+        appendTurnItem(state, state.activeTurnId, {
+          type: "tool.started",
+          toolUseId: toolStart.toolUseId,
+          toolName: toolStart.toolName,
+        });
+        return;
+      }
+
+      if (message.type === "assistant") {
+        state.activeAssistantItemId =
+          state.activeAssistantItemId ?? message.uuid ?? `assistant:${String(state.activeTurnId)}`;
+        const assistantText = extractAssistantText(message.message);
+        if (state.activeAssistantHasStreamedText) {
+          appendTurnItem(state, state.activeTurnId, {
+            type: "assistant.completed",
+            itemId: state.activeAssistantItemId,
+            text: assistantText,
+          });
+          state.activeAssistantCompleted = true;
+          return;
+        }
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "item.completed",
+            itemId: state.activeAssistantItemId,
+            rawSource: "claude-code.assistant",
+            messageType: "assistant",
+            payload: {
+              itemType: "assistant_message",
+              status: "completed",
+              ...(assistantText ? { detail: assistantText } : {}),
+              data: message.message,
+            },
+            rawPayload: message,
+          }),
+        );
+        appendTurnItem(state, state.activeTurnId, {
+          type: "assistant.completed",
+          itemId: state.activeAssistantItemId,
+          text: assistantText,
+        });
+        state.activeAssistantCompleted = true;
+        return;
+      }
+
+      if (message.type === "result") {
+        const turnId = state.activeTurnId;
+        const turnState = message.is_error ? "failed" : "completed";
+        const errorMessage =
+          message.is_error && "errors" in message
+            ? truncateDetail(message.errors[0], 1_000)
+            : undefined;
+        const assistantItemId = state.activeAssistantItemId ?? `assistant:${String(turnId)}`;
+
+        if (
+          !state.activeAssistantCompleted &&
+          !state.activeAssistantHasStreamedText &&
+          !message.is_error &&
+          "result" in message &&
+          typeof message.result === "string" &&
+          message.result.trim().length > 0
+        ) {
+          await emitRuntimeEvent(
+            createRuntimeEvent({
+              state,
+              type: "item.completed",
+              itemId: assistantItemId,
+              rawSource: "claude-code.result",
+              messageType: "result",
+              payload: {
+                itemType: "assistant_message",
+                status: "completed",
+                ...(truncateDetail(message.result, 24_000) ? { detail: truncateDetail(message.result, 24_000)! } : {}),
+              },
+              rawPayload: message,
+              turnId,
+            }),
+          );
+          state.activeAssistantCompleted = true;
+        }
+
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "turn.completed",
+            rawSource: "claude-code.result",
+            messageType: `result.${message.subtype}`,
+            payload: {
+              state: turnState,
+              stopReason: message.stop_reason,
+              usage: message.usage,
+              modelUsage: message.modelUsage,
+              totalCostUsd: message.total_cost_usd,
+              ...(errorMessage ? { errorMessage } : {}),
+            },
+            rawPayload: message,
+            turnId,
+          }),
+        );
+
+        updateSession(state, {
+          status: message.is_error ? "error" : "ready",
+          activeTurnId: undefined,
+          ...(errorMessage ? { lastError: errorMessage } : { lastError: undefined }),
+        });
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "session.state.changed",
+            rawSource: "claude-code.result",
+            messageType: `result.${message.subtype}`,
+            payload: {
+              state: message.is_error ? "error" : "ready",
+              ...(errorMessage ? { reason: errorMessage } : {}),
+              detail: message,
+            },
+            rawPayload: message,
+            turnId: null,
+          }),
+        );
+
+        state.activeTurnId = null;
+        state.activeAssistantItemId = null;
+        state.completedToolUseIds.clear();
+        return;
+      }
+    };
+
+    const createPermissionHandler = (
+      state: ClaudeSessionState,
+    ) => async (
+      toolName: string,
+      input: Record,
+      options: {
+        signal: AbortSignal;
+        suggestions?: unknown[];
+        blockedPath?: string;
+        decisionReason?: string;
+        toolUseID: string;
+      },
+    ): Promise => {
+      const requestId = ApprovalRequestId.makeUnsafe(randomUUID());
+        const detail = buildPermissionDetail({
+          toolName,
+          ...(options.decisionReason ? { decisionReason: options.decisionReason } : {}),
+          ...(options.blockedPath ? { blockedPath: options.blockedPath } : {}),
+          ...(Object.keys(input).length > 0 ? { args: input } : {}),
+        });
+
+      await emitRuntimeEvent(
+        createRuntimeEvent({
+          state,
+          type: "request.opened",
+          requestId,
+          rawSource: "claude-code.system",
+          messageType: "permission.request",
+          payload: {
+            requestType: requestTypeForTool(toolName),
+            ...(detail ? { detail } : {}),
+            args: {
+              toolName,
+              input,
+              ...(options.blockedPath ? { blockedPath: options.blockedPath } : {}),
+              ...(options.decisionReason ? { decisionReason: options.decisionReason } : {}),
+            },
+          },
+          rawPayload: {
+            toolName,
+            input,
+            toolUseID: options.toolUseID,
+            blockedPath: options.blockedPath,
+            decisionReason: options.decisionReason,
+          },
+        }),
+      );
+
+      return await new Promise((resolve, reject) => {
+        const onAbort = () => {
+          state.pendingPermissions.delete(requestId);
+          resolve({
+            behavior: "deny",
+            message: "Claude Code permission request was cancelled.",
+            interrupt: true,
+            toolUseID: options.toolUseID,
+          });
+        };
+        options.signal.addEventListener("abort", onAbort, { once: true });
+
+        state.pendingPermissions.set(requestId, {
+          requestId,
+          toolName,
+          toolUseId: options.toolUseID,
+          ...(detail ? { detail } : {}),
+          args: input,
+          ...(options.suggestions ? { suggestions: options.suggestions } : {}),
+          resolve: (result) => {
+            options.signal.removeEventListener("abort", onAbort);
+            resolve(result);
+          },
+          reject: (error) => {
+            options.signal.removeEventListener("abort", onAbort);
+            reject(error);
+          },
+        });
+      });
+    };
+
+    const createElicitationHandler =
+      (state: ClaudeSessionState) =>
+      async (request: ElicitationRequest): Promise => {
+        const requestId = ApprovalRequestId.makeUnsafe(randomUUID());
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "user-input.requested",
+            requestId,
+            rawSource: "claude-code.system",
+            messageType: "elicitation.request",
+            payload: {
+              questions: buildElicitationQuestions(request),
+            },
+            rawPayload: request,
+          }),
+        );
+
+        return await new Promise((resolve, reject) => {
+          state.pendingUserInputs.set(requestId, {
+            requestId,
+            request,
+            resolve,
+            reject,
+          });
+        });
+      };
+
+    const startSdkSession = async (
+      state: ClaudeSessionState,
+      input: { resumeSessionId?: string; initialUserMessage?: SDKUserMessage },
+    ) => {
+      const inputQueue = new AsyncPushQueue();
+      const abortController = new AbortController();
+      const cwd = state.session.cwd;
+      const model = state.session.model;
+      const binaryPath = state.binaryPath;
+      const homePath = state.homePath;
+      const resumeSessionId = input.resumeSessionId;
+      const query = createQueryImpl({
+        prompt: inputQueue,
+        options: {
+          abortController,
+          ...(typeof cwd === "string" && cwd.length > 0 ? { cwd } : {}),
+          ...(typeof model === "string" && model.length > 0 ? { model } : {}),
+          ...(typeof binaryPath === "string" && binaryPath.length > 0
+            ? { pathToClaudeCodeExecutable: binaryPath }
+            : {}),
+          ...(typeof homePath === "string" && homePath.length > 0
+            ? { env: { ...process.env, CLAUDE_CONFIG_DIR: homePath } }
+            : {}),
+          ...(typeof resumeSessionId === "string" && resumeSessionId.length > 0
+            ? { resume: resumeSessionId }
+            : {}),
+          ...(state.currentEffort ? { effort: state.currentEffort } : {}),
+          permissionMode: state.currentPermissionMode,
+          ...(state.currentPermissionMode === "bypassPermissions"
+            ? { allowDangerouslySkipPermissions: true }
+            : {}),
+          includePartialMessages: true,
+          persistSession: true,
+          canUseTool: createPermissionHandler(state),
+          onElicitation: createElicitationHandler(state),
+        },
+      });
+
+      state.inputQueue = inputQueue;
+      state.abortController = abortController;
+      state.query = query;
+      state.started = createDeferred();
+      state.sdkStarted = true;
+      state.readerDone = (async () => {
+        try {
+          for await (const message of query) {
+            await handleSdkMessage(state, message);
+          }
+          if (!state.stopRequested) {
+            updateSession(state, {
+              status: "closed",
+              activeTurnId: undefined,
+            });
+            await emitRuntimeEvent(
+              createRuntimeEvent({
+                state,
+                type: "session.exited",
+                rawSource: "claude-code.system",
+                messageType: "session.exited",
+                payload: {
+                  reason: "Claude Code session ended.",
+                  recoverable: true,
+                  exitKind: "graceful",
+                },
+                rawPayload: {
+                  reason: "session-ended",
+                },
+                turnId: null,
+              }),
+            );
+          }
+        } catch (error) {
+          if (!state.stopRequested && !isAbortLikeError(error)) {
+            const detail = toMessage(error, "Claude Code session terminated unexpectedly.");
+            updateSession(state, {
+              status: "error",
+              activeTurnId: undefined,
+              lastError: detail,
+            });
+            await emitRuntimeEvent(
+              createRuntimeEvent({
+                state,
+                type: "runtime.error",
+                rawSource: "claude-code.stderr",
+                messageType: "session.error",
+                payload: {
+                  message: detail,
+                  class: "provider_error" satisfies RuntimeErrorClass,
+                  detail: error,
+                },
+                rawPayload: {
+                  error: detail,
+                },
+                turnId: null,
+              }),
+            );
+            await emitRuntimeEvent(
+              createRuntimeEvent({
+                state,
+                type: "session.exited",
+                rawSource: "claude-code.stderr",
+                messageType: "session.error",
+                payload: {
+                  reason: detail,
+                  recoverable: true,
+                  exitKind: "error",
+                },
+                rawPayload: {
+                  error: detail,
+                },
+                turnId: null,
+              }),
+            );
+            if (!state.started.settled) {
+              state.started.reject(error);
+            }
+          }
+        } finally {
+          finalizePendingPermissionRequests(state);
+          finalizePendingUserInputs(state);
+          if (!state.started.settled) {
+            state.started.resolve(undefined);
+          }
+        }
+      })();
+
+      if (input.initialUserMessage) {
+        state.inputQueue.push(input.initialUserMessage);
+      }
+
+      await Promise.race([
+        state.started.promise,
+        new Promise((_, reject) => {
+          const timeout = setTimeout(() => {
+            clearTimeout(timeout);
+            reject(new Error(formatClaudeStartupTimeoutDetail(state)));
+          }, START_SESSION_TIMEOUT_MS);
+        }),
+      ]);
+    };
+
+    const stopSdkSession = async (state: ClaudeSessionState) => {
+      if (!state.sdkStarted) {
+        updateSession(state, {
+          status: "closed",
+          activeTurnId: undefined,
+        });
+        return;
+      }
+
+      state.stopRequested = true;
+      state.inputQueue.end();
+      try {
+        await state.query.interrupt();
+      } catch {
+        // Best effort.
+      }
+      state.abortController.abort();
+      finalizePendingPermissionRequests(state);
+      finalizePendingUserInputs(state);
+      try {
+        await state.readerDone;
+      } catch {
+        // Reader shutdown is best effort.
+      }
+      updateSession(state, {
+        status: "closed",
+        activeTurnId: undefined,
+      });
+      state.sdkStarted = false;
+    };
+
+    const startSession: ClaudeCodeAdapterShape["startSession"] = (input) =>
+      Effect.promise(async () => {
+        if (input.provider !== undefined && input.provider !== PROVIDER) {
+          throw new ProviderAdapterValidationError({
+            provider: PROVIDER,
+            operation: "startSession",
+            issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`,
+          });
+        }
+
+        const existing = sessions.get(input.threadId);
+        if (existing) {
+          await stopSdkSession(existing);
+          sessions.delete(input.threadId);
+        }
+
+        const normalizedModel = normalizeModelSlug(input.model, PROVIDER) ?? input.model;
+        const createdAt = nowIso();
+        const binaryPath = readClaudeBinaryPath(input);
+        const homePath = readClaudeHomePath(input);
+        const binaryPathFields =
+          typeof binaryPath === "string" && binaryPath.length > 0 ? { binaryPath } : {};
+        const homePathFields =
+          typeof homePath === "string" && homePath.length > 0 ? { homePath } : {};
+        const state = {
+          session: {
+            provider: PROVIDER,
+            status: "ready",
+            runtimeMode: input.runtimeMode,
+            threadId: input.threadId,
+            ...(input.cwd ? { cwd: input.cwd } : {}),
+            ...(normalizedModel ? { model: normalizedModel } : {}),
+            ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
+            createdAt,
+            updatedAt: createdAt,
+          },
+          snapshot: {
+            threadId: input.threadId,
+            turns: [],
+          },
+          inputQueue: new AsyncPushQueue(),
+          query: null as unknown as ClaudeQuery,
+          abortController: new AbortController(),
+          started: createDeferred(),
+          readerDone: Promise.resolve(),
+          pendingPermissions: new Map(),
+          pendingUserInputs: new Map(),
+          toolNamesByUseId: new Map(),
+          completedToolUseIds: new Set(),
+          activeTurnId: null,
+          activeAssistantItemId: null,
+          activeAssistantHasStreamedText: false,
+          activeAssistantCompleted: false,
+          currentEffort: readClaudeEffort(input.modelOptions),
+          currentPermissionMode: permissionModeFromRuntimeMode(input.runtimeMode),
+          ...binaryPathFields,
+          ...homePathFields,
+          startupMessageCount: 0,
+          startupMessageLabels: [],
+          sdkStarted: false,
+          stopRequested: false,
+        } satisfies ClaudeSessionState;
+
+        sessions.set(input.threadId, state);
+
+        return state.session;
+      });
+
+    const sendTurn: ClaudeCodeAdapterShape["sendTurn"] = (input) =>
+      Effect.promise(async () => {
+        const state = sessions.get(input.threadId);
+        if (!state) {
+          throw new ProviderAdapterSessionNotFoundError({
+            provider: PROVIDER,
+            threadId: input.threadId,
+          });
+        }
+
+        if (input.attachments && input.attachments.length > 0) {
+          throw new ProviderAdapterValidationError({
+            provider: PROVIDER,
+            operation: "sendTurn",
+            issue: "Claude Code image inputs are not wired yet in this adapter.",
+          });
+        }
+
+        if (state.activeTurnId) {
+          throw new ProviderAdapterRequestError({
+            provider: PROVIDER,
+            method: "turn/start",
+            detail: "Claude Code already has an active turn for this thread.",
+          });
+        }
+
+        const desiredPermissionMode = permissionModeFromRuntimeMode(
+          state.session.runtimeMode,
+          input.interactionMode,
+        );
+        const desiredEffort = readClaudeEffort(input.modelOptions) ?? state.currentEffort;
+        const trimmedInput = input.input?.trim();
+        const content = trimmedInput && trimmedInput.length > 0 ? trimmedInput : "Continue.";
+        const turnId = TurnId.makeUnsafe(randomUUID());
+        state.activeTurnId = turnId;
+        state.activeAssistantItemId = null;
+        state.activeAssistantHasStreamedText = false;
+        state.activeAssistantCompleted = false;
+        state.completedToolUseIds.clear();
+        state.toolNamesByUseId.clear();
+        ensureTurnSnapshot(state, turnId);
+        const initialUserMessage = buildSdkUserMessage(state, content);
+        let queuedOnSessionStart = false;
+
+        if (!state.sdkStarted) {
+          const resumeSessionId = readClaudeResumeSessionId(state.session.resumeCursor);
+          updateSession(state, {
+            status: "connecting",
+            lastError: undefined,
+          });
+          queuedOnSessionStart = true;
+          await startSdkSession(
+            state,
+            resumeSessionId
+              ? { resumeSessionId, initialUserMessage }
+              : { initialUserMessage },
+          );
+        }
+
+        if (desiredEffort !== state.currentEffort) {
+          const resumeSessionId = readClaudeResumeSessionId(state.session.resumeCursor);
+          await stopSdkSession(state);
+          state.stopRequested = false;
+          state.currentEffort = desiredEffort;
+          updateSession(state, {
+            status: "connecting",
+            lastError: undefined,
+          });
+          queuedOnSessionStart = true;
+          await startSdkSession(
+            state,
+            resumeSessionId
+              ? { resumeSessionId, initialUserMessage }
+              : { initialUserMessage },
+          );
+        }
+        if (desiredPermissionMode !== state.currentPermissionMode) {
+          await state.query.setPermissionMode(desiredPermissionMode);
+          state.currentPermissionMode = desiredPermissionMode;
+        }
+        updateSession(state, {
+          status: "running",
+          activeTurnId: turnId,
+          ...(normalizeModelSlug(input.model, PROVIDER) ?? input.model
+            ? { model: normalizeModelSlug(input.model, PROVIDER) ?? input.model }
+            : {}),
+          lastError: undefined,
+        });
+
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "session.state.changed",
+            rawSource: "claude-code.user",
+            messageType: "turn.start",
+            payload: {
+              state: "running",
+            },
+            rawPayload: input,
+            turnId: null,
+          }),
+        );
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "turn.started",
+            rawSource: "claude-code.user",
+            messageType: "turn.start",
+            payload: {
+              model: state.session.model,
+            },
+            rawPayload: input,
+            turnId,
+          }),
+        );
+
+        if (!queuedOnSessionStart && state.sdkStarted && state.started.settled) {
+          state.inputQueue.push(initialUserMessage);
+        }
+        appendTurnItem(state, turnId, {
+          type: "user",
+          input: content,
+        });
+
+        return {
+          threadId: input.threadId,
+          turnId,
+          ...(state.session.resumeCursor !== undefined
+            ? { resumeCursor: state.session.resumeCursor }
+            : {}),
+        } satisfies ProviderTurnStartResult;
+      });
+
+    const interruptTurn: ClaudeCodeAdapterShape["interruptTurn"] = (threadId) =>
+      Effect.promise(async () => {
+        const state = sessions.get(threadId);
+        if (!state) {
+          throw new ProviderAdapterSessionNotFoundError({
+            provider: PROVIDER,
+            threadId,
+          });
+        }
+
+        try {
+          await state.query.interrupt();
+        } catch (error) {
+          throw new ProviderAdapterRequestError({
+            provider: PROVIDER,
+            method: "turn/interrupt",
+            detail: toMessage(error, "Failed to interrupt Claude Code turn."),
+            cause: error,
+          });
+        }
+      });
+
+    const respondToRequest: ClaudeCodeAdapterShape["respondToRequest"] = (
+      threadId,
+      requestId,
+      decision,
+    ) =>
+      Effect.promise(async () => {
+        const state = sessions.get(threadId);
+        if (!state) {
+          throw new ProviderAdapterSessionNotFoundError({
+            provider: PROVIDER,
+            threadId,
+          });
+        }
+
+        const pending = state.pendingPermissions.get(requestId);
+        if (!pending) {
+          throw new ProviderAdapterRequestError({
+            provider: PROVIDER,
+            method: "permission/respond",
+            detail: UNKNOWN_PENDING_APPROVAL_REQUEST,
+          });
+        }
+
+        state.pendingPermissions.delete(requestId);
+        const permissionResult: PermissionResult = (() => {
+          switch (decision) {
+            case "acceptForSession":
+              return {
+                behavior: "allow",
+                ...(pending.suggestions && pending.suggestions.length > 0
+                  ? { updatedPermissions: pending.suggestions as never }
+                  : {}),
+                toolUseID: pending.toolUseId,
+              };
+            case "decline":
+              return {
+                behavior: "deny",
+                message: "Denied by user.",
+                toolUseID: pending.toolUseId,
+              };
+            case "cancel":
+              return {
+                behavior: "deny",
+                message: "Cancelled by user.",
+                interrupt: true,
+                toolUseID: pending.toolUseId,
+              };
+            case "accept":
+            default:
+              return {
+                behavior: "allow",
+                toolUseID: pending.toolUseId,
+              };
+          }
+        })();
+
+        pending.resolve(permissionResult);
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "request.resolved",
+            requestId,
+            rawSource: "claude-code.system",
+            messageType: "permission.response",
+            payload: {
+              requestType: requestTypeForTool(pending.toolName),
+              decision,
+              resolution: {
+                toolName: pending.toolName,
+                toolUseId: pending.toolUseId,
+              },
+            },
+            rawPayload: {
+              toolName: pending.toolName,
+              decision,
+            },
+          }),
+        );
+      });
+
+    const respondToUserInput: ClaudeCodeAdapterShape["respondToUserInput"] = (
+      threadId,
+      requestId,
+      answers,
+    ) =>
+      Effect.promise(async () => {
+        const state = sessions.get(threadId);
+        if (!state) {
+          throw new ProviderAdapterSessionNotFoundError({
+            provider: PROVIDER,
+            threadId,
+          });
+        }
+
+        const pending = state.pendingUserInputs.get(requestId);
+        if (!pending) {
+          throw new ProviderAdapterRequestError({
+            provider: PROVIDER,
+            method: "user-input/respond",
+            detail: UNKNOWN_PENDING_USER_INPUT_REQUEST,
+          });
+        }
+
+        state.pendingUserInputs.delete(requestId);
+        const actionAnswer = asString(answers.action) ?? asString(answers.response);
+        const loweredAction = actionAnswer?.trim().toLowerCase();
+        const response: ElicitationResult =
+          pending.request.mode === "url"
+            ? loweredAction === "decline"
+              ? { action: "decline" }
+              : loweredAction === "cancel"
+                ? { action: "cancel" }
+                : { action: "accept" }
+            : {
+                action: "accept",
+                content: answers,
+              };
+
+        pending.resolve(response);
+        await emitRuntimeEvent(
+          createRuntimeEvent({
+            state,
+            type: "user-input.resolved",
+            requestId,
+            rawSource: "claude-code.system",
+            messageType: "elicitation.response",
+            payload: {
+              answers,
+            },
+            rawPayload: {
+              request: pending.request,
+              answers,
+            },
+          }),
+        );
+      });
+
+    const stopSession: ClaudeCodeAdapterShape["stopSession"] = (threadId) =>
+      Effect.promise(async () => {
+        const state = sessions.get(threadId);
+        if (!state) {
+          throw new ProviderAdapterSessionNotFoundError({
+            provider: PROVIDER,
+            threadId,
+          });
+        }
+        sessions.delete(threadId);
+        await stopSdkSession(state);
+      });
+
+    const listSessions: ClaudeCodeAdapterShape["listSessions"] = () =>
+      Effect.sync(() => Array.from(sessions.values(), (state) => state.session));
+
+    const hasSession: ClaudeCodeAdapterShape["hasSession"] = (threadId) =>
+      Effect.sync(() => sessions.has(threadId));
+
+    const readThread: ClaudeCodeAdapterShape["readThread"] = (threadId) =>
+      Effect.sync(() => sessions.get(threadId)).pipe(
+        Effect.flatMap((state) =>
+          state
+            ? Effect.succeed(state.snapshot)
+            : Effect.fail(
+                new ProviderAdapterSessionNotFoundError({
+                  provider: PROVIDER,
+                  threadId,
+                }),
+              ),
+        ),
+      );
+
+    const rollbackThread: ClaudeCodeAdapterShape["rollbackThread"] = (_threadId) =>
+      Effect.fail(
+        new ProviderAdapterValidationError({
+          provider: PROVIDER,
+          operation: "rollbackThread",
+          issue: "Claude Code conversation rollback is not supported yet.",
+        }),
+      );
+
+    const stopAll: ClaudeCodeAdapterShape["stopAll"] = () =>
+      Effect.promise(async () => {
+        const activeSessions = Array.from(sessions.values());
+        sessions.clear();
+        await Promise.all(activeSessions.map((state) => stopSdkSession(state)));
+      });
+
+    yield* Effect.addFinalizer(() =>
+      stopAll().pipe(
+        Effect.catch(() => Effect.void),
+        Effect.andThen(Queue.shutdown(runtimeEventQueue)),
+      ),
+    );
+
+    return {
+      provider: PROVIDER,
+      capabilities: PROVIDER_CAPABILITIES_BY_PROVIDER.claudeCode,
+      startSession,
+      sendTurn,
+      interruptTurn,
+      respondToRequest,
+      respondToUserInput,
+      stopSession,
+      listSessions,
+      hasSession,
+      readThread,
+      rollbackThread,
+      stopAll,
+      streamEvents: Stream.fromQueue(runtimeEventQueue),
+    } satisfies ClaudeCodeAdapterShape;
+  });
+
+export const ClaudeCodeAdapterLive = Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter());
+
+export function makeClaudeCodeAdapterLive(options?: ClaudeCodeAdapterLiveOptions) {
+  return Layer.effect(ClaudeCodeAdapter, makeClaudeCodeAdapter(options));
+}
diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts
index 9a6271b79..43c90820f 100644
--- a/apps/server/src/provider/Layers/CodexAdapter.ts
+++ b/apps/server/src/provider/Layers/CodexAdapter.ts
@@ -10,6 +10,7 @@ import {
   type CanonicalItemType,
   type CanonicalRequestType,
   type ProviderEvent,
+  PROVIDER_CAPABILITIES_BY_PROVIDER,
   type ProviderRuntimeEvent,
   type ProviderUserInputAnswers,
   RuntimeItemId,
@@ -1492,9 +1493,7 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
 
     return {
       provider: PROVIDER,
-      capabilities: {
-        sessionModelSwitch: "in-session",
-      },
+      capabilities: PROVIDER_CAPABILITIES_BY_PROVIDER.codex,
       startSession,
       sendTurn,
       interruptTurn,
diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts
index a50112a62..4e5149d39 100644
--- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts
+++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts
@@ -1,10 +1,11 @@
-import type { ProviderKind } from "@t3tools/contracts";
+import { PROVIDER_CAPABILITIES_BY_PROVIDER, type ProviderKind } from "@t3tools/contracts";
 import { it, assert, vi } from "@effect/vitest";
 import { assertFailure } from "@effect/vitest/utils";
 
 import { Effect, Layer, Stream } from "effect";
 
 import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts";
+import { ClaudeCodeAdapter, type ClaudeCodeAdapterShape } from "../Services/ClaudeCodeAdapter.ts";
 import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts";
 import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts";
 import { ProviderUnsupportedError } from "../Errors.ts";
@@ -12,7 +13,24 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
 
 const fakeCodexAdapter: CodexAdapterShape = {
   provider: "codex",
-  capabilities: { sessionModelSwitch: "in-session" },
+  capabilities: PROVIDER_CAPABILITIES_BY_PROVIDER.codex,
+  startSession: vi.fn(),
+  sendTurn: vi.fn(),
+  interruptTurn: vi.fn(),
+  respondToRequest: vi.fn(),
+  respondToUserInput: vi.fn(),
+  stopSession: vi.fn(),
+  listSessions: vi.fn(),
+  hasSession: vi.fn(),
+  readThread: vi.fn(),
+  rollbackThread: vi.fn(),
+  stopAll: vi.fn(),
+  streamEvents: Stream.empty,
+};
+
+const fakeClaudeCodeAdapter: ClaudeCodeAdapterShape = {
+  provider: "claudeCode",
+  capabilities: PROVIDER_CAPABILITIES_BY_PROVIDER.claudeCode,
   startSession: vi.fn(),
   sendTurn: vi.fn(),
   interruptTurn: vi.fn(),
@@ -31,7 +49,10 @@ const layer = it.layer(
   Layer.mergeAll(
     Layer.provide(
       ProviderAdapterRegistryLive,
-      Layer.succeed(CodexAdapter, fakeCodexAdapter),
+      Layer.mergeAll(
+        Layer.succeed(CodexAdapter, fakeCodexAdapter),
+        Layer.succeed(ClaudeCodeAdapter, fakeClaudeCodeAdapter),
+      ),
     ),
     NodeServices.layer,
   ),
@@ -44,8 +65,11 @@ layer("ProviderAdapterRegistryLive", (it) => {
       const codex = yield* registry.getByProvider("codex");
       assert.equal(codex, fakeCodexAdapter);
 
+      const claudeCode = yield* registry.getByProvider("claudeCode");
+      assert.equal(claudeCode, fakeClaudeCodeAdapter);
+
       const providers = yield* registry.listProviders();
-      assert.deepEqual(providers, ["codex"]);
+      assert.deepEqual(providers, ["codex", "claudeCode"]);
     }),
   );
 
diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts
index 4f2c7f2c7..8bfd3a204 100644
--- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts
+++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts
@@ -16,6 +16,7 @@ import {
   type ProviderAdapterRegistryShape,
 } from "../Services/ProviderAdapterRegistry.ts";
 import { CodexAdapter } from "../Services/CodexAdapter.ts";
+import { ClaudeCodeAdapter } from "../Services/ClaudeCodeAdapter.ts";
 
 export interface ProviderAdapterRegistryLiveOptions {
   readonly adapters?: ReadonlyArray>;
@@ -26,7 +27,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption
     const adapters =
       options?.adapters !== undefined
         ? options.adapters
-        : [yield* CodexAdapter];
+        : [yield* CodexAdapter, yield* ClaudeCodeAdapter];
     const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter]));
 
     const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => {
diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts
index 59f41edf8..0b0ab2fcc 100644
--- a/apps/server/src/provider/Layers/ProviderHealth.ts
+++ b/apps/server/src/provider/Layers/ProviderHealth.ts
@@ -8,10 +8,13 @@
  *
  * @module ProviderHealthLive
  */
-import type {
-  ServerProviderAuthStatus,
-  ServerProviderStatus,
-  ServerProviderStatusState,
+import {
+  PROVIDER_CAPABILITIES_BY_PROVIDER,
+  type ProviderCapabilities,
+  type ProviderKind,
+  type ServerProviderAuthStatus,
+  type ServerProviderStatus,
+  type ServerProviderStatusState,
 } from "@t3tools/contracts";
 import { Effect, Layer, Option, Result, Stream } from "effect";
 import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
@@ -25,6 +28,16 @@ import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHe
 
 const DEFAULT_TIMEOUT_MS = 4_000;
 const CODEX_PROVIDER = "codex" as const;
+const CLAUDE_CODE_PROVIDER = "claudeCode" as const;
+
+function withCapabilities(
+  status: T,
+): T & { readonly capabilities: ProviderCapabilities } {
+  return {
+    ...status,
+    capabilities: PROVIDER_CAPABILITIES_BY_PROVIDER[status.provider],
+  };
+}
 
 // ── Pure helpers ────────────────────────────────────────────────────
 
@@ -40,12 +53,12 @@ function nonEmptyTrimmed(value: string | undefined): string | undefined {
   return trimmed.length > 0 ? trimmed : undefined;
 }
 
-function isCommandMissingCause(error: unknown): boolean {
+function isCommandMissingCause(commandName: string, error: unknown): boolean {
   if (!(error instanceof Error)) return false;
   const lower = error.message.toLowerCase();
   return (
-    lower.includes("command not found: codex") ||
-    lower.includes("spawn codex enoent") ||
+    lower.includes(`command not found: ${commandName}`) ||
+    lower.includes(`spawn ${commandName} enoent`) ||
     lower.includes("enoent") ||
     lower.includes("notfound")
   );
@@ -176,10 +189,10 @@ const collectStreamAsString = (stream: Stream.Stream): Effect.
     (acc, chunk) => acc + new TextDecoder().decode(chunk),
   );
 
-const runCodexCommand = (args: ReadonlyArray) =>
+const runProviderCommand = (commandName: string, args: ReadonlyArray) =>
   Effect.gen(function* () {
     const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
-    const command = ChildProcess.make("codex", [...args], {
+    const command = ChildProcess.make(commandName, [...args], {
       shell: process.platform === "win32",
     });
 
@@ -197,6 +210,9 @@ const runCodexCommand = (args: ReadonlyArray) =>
     return { stdout, stderr, code: exitCode } satisfies CommandResult;
   }).pipe(Effect.scoped);
 
+const runCodexCommand = (args: ReadonlyArray) => runProviderCommand("codex", args);
+const runClaudeCodeCommand = (args: ReadonlyArray) => runProviderCommand("claude", args);
+
 // ── Health check ────────────────────────────────────────────────────
 
 export const checkCodexProviderStatus: Effect.Effect<
@@ -214,33 +230,33 @@ export const checkCodexProviderStatus: Effect.Effect<
 
   if (Result.isFailure(versionProbe)) {
     const error = versionProbe.failure;
-    return {
+    return withCapabilities({
       provider: CODEX_PROVIDER,
       status: "error" as const,
       available: false,
       authStatus: "unknown" as const,
       checkedAt,
-      message: isCommandMissingCause(error)
+      message: isCommandMissingCause("codex", error)
         ? "Codex CLI (`codex`) is not installed or not on PATH."
         : `Failed to execute Codex CLI health check: ${error instanceof Error ? error.message : String(error)}.`,
-    };
+    });
   }
 
   if (Option.isNone(versionProbe.success)) {
-    return {
+    return withCapabilities({
       provider: CODEX_PROVIDER,
       status: "error" as const,
       available: false,
       authStatus: "unknown" as const,
       checkedAt,
       message: "Codex CLI is installed but failed to run. Timed out while running command.",
-    };
+    });
   }
 
   const version = versionProbe.success.value;
   if (version.code !== 0) {
     const detail = detailFromResult(version);
-    return {
+    return withCapabilities({
       provider: CODEX_PROVIDER,
       status: "error" as const,
       available: false,
@@ -249,19 +265,19 @@ export const checkCodexProviderStatus: Effect.Effect<
       message: detail
         ? `Codex CLI is installed but failed to run. ${detail}`
         : "Codex CLI is installed but failed to run.",
-    };
+    });
   }
 
   const parsedVersion = parseCodexCliVersion(`${version.stdout}\n${version.stderr}`);
   if (parsedVersion && !isCodexCliVersionSupported(parsedVersion)) {
-    return {
+    return withCapabilities({
       provider: CODEX_PROVIDER,
       status: "error" as const,
       available: false,
       authStatus: "unknown" as const,
       checkedAt,
       message: formatCodexCliUpgradeMessage(parsedVersion),
-    };
+    });
   }
 
   // Probe 2: `codex login status` — is the user authenticated?
@@ -272,7 +288,7 @@ export const checkCodexProviderStatus: Effect.Effect<
 
   if (Result.isFailure(authProbe)) {
     const error = authProbe.failure;
-    return {
+    return withCapabilities({
       provider: CODEX_PROVIDER,
       status: "warning" as const,
       available: true,
@@ -282,29 +298,164 @@ export const checkCodexProviderStatus: Effect.Effect<
         error instanceof Error
           ? `Could not verify Codex authentication status: ${error.message}.`
           : "Could not verify Codex authentication status.",
-    };
+    });
   }
 
   if (Option.isNone(authProbe.success)) {
-    return {
+    return withCapabilities({
       provider: CODEX_PROVIDER,
       status: "warning" as const,
       available: true,
       authStatus: "unknown" as const,
       checkedAt,
       message: "Could not verify Codex authentication status. Timed out while running command.",
-    };
+    });
   }
 
   const parsed = parseAuthStatusFromOutput(authProbe.success.value);
-  return {
+  return withCapabilities({
     provider: CODEX_PROVIDER,
     status: parsed.status,
     available: true,
     authStatus: parsed.authStatus,
     checkedAt,
     ...(parsed.message ? { message: parsed.message } : {}),
-  } satisfies ServerProviderStatus;
+  });
+});
+
+export const checkClaudeCodeProviderStatus: Effect.Effect<
+  ServerProviderStatus,
+  never,
+  ChildProcessSpawner.ChildProcessSpawner
+> = Effect.gen(function* () {
+  const checkedAt = new Date().toISOString();
+
+  const versionProbe = yield* runClaudeCodeCommand(["--version"]).pipe(
+    Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
+    Effect.result,
+  );
+
+  if (Result.isFailure(versionProbe)) {
+    const error = versionProbe.failure;
+    return withCapabilities({
+      provider: CLAUDE_CODE_PROVIDER,
+      status: "error" as const,
+      available: false,
+      authStatus: "unknown" as const,
+      checkedAt,
+      message: isCommandMissingCause("claude", error)
+        ? "Claude Code CLI (`claude`) is not installed or not on PATH."
+        : `Failed to execute Claude Code health check: ${error instanceof Error ? error.message : String(error)}.`,
+    });
+  }
+
+  if (Option.isNone(versionProbe.success)) {
+    return withCapabilities({
+      provider: CLAUDE_CODE_PROVIDER,
+      status: "error" as const,
+      available: false,
+      authStatus: "unknown" as const,
+      checkedAt,
+      message: "Claude Code CLI is installed but failed to run. Timed out while running command.",
+    });
+  }
+
+  const version = versionProbe.success.value;
+  if (version.code !== 0) {
+    const detail = detailFromResult(version);
+    return withCapabilities({
+      provider: CLAUDE_CODE_PROVIDER,
+      status: "error" as const,
+      available: false,
+      authStatus: "unknown" as const,
+      checkedAt,
+      message: detail
+        ? `Claude Code CLI is installed but failed to run. ${detail}`
+        : "Claude Code CLI is installed but failed to run.",
+    });
+  }
+
+  const authProbe = yield* runClaudeCodeCommand(["auth", "status"]).pipe(
+    Effect.timeoutOption(DEFAULT_TIMEOUT_MS),
+    Effect.result,
+  );
+
+  if (Result.isFailure(authProbe)) {
+    const error = authProbe.failure;
+    return withCapabilities({
+      provider: CLAUDE_CODE_PROVIDER,
+      status: "warning" as const,
+      available: true,
+      authStatus: "unknown" as const,
+      checkedAt,
+      message:
+        error instanceof Error
+          ? `Could not verify Claude Code authentication status: ${error.message}.`
+          : "Could not verify Claude Code authentication status.",
+    });
+  }
+
+  if (Option.isNone(authProbe.success)) {
+    return withCapabilities({
+      provider: CLAUDE_CODE_PROVIDER,
+      status: "warning" as const,
+      available: true,
+      authStatus: "unknown" as const,
+      checkedAt,
+      message:
+        "Could not verify Claude Code authentication status. Timed out while running command.",
+    });
+  }
+
+  const auth = authProbe.success.value;
+  const detail = detailFromResult(auth);
+  const parsedAuth = (() => {
+    const trimmed = auth.stdout.trim();
+    if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) {
+      return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined };
+    }
+    try {
+      return {
+        attemptedJsonParse: true as const,
+        auth: extractAuthBoolean(JSON.parse(trimmed)),
+      };
+    } catch {
+      return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined };
+    }
+  })();
+
+  if (parsedAuth.auth === true || auth.code === 0) {
+    return withCapabilities({
+      provider: CLAUDE_CODE_PROVIDER,
+      status: "ready" as const,
+      available: true,
+      authStatus: "authenticated" as const,
+      checkedAt,
+      ...(detail ? { message: detail } : {}),
+    });
+  }
+
+  if (parsedAuth.auth === false || auth.code === 1) {
+    return withCapabilities({
+      provider: CLAUDE_CODE_PROVIDER,
+      status: "error" as const,
+      available: true,
+      authStatus: "unauthenticated" as const,
+      checkedAt,
+      message:
+        detail ?? "Claude Code CLI is not authenticated. Run `claude auth login` and try again.",
+    });
+  }
+
+  return withCapabilities({
+    provider: CLAUDE_CODE_PROVIDER,
+    status: "warning" as const,
+    available: true,
+    authStatus: "unknown" as const,
+    checkedAt,
+    message:
+      detail ?? "Could not verify Claude Code authentication status from CLI output.",
+  });
 });
 
 // ── Layer ───────────────────────────────────────────────────────────
@@ -313,8 +464,9 @@ export const ProviderHealthLive = Layer.effect(
   ProviderHealth,
   Effect.gen(function* () {
     const codexStatus = yield* checkCodexProviderStatus;
+    const claudeCodeStatus = yield* checkClaudeCodeProviderStatus;
     return {
-      getStatuses: Effect.succeed([codexStatus]),
+      getStatuses: Effect.succeed([codexStatus, claudeCodeStatus]),
     } satisfies ProviderHealthShape;
   }),
 );
diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts
index 63b41d6b0..0a37b141e 100644
--- a/apps/server/src/provider/Layers/ProviderService.test.ts
+++ b/apps/server/src/provider/Layers/ProviderService.test.ts
@@ -12,6 +12,7 @@ import type {
 import {
   ApprovalRequestId,
   EventId,
+  PROVIDER_CAPABILITIES_BY_PROVIDER,
   type ProviderKind,
   ProviderSessionStartInput,
   ThreadId,
@@ -175,7 +176,7 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") {
   const adapter: ProviderAdapterShape = {
     provider,
     capabilities: {
-      sessionModelSwitch: "in-session",
+      ...PROVIDER_CAPABILITIES_BY_PROVIDER[provider],
     },
     startSession,
     sendTurn,
diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts
index 05a1de149..8c8ff582a 100644
--- a/apps/server/src/provider/Layers/ProviderService.ts
+++ b/apps/server/src/provider/Layers/ProviderService.ts
@@ -291,9 +291,11 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
           );
         }
 
-        yield* upsertSessionBinding(session, threadId, {
-          ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),
-        });
+        yield* upsertSessionBinding(
+          session,
+          threadId,
+          input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {},
+        );
         yield* analytics.record("provider.session.started", {
           provider: session.provider,
           runtimeMode: input.runtimeMode,
diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts
index 69e1e439b..14c7b569b 100644
--- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts
+++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts
@@ -25,7 +25,7 @@ function decodeProviderKind(
   providerName: string,
   operation: string,
 ): Effect.Effect {
-  if (providerName === "codex") {
+  if (providerName === "codex" || providerName === "claudeCode") {
     return Effect.succeed(providerName);
   }
   return Effect.fail(
diff --git a/apps/server/src/provider/Services/ClaudeCodeAdapter.ts b/apps/server/src/provider/Services/ClaudeCodeAdapter.ts
new file mode 100644
index 000000000..eb7023927
--- /dev/null
+++ b/apps/server/src/provider/Services/ClaudeCodeAdapter.ts
@@ -0,0 +1,21 @@
+/**
+ * ClaudeCodeAdapter - Claude Code implementation of the generic provider adapter contract.
+ *
+ * Uses the Claude Agent SDK to manage session lifecycle, streaming turns,
+ * approvals, and canonical runtime events for the shared provider layer.
+ *
+ * @module ClaudeCodeAdapter
+ */
+import { ServiceMap } from "effect";
+
+import type { ProviderAdapterError } from "../Errors.ts";
+import type { ProviderAdapterShape } from "./ProviderAdapter.ts";
+
+export interface ClaudeCodeAdapterShape extends ProviderAdapterShape {
+  readonly provider: "claudeCode";
+}
+
+export class ClaudeCodeAdapter extends ServiceMap.Service<
+  ClaudeCodeAdapter,
+  ClaudeCodeAdapterShape
+>()("t3/provider/Services/ClaudeCodeAdapter") {}
diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts
index 67755b538..c144a8d8e 100644
--- a/apps/server/src/provider/Services/ProviderAdapter.ts
+++ b/apps/server/src/provider/Services/ProviderAdapter.ts
@@ -9,6 +9,7 @@
  */
 import type {
   ApprovalRequestId,
+  ProviderCapabilities,
   ProviderApprovalDecision,
   ProviderKind,
   ProviderUserInputAnswers,
@@ -23,14 +24,7 @@ import type {
 import type { Effect } from "effect";
 import type { Stream } from "effect";
 
-export type ProviderSessionModelSwitchMode = "in-session" | "restart-session" | "unsupported";
-
-export interface ProviderAdapterCapabilities {
-  /**
-   * Declares whether changing the model on an existing session is supported.
-   */
-  readonly sessionModelSwitch: ProviderSessionModelSwitchMode;
-}
+export type ProviderAdapterCapabilities = ProviderCapabilities;
 
 export interface ProviderThreadTurnSnapshot {
   readonly id: TurnId;
diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts
index b0630a55b..025d25a11 100644
--- a/apps/server/src/serverLayers.ts
+++ b/apps/server/src/serverLayers.ts
@@ -18,6 +18,7 @@ import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/Proj
 import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery";
 import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion";
 import { ProviderUnsupportedError } from "./provider/Errors";
+import { makeClaudeCodeAdapterLive } from "./provider/Layers/ClaudeCodeAdapter";
 import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter";
 import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry";
 import { makeProviderServiceLive } from "./provider/Layers/ProviderService";
@@ -57,8 +58,12 @@ export function makeServerProviderLayer(): Layer.Layer<
     const codexAdapterLayer = makeCodexAdapterLive(
       nativeEventLogger ? { nativeEventLogger } : undefined,
     );
+    const claudeCodeAdapterLayer = makeClaudeCodeAdapterLive(
+      nativeEventLogger ? { nativeEventLogger } : undefined,
+    );
     const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe(
       Layer.provide(codexAdapterLayer),
+      Layer.provide(claudeCodeAdapterLayer),
       Layer.provideMerge(providerSessionDirectoryLayer),
     );
     return makeProviderServiceLive(
diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts
index 285028cca..631e37f10 100644
--- a/apps/server/src/wsServer.test.ts
+++ b/apps/server/src/wsServer.test.ts
@@ -17,6 +17,7 @@ import {
   EventId,
   ORCHESTRATION_WS_CHANNELS,
   ORCHESTRATION_WS_METHODS,
+  PROVIDER_CAPABILITIES_BY_PROVIDER,
   ProviderItemId,
   ThreadId,
   TurnId,
@@ -75,6 +76,7 @@ const defaultProviderStatuses: ReadonlyArray = [
     status: "ready",
     available: true,
     authStatus: "authenticated",
+    capabilities: PROVIDER_CAPABILITIES_BY_PROVIDER.codex,
     checkedAt: "2026-01-01T00:00:00.000Z",
   },
 ];
@@ -1170,7 +1172,7 @@ describe("WebSocket Server", () => {
       respondToUserInput: () => unsupported(),
       stopSession: () => unsupported(),
       listSessions: () => Effect.succeed([]),
-      getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
+      getCapabilities: () => Effect.succeed(PROVIDER_CAPABILITIES_BY_PROVIDER.codex),
       rollbackConversation: () => unsupported(),
       streamEvents: Stream.fromPubSub(runtimeEventPubSub),
     };
diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts
index e58f7af4a..c72b37799 100644
--- a/apps/web/src/appSettings.ts
+++ b/apps/web/src/appSettings.ts
@@ -28,9 +28,16 @@ const AppServiceTierSchema = Schema.Literals(["auto", "fast", "flex"]);
 const MODELS_WITH_FAST_SUPPORT = new Set(["gpt-5.4"]);
 const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = {
   codex: new Set(getModelOptions("codex").map((option) => option.slug)),
+  claudeCode: new Set(getModelOptions("claudeCode").map((option) => option.slug)),
 };
 
 const AppSettingsSchema = Schema.Struct({
+  claudeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
+    Schema.withConstructorDefault(() => Option.some("")),
+  ),
+  claudeHomePath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
+    Schema.withConstructorDefault(() => Option.some("")),
+  ),
   codexBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(
     Schema.withConstructorDefault(() => Option.some("")),
   ),
@@ -45,6 +52,9 @@ const AppSettingsSchema = Schema.Struct({
   customCodexModels: Schema.Array(Schema.String).pipe(
     Schema.withConstructorDefault(() => Option.some([])),
   ),
+  customClaudeCodeModels: Schema.Array(Schema.String).pipe(
+    Schema.withConstructorDefault(() => Option.some([])),
+  ),
 });
 export type AppSettings = typeof AppSettingsSchema.Type;
 export interface AppModelOption {
@@ -108,6 +118,10 @@ function normalizeAppSettings(settings: AppSettings): AppSettings {
   return {
     ...settings,
     customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),
+    customClaudeCodeModels: normalizeCustomModelSlugs(
+      settings.customClaudeCodeModels,
+      "claudeCode",
+    ),
   };
 }
 
diff --git a/apps/web/src/clipboard.ts b/apps/web/src/clipboard.ts
new file mode 100644
index 000000000..7e6530f78
--- /dev/null
+++ b/apps/web/src/clipboard.ts
@@ -0,0 +1,7 @@
+export async function copyTextToClipboard(text: string): Promise {
+  if (typeof navigator === "undefined" || navigator.clipboard?.writeText === undefined) {
+    throw new Error("Clipboard API unavailable.");
+  }
+  await navigator.clipboard.writeText(text);
+}
+
diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx
index e2fd573fe..062bdb709 100644
--- a/apps/web/src/components/ChatView.browser.tsx
+++ b/apps/web/src/components/ChatView.browser.tsx
@@ -5,6 +5,7 @@ import {
   ORCHESTRATION_WS_METHODS,
   type MessageId,
   type OrchestrationReadModel,
+  PROVIDER_CAPABILITIES_BY_PROVIDER,
   type ProjectId,
   type ServerConfig,
   type ThreadId,
@@ -103,6 +104,15 @@ function createBaseServerConfig(): ServerConfig {
         status: "ready",
         available: true,
         authStatus: "authenticated",
+        capabilities: PROVIDER_CAPABILITIES_BY_PROVIDER.codex,
+        checkedAt: NOW_ISO,
+      },
+      {
+        provider: "claudeCode",
+        status: "ready",
+        available: true,
+        authStatus: "authenticated",
+        capabilities: PROVIDER_CAPABILITIES_BY_PROVIDER.claudeCode,
         checkedAt: NOW_ISO,
       },
     ],
diff --git a/apps/web/src/components/ChatView.providerOptions.test.ts b/apps/web/src/components/ChatView.providerOptions.test.ts
new file mode 100644
index 000000000..1a3206602
--- /dev/null
+++ b/apps/web/src/components/ChatView.providerOptions.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+
+import { getProviderOptionsForDispatch } from "./ChatView.providerOptions";
+
+describe("getProviderOptionsForDispatch", () => {
+  it("returns Claude Code overrides when configured", () => {
+    expect(
+      getProviderOptionsForDispatch(
+        {
+          claudeBinaryPath: "/usr/local/bin/claude",
+          claudeHomePath: "/tmp/.claude",
+          codexBinaryPath: "",
+          codexHomePath: "",
+        },
+        "claudeCode",
+      ),
+    ).toEqual({
+      claudeCode: {
+        binaryPath: "/usr/local/bin/claude",
+        homePath: "/tmp/.claude",
+      },
+    });
+  });
+
+  it("returns Codex overrides when configured", () => {
+    expect(
+      getProviderOptionsForDispatch(
+        {
+          claudeBinaryPath: "",
+          claudeHomePath: "",
+          codexBinaryPath: "/usr/local/bin/codex",
+          codexHomePath: "/tmp/.codex",
+        },
+        "codex",
+      ),
+    ).toEqual({
+      codex: {
+        binaryPath: "/usr/local/bin/codex",
+        homePath: "/tmp/.codex",
+      },
+    });
+  });
+
+  it("omits overrides when the selected provider has no configured values", () => {
+    expect(
+      getProviderOptionsForDispatch(
+        {
+          claudeBinaryPath: "/usr/local/bin/claude",
+          claudeHomePath: "/tmp/.claude",
+          codexBinaryPath: "",
+          codexHomePath: "",
+        },
+        "codex",
+      ),
+    ).toBeUndefined();
+  });
+});
diff --git a/apps/web/src/components/ChatView.providerOptions.ts b/apps/web/src/components/ChatView.providerOptions.ts
new file mode 100644
index 000000000..4e7dd6da8
--- /dev/null
+++ b/apps/web/src/components/ChatView.providerOptions.ts
@@ -0,0 +1,33 @@
+import type { ProviderKind, ProviderSessionStartInput } from "@t3tools/contracts";
+
+import type { AppSettings } from "../appSettings";
+
+export function getProviderOptionsForDispatch(
+  settings: Pick<
+    AppSettings,
+    "claudeBinaryPath" | "claudeHomePath" | "codexBinaryPath" | "codexHomePath"
+  >,
+  provider: ProviderKind,
+): ProviderSessionStartInput["providerOptions"] | undefined {
+  const providerSettings =
+    provider === "claudeCode"
+      ? {
+          binaryPath: settings.claudeBinaryPath,
+          homePath: settings.claudeHomePath,
+        }
+      : {
+          binaryPath: settings.codexBinaryPath,
+          homePath: settings.codexHomePath,
+        };
+
+  const normalized = {
+    ...(providerSettings.binaryPath ? { binaryPath: providerSettings.binaryPath } : {}),
+    ...(providerSettings.homePath ? { homePath: providerSettings.homePath } : {}),
+  };
+
+  if (Object.keys(normalized).length === 0) {
+    return undefined;
+  }
+
+  return provider === "claudeCode" ? { claudeCode: normalized } : { codex: normalized };
+}
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index a96b17bdb..3c1852d71 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -1,11 +1,12 @@
 import {
   type ApprovalRequestId,
+  DEFAULT_PROVIDER_KIND,
   DEFAULT_MODEL_BY_PROVIDER,
   EDITORS,
   type EditorId,
   type KeybindingCommand,
-  type CodexReasoningEffort,
   type MessageId,
+  type ProviderReasoningEffort,
   type ProjectId,
   type ProjectEntry,
   type ProjectScript,
@@ -14,6 +15,7 @@ import {
   PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
   type ResolvedKeybindingsConfig,
   type ProviderApprovalDecision,
+  PROVIDER_CAPABILITIES_BY_PROVIDER,
   type ServerProviderStatus,
   type ProviderKind,
   type ThreadId,
@@ -26,6 +28,7 @@ import {
   getDefaultModel,
   getDefaultReasoningEffort,
   getReasoningEffortOptions,
+  inferProviderFromModel,
   normalizeModelSlug,
   resolveModelSlugForProvider,
 } from "@t3tools/shared/model";
@@ -50,9 +53,11 @@ import {
 import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
 import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
 import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
+import { getProviderOptionsForDispatch } from "./ChatView.providerOptions";
 
 import { isElectron } from "../env";
 import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
+import { getProviderAuthGuidance } from "../providerAuthGuidance";
 import {
   type ComposerSlashCommand,
   type ComposerTrigger,
@@ -744,7 +749,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
         ? buildLocalDraftThread(
             threadId,
             draftThread,
-            fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex,
+            fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER[DEFAULT_PROVIDER_KIND],
             localDraftError,
           )
         : undefined,
@@ -766,6 +771,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
   const activeLatestTurn = activeThread?.latestTurn ?? null;
   const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null);
   const activeProject = projects.find((p) => p.id === activeThread?.projectId);
+  const serverConfigQuery = useQuery(serverConfigQueryOptions());
+  const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES;
+  const providerStatusMap = useMemo(
+    () => new Map(providerStatuses.map((status) => [status.provider, status] as const)),
+    [providerStatuses],
+  );
+  const availableProviderOptions = useMemo(
+    () => getAvailableProviderOptions(providerStatusMap),
+    [providerStatusMap],
+  );
+  const unavailableProviderOptions = useMemo(
+    () => getUnavailableProviderOptions(providerStatusMap),
+    [providerStatusMap],
+  );
 
   useEffect(() => {
     if (!activeThread?.id) return;
@@ -787,6 +806,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
 
   const sessionProvider = activeThread?.session?.provider ?? null;
   const selectedProviderByThreadId = composerDraft.provider;
+  const inferredThreadProvider = inferProviderFromModel(activeThread?.model ?? null);
+  const inferredProjectProvider = inferProviderFromModel(activeProject?.model ?? null);
   const hasThreadStarted = Boolean(
     activeThread &&
     (activeThread.latestTurn !== null ||
@@ -794,16 +815,31 @@ export default function ChatView({ threadId }: ChatViewProps) {
       activeThread.session !== null),
   );
   const selectedServiceTierSetting = settings.codexServiceTier;
-  const selectedServiceTier = resolveAppServiceTier(selectedServiceTierSetting);
   const lockedProvider: ProviderKind | null = hasThreadStarted
-    ? (sessionProvider ?? selectedProviderByThreadId ?? null)
+    ?
+        sessionProvider ??
+        selectedProviderByThreadId ??
+        inferredThreadProvider ??
+        inferredProjectProvider ??
+        null
+    : null;
+  const selectedProvider: ProviderKind =
+    lockedProvider ??
+    selectedProviderByThreadId ??
+    inferredThreadProvider ??
+    inferredProjectProvider ??
+    DEFAULT_PROVIDER_KIND;
+  const selectedProviderCapabilities =
+    providerStatusMap.get(selectedProvider)?.capabilities ??
+    PROVIDER_CAPABILITIES_BY_PROVIDER[selectedProvider];
+  const selectedServiceTier = selectedProviderCapabilities.supportsServiceTier
+    ? resolveAppServiceTier(selectedServiceTierSetting)
     : null;
-  const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex";
   const baseThreadModel = resolveModelSlugForProvider(
     selectedProvider,
     activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider),
   );
-  const customModelsForSelectedProvider = settings.customCodexModels;
+  const customModelsForSelectedProvider = getCustomModelsForProvider(settings, selectedProvider);
   const selectedModel = useMemo(() => {
     const draftModel = composerDraft.model;
     if (!draftModel) {
@@ -815,32 +851,64 @@ export default function ChatView({ threadId }: ChatViewProps) {
       draftModel,
     ) as ModelSlug;
   }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]);
-  const reasoningOptions = getReasoningEffortOptions(selectedProvider);
-  const supportsReasoningEffort = reasoningOptions.length > 0;
-  const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider);
+  const reasoningOptions = useMemo(
+    () =>
+      selectedProviderCapabilities.supportsReasoningEffort
+        ? getReasoningEffortOptions(selectedProvider)
+        : [],
+    [selectedProvider, selectedProviderCapabilities.supportsReasoningEffort],
+  );
+  const supportsReasoningEffort =
+    selectedProviderCapabilities.supportsReasoningEffort && reasoningOptions.length > 0;
+  const selectedEffort = useMemo(() => {
+    const fallback = getDefaultReasoningEffort(selectedProvider);
+    const candidate = composerDraft.effort ?? fallback;
+    if (!candidate) {
+      return fallback;
+    }
+    return reasoningOptions.includes(candidate) ? candidate : fallback;
+  }, [composerDraft.effort, reasoningOptions, selectedProvider]);
   const selectedCodexFastModeEnabled =
     selectedProvider === "codex" ? composerDraft.codexFastMode : false;
   const selectedModelOptionsForDispatch = useMemo(() => {
-    if (selectedProvider !== "codex") {
-      return undefined;
+    switch (selectedProvider) {
+      case "claudeCode": {
+        const effort =
+          supportsReasoningEffort &&
+          selectedEffort &&
+          (selectedEffort === "max" ||
+            selectedEffort === "high" ||
+            selectedEffort === "medium" ||
+            selectedEffort === "low")
+            ? selectedEffort
+            : undefined;
+        const claudeCodeOptions = effort ? { effort } : undefined;
+        return claudeCodeOptions ? { claudeCode: claudeCodeOptions } : undefined;
+      }
+      case "codex": {
+        const reasoningEffort =
+          supportsReasoningEffort &&
+          selectedEffort &&
+          (selectedEffort === "xhigh" ||
+            selectedEffort === "high" ||
+            selectedEffort === "medium" ||
+            selectedEffort === "low")
+            ? selectedEffort
+            : undefined;
+        const codexOptions = {
+          ...(reasoningEffort ? { reasoningEffort } : {}),
+          ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}),
+        };
+        return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined;
+      }
+      default:
+        return undefined;
     }
-    const codexOptions = {
-      ...(supportsReasoningEffort && selectedEffort ? { reasoningEffort: selectedEffort } : {}),
-      ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}),
-    };
-    return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined;
   }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]);
-  const providerOptionsForDispatch = useMemo(() => {
-    if (!settings.codexBinaryPath && !settings.codexHomePath) {
-      return undefined;
-    }
-    return {
-      codex: {
-        ...(settings.codexBinaryPath ? { binaryPath: settings.codexBinaryPath } : {}),
-        ...(settings.codexHomePath ? { homePath: settings.codexHomePath } : {}),
-      },
-    };
-  }, [settings.codexBinaryPath, settings.codexHomePath]);
+  const selectedProviderOptionsForDispatch = useMemo(
+    () => getProviderOptionsForDispatch(settings, selectedProvider),
+    [selectedProvider, settings],
+  );
   const selectedModelForPicker = selectedModel;
   const modelOptionsByProvider = useMemo(
     () => getCustomModelOptionsByProvider(settings),
@@ -854,7 +922,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
   }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]);
   const searchableModelOptions = useMemo(
     () =>
-      AVAILABLE_PROVIDER_OPTIONS.filter(
+      availableProviderOptions.filter(
         (option) => lockedProvider === null || option.value === lockedProvider,
       ).flatMap((option) =>
         modelOptionsByProvider[option.value].map(({ slug, name }) => ({
@@ -867,7 +935,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
           searchProvider: option.label.toLowerCase(),
         })),
       ),
-    [lockedProvider, modelOptionsByProvider],
+    [availableProviderOptions, lockedProvider, modelOptionsByProvider],
   );
   const phase = derivePhase(activeThread?.session ?? null);
   const isSendBusy = sendPhase !== "idle";
@@ -1191,7 +1259,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
   );
   const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : "";
   const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd));
-  const serverConfigQuery = useQuery(serverConfigQueryOptions());
   const workspaceEntriesQuery = useQuery(
     projectSearchEntriesQueryOptions({
       cwd: gitCwd,
@@ -1215,7 +1282,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
     }
 
     if (composerTrigger.kind === "slash-command") {
-      const slashCommandItems = [
+      const slashCommandItemsBase: Array> = [
         {
           id: "slash:model",
           type: "slash-command",
@@ -1223,21 +1290,31 @@ export default function ChatView({ threadId }: ChatViewProps) {
           label: "/model",
           description: "Switch response model for this thread",
         },
-        {
-          id: "slash:plan",
-          type: "slash-command",
-          command: "plan",
-          label: "/plan",
-          description: "Switch this thread into plan mode",
-        },
-        {
-          id: "slash:default",
-          type: "slash-command",
-          command: "default",
-          label: "/default",
-          description: "Switch this thread back to normal chat mode",
-        },
-      ] satisfies ReadonlyArray>;
+      ];
+      const interactionModeSlashCommandItems: Array<
+        Extract
+      > = selectedProviderCapabilities.supportsCollaborationMode
+        ? [
+            {
+              id: "slash:plan",
+              type: "slash-command",
+              command: "plan",
+              label: "/plan",
+              description: "Switch this thread into plan mode",
+            },
+            {
+              id: "slash:default",
+              type: "slash-command",
+              command: "default",
+              label: "/default",
+              description: "Switch this thread back to normal chat mode",
+            },
+          ]
+        : [];
+      const slashCommandItems: Array> = [
+        ...slashCommandItemsBase,
+        ...interactionModeSlashCommandItems,
+      ];
       const query = composerTrigger.query.trim().toLowerCase();
       if (!query) {
         return [...slashCommandItems];
@@ -1265,7 +1342,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
         showFastBadge:
           provider === "codex" && shouldShowFastTierIcon(slug, selectedServiceTierSetting),
       }));
-  }, [composerTrigger, searchableModelOptions, selectedServiceTierSetting, workspaceEntries]);
+  }, [
+    composerTrigger,
+    searchableModelOptions,
+    selectedProviderCapabilities.supportsCollaborationMode,
+    selectedServiceTierSetting,
+    workspaceEntries,
+  ]);
   const composerMenuOpen = Boolean(composerTrigger);
   const activeComposerMenuItem = useMemo(
     () =>
@@ -1283,8 +1366,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
   );
   const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS;
   const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS;
-  const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDER_STATUSES;
-  const activeProvider = activeThread?.session?.provider ?? "codex";
+  const activeProvider = selectedProvider;
   const activeProviderStatus = useMemo(
     () => providerStatuses.find((status) => status.provider === activeProvider) ?? null,
     [activeProvider, providerStatuses],
@@ -1673,6 +1755,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
 
   const handleInteractionModeChange = useCallback(
     (mode: ProviderInteractionMode) => {
+      if (!selectedProviderCapabilities.supportsCollaborationMode) {
+        toastManager.add({
+          type: "warning",
+          title: "Interaction mode unavailable",
+          description: "This provider does not support T3 plan/default interaction mode switching yet.",
+        });
+        scheduleComposerFocus();
+        return;
+      }
       if (mode === interactionMode) return;
       setComposerDraftInteractionMode(threadId, mode);
       if (isLocalDraftThread) {
@@ -1684,6 +1775,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
       interactionMode,
       isLocalDraftThread,
       scheduleComposerFocus,
+      selectedProviderCapabilities.supportsCollaborationMode,
       setComposerDraftInteractionMode,
       setDraftThreadContext,
       threadId,
@@ -2400,6 +2492,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
       const api = readNativeApi();
       if (!api || !activeThread || isRevertingCheckpoint) return;
 
+      if (!selectedProviderCapabilities.supportsConversationRollback) {
+        setThreadError(
+          activeThread.id,
+          `${selectedProvider} does not support checkpoint revert yet.`,
+        );
+        return;
+      }
+
       if (phase === "running" || isSendBusy || isConnecting) {
         setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
         return;
@@ -2433,7 +2533,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
       }
       setIsRevertingCheckpoint(false);
     },
-    [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
+    [
+      activeThread,
+      isConnecting,
+      isRevertingCheckpoint,
+      isSendBusy,
+      phase,
+      selectedProvider,
+      selectedProviderCapabilities.supportsConversationRollback,
+      setThreadError,
+    ],
   );
 
   const onSend = async (e?: { preventDefault: () => void }) => {
@@ -2475,6 +2584,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
     if (!trimmed && composerImages.length === 0) return;
     if (!activeProject) return;
     const threadIdForSend = activeThread.id;
+    if (composerImages.length > 0 && !selectedProviderCapabilities.supportsImageInputs) {
+      setStoreThreadError(
+        threadIdForSend,
+        `${selectedProvider} does not support image inputs yet.`,
+      );
+      return;
+    }
     const isFirstMessage = !isServerThread || activeThread.messages.length === 0;
     const baseBranchForWorktree =
       isFirstMessage && envMode === "worktree" && !activeThread.worktreePath
@@ -2585,7 +2701,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
       }
       const title = truncateTitle(titleSeed);
       let threadCreateModel: ModelSlug =
-        selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex;
+        selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER[selectedProvider];
 
       if (isLocalDraftThread) {
         await api.orchestration.dispatchCommand({
@@ -2667,8 +2783,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
         ...(selectedModelOptionsForDispatch
           ? { modelOptions: selectedModelOptionsForDispatch }
           : {}),
-        ...(providerOptionsForDispatch
-          ? { providerOptions: providerOptionsForDispatch }
+        ...(selectedProviderOptionsForDispatch
+          ? { providerOptions: selectedProviderOptionsForDispatch }
           : {}),
         provider: selectedProvider,
         assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
@@ -2947,8 +3063,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
           ...(selectedModelOptionsForDispatch
             ? { modelOptions: selectedModelOptionsForDispatch }
             : {}),
-          ...(providerOptionsForDispatch
-            ? { providerOptions: providerOptionsForDispatch }
+          ...(selectedProviderOptionsForDispatch
+            ? { providerOptions: selectedProviderOptionsForDispatch }
             : {}),
           assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
           runtimeMode,
@@ -2980,7 +3096,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
       runtimeMode,
       selectedModel,
       selectedModelOptionsForDispatch,
-      providerOptionsForDispatch,
+      selectedProviderOptionsForDispatch,
       selectedProvider,
       setComposerDraftInteractionMode,
       setThreadError,
@@ -3012,7 +3128,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
       selectedModel ||
       (activeThread.model as ModelSlug) ||
       (activeProject.model as ModelSlug) ||
-      DEFAULT_MODEL_BY_PROVIDER.codex;
+      DEFAULT_MODEL_BY_PROVIDER[selectedProvider];
 
     sendInFlightRef.current = true;
     beginSendPhase("sending-turn");
@@ -3051,8 +3167,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
           ...(selectedModelOptionsForDispatch
             ? { modelOptions: selectedModelOptionsForDispatch }
             : {}),
-          ...(providerOptionsForDispatch
-            ? { providerOptions: providerOptionsForDispatch }
+          ...(selectedProviderOptionsForDispatch
+            ? { providerOptions: selectedProviderOptionsForDispatch }
             : {}),
           assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
           runtimeMode,
@@ -3103,7 +3219,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
     runtimeMode,
     selectedModel,
     selectedModelOptionsForDispatch,
-    providerOptionsForDispatch,
+    selectedProviderOptionsForDispatch,
     selectedProvider,
     settings.enableAssistantStreaming,
     syncServerReadModel,
@@ -3119,7 +3235,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
       setComposerDraftProvider(activeThread.id, provider);
       setComposerDraftModel(
         activeThread.id,
-        resolveAppModelSelection(provider, settings.customCodexModels, model),
+        resolveAppModelSelection(provider, getCustomModelsForProvider(settings, provider), model),
       );
       scheduleComposerFocus();
     },
@@ -3129,15 +3245,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
       scheduleComposerFocus,
       setComposerDraftModel,
       setComposerDraftProvider,
-      settings.customCodexModels,
+      settings,
     ],
   );
   const onEffortSelect = useCallback(
-    (effort: CodexReasoningEffort) => {
-      setComposerDraftEffort(threadId, effort);
+    (effort: ProviderReasoningEffort) => {
+      setComposerDraftEffort(threadId, selectedProvider, effort);
       scheduleComposerFocus();
     },
-    [scheduleComposerFocus, setComposerDraftEffort, threadId],
+    [scheduleComposerFocus, selectedProvider, setComposerDraftEffort, threadId],
   );
   const onCodexFastModeChange = useCallback(
     (enabled: boolean) => {
@@ -3502,6 +3618,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
           onToggleWorkGroup={onToggleWorkGroup}
           onOpenTurnDiff={onOpenTurnDiff}
           revertTurnCountByUserMessageId={revertTurnCountByUserMessageId}
+          supportsConversationRollback={selectedProviderCapabilities.supportsConversationRollback}
           onRevertUserMessage={onRevertUserMessage}
           isRevertingCheckpoint={isRevertingCheckpoint}
           onImageExpand={onExpandTimelineImage}
@@ -3685,14 +3802,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
                     model={selectedModelForPickerWithCustomFallback}
                     lockedProvider={lockedProvider}
                     modelOptionsByProvider={modelOptionsByProvider}
+                    availableProviderOptions={availableProviderOptions}
+                    unavailableProviderOptions={unavailableProviderOptions}
                     serviceTierSetting={selectedServiceTierSetting}
                     onProviderModelChange={onProviderModelSelect}
                   />
 
-                  {selectedProvider === "codex" && selectedEffort != null ? (
+                  {supportsReasoningEffort && selectedEffort != null ? (
                     <>
                       
-                      
       
         
         
-          {status.provider === "codex" ? "Codex provider status" : `${status.provider} status`}
+          {`${providerLabel} provider status`}
         
-        
-          {status.message ?? defaultMessage}
+        
+          {message}
         
       
     
@@ -4774,6 +4907,7 @@ interface MessagesTimelineProps {
   onToggleWorkGroup: (groupId: string) => void;
   onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
   revertTurnCountByUserMessageId: Map;
+  supportsConversationRollback: boolean;
   onRevertUserMessage: (messageId: MessageId) => void;
   isRevertingCheckpoint: boolean;
   onImageExpand: (preview: ExpandedImagePreview) => void;
@@ -4828,6 +4962,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
   onToggleWorkGroup,
   onOpenTurnDiff,
   revertTurnCountByUserMessageId,
+  supportsConversationRollback,
   onRevertUserMessage,
   isRevertingCheckpoint,
   onImageExpand,
@@ -5122,7 +5257,9 @@ const MessagesTimeline = memo(function MessagesTimeline({
         row.message.role === "user" &&
         (() => {
           const userImages = row.message.attachments ?? [];
-          const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id);
+          const canRevertAgentWork =
+            supportsConversationRollback &&
+            revertTurnCountByUserMessageId.has(row.message.id);
           return (
             
@@ -5356,16 +5493,17 @@ const MessagesTimeline = memo(function MessagesTimeline({ ); }); -function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { +type ProviderMenuOption = { value: ProviderKind; label: string; - available: true; -} { - return option.available && option.value !== "claudeCode"; -} + status: ServerProviderStatus | null; +}; + +type UnavailableProviderMenuOption = ProviderMenuOption & { + badgeLabel: string; + detail: string; +}; -const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); -const UNAVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter((option) => !option.available); const COMING_SOON_PROVIDER_OPTIONS = [ { id: "opencode", label: "OpenCode", icon: OpenCodeIcon }, { id: "gemini", label: "Gemini", icon: Gemini }, @@ -5373,12 +5511,83 @@ const COMING_SOON_PROVIDER_OPTIONS = [ function getCustomModelOptionsByProvider(settings: { customCodexModels: readonly string[]; + customClaudeCodeModels: readonly string[]; }): Record> { return { codex: getAppModelOptions("codex", settings.customCodexModels), + claudeCode: getAppModelOptions("claudeCode", settings.customClaudeCodeModels), }; } +function getCustomModelsForProvider( + settings: { + customCodexModels: readonly string[]; + customClaudeCodeModels: readonly string[]; + }, + provider: ProviderKind, +): readonly string[] { + switch (provider) { + case "claudeCode": + return settings.customClaudeCodeModels; + case "codex": + default: + return settings.customCodexModels; + } +} + +function isProviderSelectable( + provider: ProviderKind, + status: ServerProviderStatus | null, +): boolean { + if (!status) { + return provider === DEFAULT_PROVIDER_KIND; + } + return status.available && status.authStatus !== "unauthenticated"; +} + +function getAvailableProviderOptions( + providerStatusMap: ReadonlyMap, +): ReadonlyArray { + return PROVIDER_OPTIONS.flatMap((option) => { + if (option.value === "cursor") { + return []; + } + const status = providerStatusMap.get(option.value) ?? null; + return isProviderSelectable(option.value, status) + ? [{ value: option.value, label: option.label, status }] + : []; + }); +} + +function getUnavailableProviderOptions( + providerStatusMap: ReadonlyMap, +): ReadonlyArray { + return PROVIDER_OPTIONS.flatMap((option) => { + if (option.value === "cursor") { + return []; + } + const status = providerStatusMap.get(option.value) ?? null; + if (isProviderSelectable(option.value, status)) { + return []; + } + + const badgeLabel = + status?.authStatus === "unauthenticated" + ? "Sign in" + : status?.available === false + ? "Unavailable" + : "Unavailable"; + const authGuidance = getProviderAuthGuidance(option.value, status?.authStatus ?? null); + const detail = + status?.message ?? + (status?.authStatus === "unauthenticated" + ? authGuidance?.summary ?? `${option.label} requires sign-in before it can be used.` + : `${option.label} is unavailable on this machine.`); + + return [{ value: option.value, label: option.label, status, badgeLabel, detail }]; + }); +} + const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, claudeCode: ClaudeAI, @@ -5423,6 +5632,8 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { model: ModelSlug; lockedProvider: ProviderKind | null; modelOptionsByProvider: Record>; + availableProviderOptions: ReadonlyArray; + unavailableProviderOptions: ReadonlyArray; serviceTierSetting: AppServiceTier; disabled?: boolean; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; @@ -5464,7 +5675,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { - {AVAILABLE_PROVIDER_OPTIONS.map((option) => { + {props.availableProviderOptions.map((option) => { const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; const isDisabledByProviderLock = props.lockedProvider !== null && props.lockedProvider !== option.value; @@ -5514,11 +5725,11 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { ); })} - {UNAVAILABLE_PROVIDER_OPTIONS.length > 0 && } - {UNAVAILABLE_PROVIDER_OPTIONS.map((option) => { + {props.unavailableProviderOptions.length > 0 && } + {props.unavailableProviderOptions.map((option) => { const OptionIcon = PROVIDER_ICON_BY_PROVIDER[option.value]; return ( - + ); })} - {UNAVAILABLE_PROVIDER_OPTIONS.length === 0 && } + {props.unavailableProviderOptions.length === 0 && } {COMING_SOON_PROVIDER_OPTIONS.map((option) => { const OptionIcon = option.icon; return ( @@ -5551,24 +5762,27 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: { ); }); -const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { - effort: CodexReasoningEffort; +const PROVIDER_REASONING_LABELS: Record = { + max: "Max", + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", +}; + +const ProviderTraitsPicker = memo(function ProviderTraitsPicker(props: { + provider: ProviderKind; + effort: ProviderReasoningEffort; fastModeEnabled: boolean; - options: ReadonlyArray; - onEffortChange: (effort: CodexReasoningEffort) => void; + options: ReadonlyArray; + onEffortChange: (effort: ProviderReasoningEffort) => void; onFastModeChange: (enabled: boolean) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningLabelByOption: Record = { - low: "Low", - medium: "Medium", - high: "High", - xhigh: "Extra High", - }; + const defaultReasoningEffort = getDefaultReasoningEffort(props.provider); const triggerLabel = [ - reasoningLabelByOption[props.effort], - ...(props.fastModeEnabled ? ["Fast"] : []), + PROVIDER_REASONING_LABELS[props.effort], + ...(props.provider === "codex" && props.fastModeEnabled ? ["Fast"] : []), ] .filter(Boolean) .join(" · "); @@ -5606,25 +5820,29 @@ const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { > {props.options.map((effort) => ( - {reasoningLabelByOption[effort]} + {PROVIDER_REASONING_LABELS[effort]} {effort === defaultReasoningEffort ? " (default)" : ""} ))} - - -
Fast Mode
- { - props.onFastModeChange(value === "on"); - }} - > - off - on - -
+ {props.provider === "codex" ? ( + <> + + +
Fast Mode
+ { + props.onFastModeChange(value === "on"); + }} + > + off + on + +
+ + ) : null}
); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ecfd526ac..9e4a8c4ea 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -11,6 +11,7 @@ import { } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + DEFAULT_PROVIDER_KIND, DEFAULT_RUNTIME_MODE, DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, @@ -22,6 +23,7 @@ import { import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useLocation, useNavigate, useParams } from "@tanstack/react-router"; import { useAppSettings } from "../appSettings"; +import { copyTextToClipboard } from "../clipboard"; import { isElectron } from "../env"; import { APP_STAGE_LABEL } from "../branding"; import { newCommandId, newProjectId, newThreadId } from "../lib/utils"; @@ -67,13 +69,6 @@ import { isNonEmpty as isNonEmptyString } from "effect/String"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const THREAD_PREVIEW_LIMIT = 6; -async function copyTextToClipboard(text: string): Promise { - if (typeof navigator === "undefined" || navigator.clipboard?.writeText === undefined) { - throw new Error("Clipboard API unavailable."); - } - await navigator.clipboard.writeText(text); -} - function formatRelativeTime(iso: string): string { const diff = Date.now() - new Date(iso).getTime(); const minutes = Math.floor(diff / 60_000); @@ -513,7 +508,7 @@ export default function Sidebar() { projectId, title, workspaceRoot: cwd, - defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, + defaultModel: DEFAULT_MODEL_BY_PROVIDER[DEFAULT_PROVIDER_KIND], createdAt, }); await handleNewThread(projectId).catch(() => undefined); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2ac03a3ed..9312025e4 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,11 +1,13 @@ import { + DEFAULT_PROVIDER_KIND, DEFAULT_REASONING_EFFORT_BY_PROVIDER, ProjectId, + PROVIDER_KIND_VALUES, REASONING_EFFORT_OPTIONS_BY_PROVIDER, ThreadId, - type CodexReasoningEffort, type ProviderKind, type ProviderInteractionMode, + type ProviderReasoningEffort, type RuntimeMode, } from "@t3tools/contracts"; import { normalizeModelSlug } from "@t3tools/shared/model"; @@ -40,7 +42,7 @@ interface PersistedComposerThreadDraftState { model?: string | null; runtimeMode?: RuntimeMode | null; interactionMode?: ProviderInteractionMode | null; - effort?: CodexReasoningEffort | null; + effort?: ProviderReasoningEffort | null; codexFastMode?: boolean | null; serviceTier?: string | null; } @@ -70,7 +72,7 @@ interface ComposerThreadDraftState { model: string | null; runtimeMode: RuntimeMode | null; interactionMode: ProviderInteractionMode | null; - effort: CodexReasoningEffort | null; + effort: ProviderReasoningEffort | null; codexFastMode: boolean; } @@ -129,7 +131,11 @@ interface ComposerDraftStoreState { threadId: ThreadId, interactionMode: ProviderInteractionMode | null | undefined, ) => void; - setEffort: (threadId: ThreadId, effort: CodexReasoningEffort | null | undefined) => void; + setEffort: ( + threadId: ThreadId, + provider: ProviderKind, + effort: ProviderReasoningEffort | null | undefined, + ) => void; setCodexFastMode: (threadId: ThreadId, enabled: boolean | null | undefined) => void; addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void; addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void; @@ -168,9 +174,10 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ codexFastMode: false, }) as ComposerThreadDraftState; -const REASONING_EFFORT_VALUES = new Set( - REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex, -); +const REASONING_EFFORT_VALUES = new Set([ + ...REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex, + ...REASONING_EFFORT_OPTIONS_BY_PROVIDER.claudeCode, +]); function createEmptyThreadDraft(): ComposerThreadDraftState { return { @@ -208,7 +215,9 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { } function normalizeProviderKind(value: unknown): ProviderKind | null { - return value === "codex" ? value : null; + return typeof value === "string" && PROVIDER_KIND_VALUES.includes(value as ProviderKind) + ? (value as ProviderKind) + : null; } function revokeObjectPreviewUrl(previewUrl: string): void { @@ -369,7 +378,7 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer const provider = normalizeProviderKind(draftCandidate.provider); const model = typeof draftCandidate.model === "string" - ? normalizeModelSlug(draftCandidate.model, provider ?? "codex") + ? normalizeModelSlug(draftCandidate.model, provider ?? DEFAULT_PROVIDER_KIND) : null; const runtimeMode = draftCandidate.runtimeMode === "approval-required" || @@ -383,8 +392,8 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer const effortCandidate = typeof draftCandidate.effort === "string" ? draftCandidate.effort : null; const effort = - effortCandidate && REASONING_EFFORT_VALUES.has(effortCandidate as CodexReasoningEffort) - ? (effortCandidate as CodexReasoningEffort) + effortCandidate && REASONING_EFFORT_VALUES.has(effortCandidate as ProviderReasoningEffort) + ? (effortCandidate as ProviderReasoningEffort) : null; const codexFastMode = draftCandidate.codexFastMode === true || @@ -888,14 +897,14 @@ export const useComposerDraftStore = create()( return { draftsByThreadId: nextDraftsByThreadId }; }); }, - setEffort: (threadId, effort) => { + setEffort: (threadId, provider, effort) => { if (threadId.length === 0) { return; } const nextEffort = effort && REASONING_EFFORT_VALUES.has(effort) && - effort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex + effort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER[provider] ? effort : null; set((state) => { diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts index 017b6bee0..5af08d829 100644 --- a/apps/web/src/lib/utils.test.ts +++ b/apps/web/src/lib/utils.test.ts @@ -1,6 +1,13 @@ -import { assert, describe, it } from "vitest"; +import { afterEach, assert, describe, it, vi } from "vitest"; -import { isWindowsPlatform } from "./utils"; +import { isWindowsPlatform, randomUuid } from "./utils"; + +const UUID_V4_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +afterEach(() => { + vi.unstubAllGlobals(); +}); describe("isWindowsPlatform", () => { it("matches Windows platform identifiers", () => { @@ -13,3 +20,35 @@ describe("isWindowsPlatform", () => { assert.isFalse(isWindowsPlatform("darwin")); }); }); + +describe("randomUuid", () => { + it("uses crypto.randomUUID when available", () => { + vi.stubGlobal("crypto", { + randomUUID: () => "11111111-2222-4333-8444-555555555555", + }); + + assert.equal(randomUuid(), "11111111-2222-4333-8444-555555555555"); + }); + + it("builds a UUID from getRandomValues when randomUUID is unavailable", () => { + vi.stubGlobal("crypto", { + getRandomValues: (bytes: Uint8Array) => { + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = index; + } + return bytes; + }, + }); + + const uuid = randomUuid(); + assert.match(uuid, UUID_V4_PATTERN); + assert.equal(uuid, "00010203-0405-4607-8809-0a0b0c0d0e0f"); + }); + + it("falls back to Math.random when crypto is unavailable", () => { + vi.stubGlobal("crypto", undefined); + + const uuid = randomUuid(); + assert.match(uuid, UUID_V4_PATTERN); + }); +}); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index 7684e68d7..970f1eff4 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -14,10 +14,31 @@ export function isWindowsPlatform(platform: string): boolean { return /^win(dows)?/i.test(platform); } -export const newCommandId = (): CommandId => CommandId.makeUnsafe(crypto.randomUUID()); +export function randomUuid(): string { + if (typeof globalThis.crypto?.randomUUID === "function") { + return globalThis.crypto.randomUUID(); + } -export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(crypto.randomUUID()); + if (typeof globalThis.crypto?.getRandomValues === "function") { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40; + bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80; + const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } -export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(crypto.randomUUID()); + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => { + const value = Math.floor(Math.random() * 16); + const nibble = char === "x" ? value : (value & 0x3) | 0x8; + return nibble.toString(16); + }); +} + +export const newCommandId = (): CommandId => CommandId.makeUnsafe(randomUuid()); + +export const newProjectId = (): ProjectId => ProjectId.makeUnsafe(randomUuid()); + +export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUuid()); -export const newMessageId = (): MessageId => MessageId.makeUnsafe(crypto.randomUUID()); +export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUuid()); diff --git a/apps/web/src/providerAuthGuidance.test.ts b/apps/web/src/providerAuthGuidance.test.ts new file mode 100644 index 000000000..fba461816 --- /dev/null +++ b/apps/web/src/providerAuthGuidance.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { getProviderAuthGuidance } from "./providerAuthGuidance"; + +describe("getProviderAuthGuidance", () => { + it("returns concise Claude auth guidance", () => { + expect(getProviderAuthGuidance("claudeCode")).toEqual({ + summary: "Claude supports native sign-in and external API-key mode.", + detail: + "T3 Code does not store Claude secrets. Use `claude auth login`, or configure API-key mode with environment/config outside the app.", + }); + }); + + it("returns sign-in guidance for unauthenticated Claude", () => { + expect(getProviderAuthGuidance("claudeCode", "unauthenticated")?.summary).toContain( + "claude auth login", + ); + }); + + it("does not add extra auth guidance for Codex", () => { + expect(getProviderAuthGuidance("codex")).toBeNull(); + }); +}); diff --git a/apps/web/src/providerAuthGuidance.ts b/apps/web/src/providerAuthGuidance.ts new file mode 100644 index 000000000..7309e5281 --- /dev/null +++ b/apps/web/src/providerAuthGuidance.ts @@ -0,0 +1,27 @@ +import type { ProviderKind, ServerProviderAuthStatus } from "@t3tools/contracts"; + +export interface ProviderAuthGuidance { + readonly summary: string; + readonly detail: string; +} + +export const CLAUDE_CODE_GETTING_STARTED_DOCS_URL = + "https://docs.anthropic.com/en/docs/claude-code/getting-started"; + +export function getProviderAuthGuidance( + provider: ProviderKind, + authStatus?: ServerProviderAuthStatus | null, +): ProviderAuthGuidance | null { + if (provider !== "claudeCode") { + return null; + } + + return { + summary: + authStatus === "unauthenticated" + ? "Sign in with `claude auth login`, or use Claude API-key mode outside T3 Code." + : "Claude supports native sign-in and external API-key mode.", + detail: + "T3 Code does not store Claude secrets. Use `claude auth login`, or configure API-key mode with environment/config outside the app.", + }; +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index cc4a39a27..fc48ef9f8 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { type ProviderKind } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; -import { ZapIcon } from "lucide-react"; +import { CopyIcon, ExternalLinkIcon, ZapIcon } from "lucide-react"; import { APP_SERVICE_TIER_OPTIONS, @@ -15,13 +15,21 @@ import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { ensureNativeApi } from "../nativeApi"; +import { + CLAUDE_CODE_GETTING_STARTED_DOCS_URL, + getProviderAuthGuidance, +} from "../providerAuthGuidance"; +import { copyTextToClipboard } from "../clipboard"; import { preferredTerminalEditor } from "../terminal-links"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; +import { toastManager } from "../components/ui/toast"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../components/ui/select"; import { Switch } from "../components/ui/switch"; import { SidebarInset } from "~/components/ui/sidebar"; +const CLAUDE_AUTH_LOGIN_COMMAND = "claude auth login"; + const THEME_OPTIONS = [ { value: "system", @@ -54,6 +62,13 @@ const MODEL_PROVIDER_SETTINGS: Array<{ placeholder: "your-codex-model-slug", example: "gpt-6.7-codex-ultra-preview", }, + { + provider: "claudeCode", + title: "Claude Code", + description: "Save additional Claude model aliases or full model names for the picker and `/model` command.", + placeholder: "your-claude-model-alias", + example: "claude-opus-4-1", + }, ] as const; function getCustomModelsForProvider( @@ -61,6 +76,8 @@ function getCustomModelsForProvider( provider: ProviderKind, ) { switch (provider) { + case "claudeCode": + return settings.customClaudeCodeModels; case "codex": default: return settings.customCodexModels; @@ -72,6 +89,8 @@ function getDefaultCustomModelsForProvider( provider: ProviderKind, ) { switch (provider) { + case "claudeCode": + return defaults.customClaudeCodeModels; case "codex": default: return defaults.customCodexModels; @@ -80,6 +99,8 @@ function getDefaultCustomModelsForProvider( function patchCustomModels(provider: ProviderKind, models: string[]) { switch (provider) { + case "claudeCode": + return { customClaudeCodeModels: models }; case "codex": default: return { customCodexModels: models }; @@ -96,6 +117,7 @@ function SettingsRouteView() { Record >({ codex: "", + claudeCode: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> @@ -103,7 +125,10 @@ function SettingsRouteView() { const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; + const claudeBinaryPath = settings.claudeBinaryPath; + const claudeHomePath = settings.claudeHomePath; const codexServiceTier = settings.codexServiceTier; + const claudeAuthGuidance = getProviderAuthGuidance("claudeCode"); const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const openKeybindingsFile = useCallback(() => { @@ -123,6 +148,35 @@ function SettingsRouteView() { }); }, [keybindingsConfigPath]); + const copyClaudeAuthCommand = useCallback(() => { + void copyTextToClipboard(CLAUDE_AUTH_LOGIN_COMMAND) + .then(() => { + toastManager.add({ + type: "success", + title: "Claude auth command copied", + description: CLAUDE_AUTH_LOGIN_COMMAND, + }); + }) + .catch((error) => { + toastManager.add({ + type: "error", + title: "Failed to copy Claude auth command", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + }, []); + + const openClaudeDocs = useCallback(() => { + const api = ensureNativeApi(); + void api.shell.openExternal(CLAUDE_CODE_GETTING_STARTED_DOCS_URL).catch((error) => { + toastManager.add({ + type: "error", + title: "Failed to open Claude docs", + description: error instanceof Error ? error.message : "An error occurred.", + }); + }); + }, []); + const addCustomModel = useCallback((provider: ProviderKind) => { const customModelInput = customModelInputByProvider[provider]; const customModels = getCustomModelsForProvider(settings, provider); @@ -300,6 +354,84 @@ function SettingsRouteView() {
+
+
+

Claude Code

+

+ These overrides apply to new Claude sessions and let you use a non-default Claude install or config directory. +

+ {claudeAuthGuidance ? ( +
+

+ {claudeAuthGuidance.summary} {claudeAuthGuidance.detail} +

+
+ + {CLAUDE_AUTH_LOGIN_COMMAND} + + + +
+
+ ) : null} +
+ +
+ + + + +
+

+ Binary source:{" "} + {claudeBinaryPath || "PATH"} +

+ +
+
+
+

Models

diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 3d1d269d4..5e85d379f 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -640,23 +640,21 @@ describe("deriveActiveWorkStartedAt", () => { }); describe("PROVIDER_OPTIONS", () => { - it("keeps Claude Code and Cursor visible as unavailable placeholders in the stack base", () => { + it("keeps Claude Code and Cursor visible in the base provider list", () => { const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeCode"); const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); expect(PROVIDER_OPTIONS).toEqual([ - { value: "codex", label: "Codex", available: true }, - { value: "claudeCode", label: "Claude Code", available: false }, - { value: "cursor", label: "Cursor", available: false }, + { value: "codex", label: "Codex" }, + { value: "claudeCode", label: "Claude Code" }, + { value: "cursor", label: "Cursor" }, ]); expect(claude).toEqual({ value: "claudeCode", label: "Claude Code", - available: false, }); expect(cursor).toEqual({ value: "cursor", label: "Cursor", - available: false, }); }); }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index e9351ca2b..7db10443c 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -10,16 +10,15 @@ import { import type { ChatMessage, ProposedPlan, SessionPhase, ThreadSession, TurnDiffSummary } from "./types"; -export type ProviderPickerKind = ProviderKind | "claudeCode" | "cursor"; +export type ProviderPickerKind = ProviderKind | "cursor"; export const PROVIDER_OPTIONS: Array<{ value: ProviderPickerKind; label: string; - available: boolean; }> = [ - { value: "codex", label: "Codex", available: true }, - { value: "claudeCode", label: "Claude Code", available: false }, - { value: "cursor", label: "Cursor", available: false }, + { value: "codex", label: "Codex" }, + { value: "claudeCode", label: "Claude Code" }, + { value: "cursor", label: "Cursor" }, ]; export interface WorkLogEntry { diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 145d8301e..4d06ed31e 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -1,5 +1,4 @@ import { - DEFAULT_MODEL_BY_PROVIDER, ProjectId, ThreadId, TurnId, @@ -135,7 +134,7 @@ describe("store pure functions", () => { }); describe("store read model sync", () => { - it("falls back to the codex default for unsupported provider models without an active session", () => { + it("infers the Claude provider from a known Claude model without an active session", () => { const initialState = makeState(makeThread()); const readModel = makeReadModel( makeReadModelThread({ @@ -145,6 +144,6 @@ describe("store read model sync", () => { const next = syncServerReadModel(initialState, readModel); - expect(next.threads[0]?.model).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); + expect(next.threads[0]?.model).toBe("claude-opus-4-6"); }); }); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 65c966537..b3c2d5fed 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,6 +1,8 @@ import { Fragment, type ReactNode, createElement, useEffect } from "react"; import { + DEFAULT_PROVIDER_KIND, DEFAULT_MODEL_BY_PROVIDER, + PROVIDER_KIND_VALUES, type ProviderKind, ThreadId, type OrchestrationReadModel, @@ -112,7 +114,7 @@ function mapProjectsFromReadModel( cwd: project.workspaceRoot, model: existing?.model ?? - resolveModelSlug(project.defaultModel ?? DEFAULT_MODEL_BY_PROVIDER.codex), + resolveModelSlug(project.defaultModel ?? DEFAULT_MODEL_BY_PROVIDER[DEFAULT_PROVIDER_KIND]), expanded: existing?.expanded ?? (persistedExpandedProjectCwds.size > 0 @@ -143,26 +145,35 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (providerName === "codex") { - return providerName; - } - return "codex"; + return typeof providerName === "string" && PROVIDER_KIND_VALUES.includes(providerName as ProviderKind) + ? (providerName as ProviderKind) + : DEFAULT_PROVIDER_KIND; } -const CODEX_MODEL_SLUGS = new Set(getModelOptions("codex").map((option) => option.slug)); +const MODEL_SLUGS_BY_PROVIDER: Record> = { + codex: new Set(getModelOptions("codex").map((option) => option.slug)), + claudeCode: new Set(getModelOptions("claudeCode").map((option) => option.slug)), +}; function inferProviderForThreadModel(input: { readonly model: string; readonly sessionProviderName: string | null; }): ProviderKind { - if (input.sessionProviderName === "codex") { - return input.sessionProviderName; + if ( + typeof input.sessionProviderName === "string" && + PROVIDER_KIND_VALUES.includes(input.sessionProviderName as ProviderKind) + ) { + return input.sessionProviderName as ProviderKind; } - const normalizedCodex = normalizeModelSlug(input.model, "codex"); - if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) { - return "codex"; + + for (const provider of PROVIDER_KIND_VALUES) { + const normalizedModel = normalizeModelSlug(input.model, provider); + if (normalizedModel && MODEL_SLUGS_BY_PROVIDER[provider].has(normalizedModel)) { + return provider; + } } - return "codex"; + + return DEFAULT_PROVIDER_KIND; } function resolveWsHttpOrigin(): string { diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index 142174fb0..ca0726279 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -3,6 +3,7 @@ import { type ContextMenuItem, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, + PROVIDER_CAPABILITIES_BY_PROVIDER, ProjectId, ThreadId, WS_CHANNELS, @@ -70,6 +71,7 @@ const defaultProviders: ReadonlyArray = [ status: "ready", available: true, authStatus: "authenticated", + capabilities: PROVIDER_CAPABILITIES_BY_PROVIDER.codex, checkedAt: "2026-01-01T00:00:00.000Z", }, ]; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 652ebea30..8e6393b3b 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,9 +1,32 @@ +import { formatHostForUrl, isIpAddressHost, isLoopbackHost, isWildcardHost, normalizeHost } from "@t3tools/shared/host"; import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; import { defineConfig } from "vite"; const port = Number(process.env.PORT ?? 5733); +const bindHost = process.env.T3CODE_HOST ? normalizeHost(process.env.T3CODE_HOST) : "localhost"; +const remoteBindEnabled = !isLoopbackHost(bindHost); +const configuredDevUrl = process.env.VITE_DEV_SERVER_URL; +const devServerUrl = configuredDevUrl ? new URL(configuredDevUrl) : undefined; +const explicitPublicHost = devServerUrl?.hostname; +const publicHmrHost = + explicitPublicHost && (!isLoopbackHost(explicitPublicHost) || !isWildcardHost(bindHost)) + ? explicitPublicHost + : !isWildcardHost(bindHost) + ? formatHostForUrl(bindHost) + : undefined; +const allowedHosts = (() => { + if (remoteBindEnabled) { + return true; + } + + const candidate = publicHmrHost ?? (!isWildcardHost(bindHost) ? bindHost : undefined); + if (!candidate || isLoopbackHost(candidate) || isIpAddressHost(candidate)) { + return undefined; + } + return [normalizeHost(candidate)]; +})(); export default defineConfig({ plugins: [ @@ -29,14 +52,17 @@ export default defineConfig({ tsconfigPaths: true, }, server: { + host: bindHost, port, strictPort: true, + ...(allowedHosts ? { allowedHosts } : {}), hmr: { // Explicit config so Vite's HMR WebSocket connects reliably // inside Electron's BrowserWindow. Vite 8 uses console.debug for // connection logs — enable "Verbose" in DevTools to see them. protocol: "ws", - host: "localhost", + ...(publicHmrHost ? { host: publicHmrHost } : {}), + clientPort: Number(devServerUrl?.port ?? port), }, }, build: { diff --git a/bun.lock b/bun.lock index ce4b7e7a1..bd544e9fb 100644 --- a/bun.lock +++ b/bun.lock @@ -48,6 +48,7 @@ "t3": "./dist/index.mjs", }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.71", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@pierre/diffs": "^1.1.0-beta.16", @@ -55,6 +56,7 @@ "node-pty": "^1.1.0", "open": "^10.1.0", "ws": "^8.18.0", + "zod": "^4.3.6", }, "devDependencies": { "@effect/language-service": "catalog:", @@ -173,6 +175,8 @@ "vitest": "^4.0.0", }, "packages": { + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.71", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-pIsQJnM7Y+cJHL7aFY6SCCW3FIni218gVEpPqG8XGowfYxboFNBbNssWiUNRwthT8bp9jypcX7q5kx0Xsw14xg=="], + "@astrojs/check": ["@astrojs/check@0.9.6", "", { "dependencies": { "@astrojs/language-server": "^2.16.1", "chokidar": "^4.0.1", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA=="], "@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], @@ -1927,7 +1931,7 @@ "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], @@ -1999,8 +2003,12 @@ "@tanstack/react-store/@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], + "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@tanstack/router-plugin/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@tanstack/router-utils/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], "@tanstack/router-utils/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], @@ -2015,6 +2023,8 @@ "astro/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + "astro/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "babel-dead-code-elimination/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], "boxen/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], @@ -2073,6 +2083,8 @@ "yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], + "zod-to-ts/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@babel/generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], "@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0-rc.2", "", {}, "sha512-noLx87RwlBEMrTzncWd/FvTxoJ9+ycHNg0n8yyYydIoDsLZuxknKgWRJUqcrVkNrJ74uGyhWQzQaS3q8xfGAhQ=="], diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0f37a9351..709a6c1ea 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -3,6 +3,7 @@ export * from "./ipc"; export * from "./terminal"; export * from "./provider"; export * from "./providerRuntime"; +export * from "./providerOptions"; export * from "./model"; export * from "./ws"; export * from "./keybindings"; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 189fbf09d..61c52ab7e 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -3,6 +3,9 @@ import { ProviderKind } from "./orchestration"; export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number]; +export const CLAUDE_CODE_REASONING_EFFORT_OPTIONS = ["max", "high", "medium", "low"] as const; +export type ClaudeCodeReasoningEffort = (typeof CLAUDE_CODE_REASONING_EFFORT_OPTIONS)[number]; +export type ProviderReasoningEffort = CodexReasoningEffort | ClaudeCodeReasoningEffort; export const CodexModelOptions = Schema.Struct({ reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), @@ -10,8 +13,14 @@ export const CodexModelOptions = Schema.Struct({ }); export type CodexModelOptions = typeof CodexModelOptions.Type; +export const ClaudeCodeModelOptions = Schema.Struct({ + effort: Schema.optional(Schema.Literals(CLAUDE_CODE_REASONING_EFFORT_OPTIONS)), +}); +export type ClaudeCodeModelOptions = typeof ClaudeCodeModelOptions.Type; + export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), + claudeCode: Schema.optional(ClaudeCodeModelOptions), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; @@ -28,6 +37,10 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, { slug: "gpt-5.2", name: "GPT-5.2" }, ], + claudeCode: [ + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, + { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, + ], } as const satisfies Record; export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; @@ -36,6 +49,7 @@ export type ModelSlug = BuiltInModelSlug | (string & {}); export const DEFAULT_MODEL_BY_PROVIDER = { codex: "gpt-5.4", + claudeCode: "claude-sonnet-4-6", } as const satisfies Record; export const MODEL_SLUG_ALIASES_BY_PROVIDER = { @@ -46,12 +60,20 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER = { "5.3-spark": "gpt-5.3-codex-spark", "gpt-5.3-spark": "gpt-5.3-codex-spark", }, + claudeCode: { + sonnet: "claude-sonnet-4-6", + opus: "claude-opus-4-6", + "sonnet-4.6": "claude-sonnet-4-6", + "opus-4.6": "claude-opus-4-6", + }, } as const satisfies Record>; export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = { codex: CODEX_REASONING_EFFORT_OPTIONS, -} as const satisfies Record; + claudeCode: CLAUDE_CODE_REASONING_EFFORT_OPTIONS, +} as const satisfies Record; export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { codex: "high", -} as const satisfies Record; + claudeCode: "high", +} as const satisfies Record; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 25a641edb..9d6fc5264 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -186,6 +186,34 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () => }), ); +it.effect("accepts provider-scoped provider options in thread.turn.start", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-provider-options", + threadId: "thread-1", + message: { + messageId: "msg-provider-options", + role: "user", + text: "hello", + attachments: [], + }, + provider: "claudeCode", + model: "claude-sonnet-4-6", + providerOptions: { + claudeCode: { + binaryPath: "/usr/local/bin/claude", + homePath: "/tmp/.claude", + }, + }, + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.provider, "claudeCode"); + assert.strictEqual(parsed.providerOptions?.claudeCode?.binaryPath, "/usr/local/bin/claude"); + assert.strictEqual(parsed.providerOptions?.claudeCode?.homePath, "/tmp/.claude"); + }), +); + it.effect( "decodes thread.turn-start-requested defaults for provider, runtime mode, and interaction mode", () => diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index e90ddd4b5..d9fb954f1 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,5 +1,6 @@ import { Option, Schema, SchemaIssue, Struct } from "effect"; import { ProviderModelOptions } from "./model"; +import { ProviderStartOptions } from "./providerOptions"; import { ApprovalRequestId, CheckpointRef, @@ -27,7 +28,8 @@ export const ORCHESTRATION_WS_CHANNELS = { domainEvent: "orchestration.domainEvent", } as const; -export const ProviderKind = Schema.Literal("codex"); +export const PROVIDER_KIND_VALUES = ["codex", "claudeCode"] as const; +export const ProviderKind = Schema.Literals(PROVIDER_KIND_VALUES); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", @@ -45,13 +47,6 @@ export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; export const ProviderServiceTier = Schema.Literals(["fast", "flex"]); export type ProviderServiceTier = typeof ProviderServiceTier.Type; export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; -const CodexProviderStartOptions = Schema.Struct({ - binaryPath: Schema.optional(TrimmedNonEmptyString), - homePath: Schema.optional(TrimmedNonEmptyString), -}); -const ProviderStartOptions = Schema.Struct({ - codex: Schema.optional(CodexProviderStartOptions), -}); export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); export type RuntimeMode = typeof RuntimeMode.Type; export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 997db09b7..8d3bf70a2 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -34,6 +34,28 @@ describe("ProviderSessionStartInput", () => { expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex"); }); + it("accepts claude-code-compatible payloads", () => { + const parsed = decodeProviderSessionStartInput({ + threadId: "thread-claude-1", + provider: "claudeCode", + cwd: "/tmp/workspace", + model: "claude-sonnet-4-6", + modelOptions: { + claudeCode: { + effort: "medium", + }, + }, + runtimeMode: "full-access", + providerOptions: { + claudeCode: { + binaryPath: "/usr/local/bin/claude", + }, + }, + }); + expect(parsed.modelOptions?.claudeCode?.effort).toBe("medium"); + expect(parsed.providerOptions?.claudeCode?.binaryPath).toBe("/usr/local/bin/claude"); + }); + it("rejects payloads without runtime mode", () => { expect(() => decodeProviderSessionStartInput({ @@ -61,4 +83,34 @@ describe("ProviderSendTurnInput", () => { expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("xhigh"); expect(parsed.modelOptions?.codex?.fastMode).toBe(true); }); + + it("accepts claude code provider-scoped model options", () => { + const parsed = decodeProviderSendTurnInput({ + threadId: "thread-1", + model: "claude-sonnet-4-6", + modelOptions: { + claudeCode: { + effort: "high", + }, + }, + }); + + expect(parsed.model).toBe("claude-sonnet-4-6"); + expect(parsed.modelOptions?.claudeCode?.effort).toBe("high"); + }); + + it("accepts claude code max effort", () => { + const parsed = decodeProviderSendTurnInput({ + threadId: "thread-1", + model: "claude-opus-4-6", + modelOptions: { + claudeCode: { + effort: "max", + }, + }, + }); + + expect(parsed.model).toBe("claude-opus-4-6"); + expect(parsed.modelOptions?.claudeCode?.effort).toBe("max"); + }); }); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 5e551c557..3c94a255b 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -1,6 +1,7 @@ import { Schema } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas"; import { ProviderModelOptions } from "./model"; +import { ProviderStartOptions } from "./providerOptions"; import { ApprovalRequestId, EventId, @@ -25,6 +26,54 @@ import { } from "./orchestration"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; +export const ProviderSessionModelSwitchMode = Schema.Literals([ + "in-session", + "restart-session", + "unsupported", +]); +export type ProviderSessionModelSwitchMode = typeof ProviderSessionModelSwitchMode.Type; + +export const ProviderResumeSupport = Schema.Literals(["none", "thread", "provider-session-id"]); +export type ProviderResumeSupport = typeof ProviderResumeSupport.Type; + +export const ProviderCapabilities = Schema.Struct({ + sessionModelSwitch: ProviderSessionModelSwitchMode, + supportsApprovals: Schema.Boolean, + supportsUserInput: Schema.Boolean, + supportsResume: ProviderResumeSupport, + supportsCollaborationMode: Schema.Boolean, + supportsImageInputs: Schema.Boolean, + supportsReasoningEffort: Schema.Boolean, + supportsServiceTier: Schema.Boolean, + supportsConversationRollback: Schema.Boolean, +}); +export type ProviderCapabilities = typeof ProviderCapabilities.Type; + +export const PROVIDER_CAPABILITIES_BY_PROVIDER = { + codex: { + sessionModelSwitch: "in-session", + supportsApprovals: true, + supportsUserInput: true, + supportsResume: "thread", + supportsCollaborationMode: true, + supportsImageInputs: true, + supportsReasoningEffort: true, + supportsServiceTier: true, + supportsConversationRollback: true, + }, + claudeCode: { + sessionModelSwitch: "restart-session", + supportsApprovals: true, + supportsUserInput: true, + supportsResume: "provider-session-id", + supportsCollaborationMode: true, + supportsImageInputs: false, + supportsReasoningEffort: true, + supportsServiceTier: false, + supportsConversationRollback: false, + }, +} as const satisfies Record; + const ProviderSessionStatus = Schema.Literals([ "connecting", "ready", @@ -47,17 +96,6 @@ export const ProviderSession = Schema.Struct({ lastError: Schema.optional(TrimmedNonEmptyStringSchema), }); export type ProviderSession = typeof ProviderSession.Type; - -const CodexProviderStartOptions = Schema.Struct({ - binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), - homePath: Schema.optional(TrimmedNonEmptyStringSchema), -}); - -export const ProviderStartOptions = Schema.Struct({ - codex: Schema.optional(CodexProviderStartOptions), -}); -export type ProviderStartOptions = typeof ProviderStartOptions.Type; - export const ProviderSessionStartInput = Schema.Struct({ threadId: ThreadId, provider: Schema.optional(ProviderKind), diff --git a/packages/contracts/src/providerOptions.ts b/packages/contracts/src/providerOptions.ts new file mode 100644 index 000000000..d62d5b8c0 --- /dev/null +++ b/packages/contracts/src/providerOptions.ts @@ -0,0 +1,24 @@ +import { Schema } from "effect"; + +import { TrimmedNonEmptyString } from "./baseSchemas"; + +const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; + +export const CodexProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), + homePath: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type CodexProviderStartOptions = typeof CodexProviderStartOptions.Type; + +export const ClaudeCodeProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyStringSchema), + homePath: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type ClaudeCodeProviderStartOptions = typeof ClaudeCodeProviderStartOptions.Type; + +export const ProviderStartOptions = Schema.Struct({ + codex: Schema.optional(CodexProviderStartOptions), + claudeCode: Schema.optional(ClaudeCodeProviderStartOptions), +}); +export type ProviderStartOptions = typeof ProviderStartOptions.Type; + diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 903bb5da7..19acce8b9 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -20,6 +20,12 @@ const RuntimeEventRawSource = Schema.Literals([ "codex.app-server.request", "codex.eventmsg", "codex.sdk.thread-event", + "claude-code.system", + "claude-code.assistant", + "claude-code.user", + "claude-code.result", + "claude-code.stream-event", + "claude-code.stderr", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 96ea90c1f..90aff2bd4 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -3,6 +3,7 @@ import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas"; import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; import { EditorId } from "./editor"; import { ProviderKind } from "./orchestration"; +import { ProviderCapabilities } from "./provider"; const KeybindingsMalformedConfigIssue = Schema.Struct({ kind: Schema.Literal("keybindings.malformed-config"), @@ -38,6 +39,7 @@ export const ServerProviderStatus = Schema.Struct({ status: ServerProviderStatusState, available: Schema.Boolean, authStatus: ServerProviderAuthStatus, + capabilities: ProviderCapabilities, checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), }); diff --git a/packages/shared/package.json b/packages/shared/package.json index b1a94c760..58fac761c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -23,6 +23,10 @@ "./Net": { "types": "./src/Net.ts", "import": "./src/Net.ts" + }, + "./host": { + "types": "./src/host.ts", + "import": "./src/host.ts" } }, "scripts": { diff --git a/packages/shared/src/host.test.ts b/packages/shared/src/host.test.ts new file mode 100644 index 000000000..fb63ab7ca --- /dev/null +++ b/packages/shared/src/host.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { + formatHostForUrl, + isIpAddressHost, + isLoopbackHost, + isWildcardHost, + normalizeHost, +} from "./host"; + +describe("host helpers", () => { + it("normalizes bracketed IPv6 host strings", () => { + expect(normalizeHost("[fd7a:115c:a1e0::1]")).toBe("fd7a:115c:a1e0::1"); + }); + + it("formats IPv6 hosts for URLs", () => { + expect(formatHostForUrl("fd7a:115c:a1e0::1")).toBe("[fd7a:115c:a1e0::1]"); + expect(formatHostForUrl("100.88.10.4")).toBe("100.88.10.4"); + }); + + it("detects wildcard hosts", () => { + expect(isWildcardHost("0.0.0.0")).toBe(true); + expect(isWildcardHost("[::]")).toBe(true); + expect(isWildcardHost("localhost")).toBe(false); + }); + + it("detects loopback hosts", () => { + expect(isLoopbackHost("localhost")).toBe(true); + expect(isLoopbackHost("127.0.0.1")).toBe(true); + expect(isLoopbackHost("[::1]")).toBe(true); + expect(isLoopbackHost("100.88.10.4")).toBe(false); + }); + + it("detects IP address hosts", () => { + expect(isIpAddressHost("100.88.10.4")).toBe(true); + expect(isIpAddressHost("[fd7a:115c:a1e0::1]")).toBe(true); + expect(isIpAddressHost("monitoring.tailnet.ts.net")).toBe(false); + }); +}); diff --git a/packages/shared/src/host.ts b/packages/shared/src/host.ts new file mode 100644 index 000000000..5a1071696 --- /dev/null +++ b/packages/shared/src/host.ts @@ -0,0 +1,37 @@ +import { isIP } from "node:net"; + +const BRACKETED_IPV6_HOST_REGEX = /^\[(.*)]$/; + +export const normalizeHost = (host: string): string => { + const trimmed = host.trim(); + const bracketedMatch = BRACKETED_IPV6_HOST_REGEX.exec(trimmed); + return bracketedMatch?.[1] ?? trimmed; +}; + +export const formatHostForUrl = (host: string): string => { + const normalized = normalizeHost(host); + return normalized.includes(":") ? `[${normalized}]` : normalized; +}; + +export const isWildcardHost = (host: string | undefined): boolean => { + if (!host) { + return false; + } + const normalized = normalizeHost(host); + return normalized === "0.0.0.0" || normalized === "::"; +}; + +export const isLoopbackHost = (host: string | undefined): boolean => { + if (!host) { + return false; + } + const normalized = normalizeHost(host).toLowerCase(); + return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1"; +}; + +export const isIpAddressHost = (host: string | undefined): boolean => { + if (!host) { + return false; + } + return isIP(normalizeHost(host)) !== 0; +}; diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 8771a24c1..4b12e1639 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -14,6 +14,7 @@ describe("normalizeModelSlug", () => { it("maps known aliases to canonical slugs", () => { expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex"); expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex"); + expect(normalizeModelSlug("sonnet", "claudeCode")).toBe("claude-sonnet-4-6"); }); it("returns null for empty or missing values", () => { @@ -49,21 +50,33 @@ describe("resolveModelSlug", () => { for (const model of MODEL_OPTIONS_BY_PROVIDER.codex) { expect(resolveModelSlug(model.slug)).toBe(model.slug); } + for (const model of MODEL_OPTIONS_BY_PROVIDER.claudeCode) { + expect(resolveModelSlug(model.slug, "claudeCode")).toBe(model.slug); + } }); it("keeps codex defaults for backward compatibility", () => { expect(getDefaultModel()).toBe(DEFAULT_MODEL_BY_PROVIDER.codex); expect(getModelOptions()).toEqual(MODEL_OPTIONS_BY_PROVIDER.codex); }); + + it("resolves claude defaults when the model is missing", () => { + expect(resolveModelSlug(undefined, "claudeCode")).toBe(DEFAULT_MODEL_BY_PROVIDER.claudeCode); + }); }); describe("getReasoningEffortOptions", () => { it("returns codex reasoning options for codex", () => { expect(getReasoningEffortOptions("codex")).toEqual(["xhigh", "high", "medium", "low"]); }); + + it("returns claude reasoning options for claude code", () => { + expect(getReasoningEffortOptions("claudeCode")).toEqual(["max", "high", "medium", "low"]); + }); }); describe("getDefaultReasoningEffort", () => { it("returns provider-scoped defaults", () => { expect(getDefaultReasoningEffort("codex")).toBe("high"); + expect(getDefaultReasoningEffort("claudeCode")).toBe("high"); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 592e2dfb9..4d5b03ff4 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,17 +1,19 @@ import { - CODEX_REASONING_EFFORT_OPTIONS, DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_REASONING_EFFORT_BY_PROVIDER, MODEL_OPTIONS_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, - type CodexReasoningEffort, + REASONING_EFFORT_OPTIONS_BY_PROVIDER, type ModelSlug, type ProviderKind, + type ProviderReasoningEffort, } from "@t3tools/contracts"; type CatalogProvider = keyof typeof MODEL_OPTIONS_BY_PROVIDER; const MODEL_SLUG_SET_BY_PROVIDER: Record> = { codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), + claudeCode: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeCode.map((option) => option.slug)), }; export function getModelOptions(provider: ProviderKind = "codex") { @@ -61,18 +63,33 @@ export function resolveModelSlugForProvider( return resolveModelSlug(model, provider); } +export function inferProviderFromModel( + model: string | null | undefined, +): ProviderKind | null { + if (typeof model !== "string") { + return null; + } + + for (const provider of Object.keys(MODEL_OPTIONS_BY_PROVIDER) as ProviderKind[]) { + const normalized = normalizeModelSlug(model, provider); + if (normalized && MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized)) { + return provider; + } + } + + return null; +} + export function getReasoningEffortOptions( provider: ProviderKind = "codex", -): ReadonlyArray { - return provider === "codex" ? CODEX_REASONING_EFFORT_OPTIONS : []; +): ReadonlyArray { + return REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider]; } -export function getDefaultReasoningEffort(provider: "codex"): CodexReasoningEffort; -export function getDefaultReasoningEffort(provider: ProviderKind): CodexReasoningEffort | null; export function getDefaultReasoningEffort( provider: ProviderKind = "codex", -): CodexReasoningEffort | null { - return provider === "codex" ? "high" : null; +): ProviderReasoningEffort | null { + return DEFAULT_REASONING_EFFORT_BY_PROVIDER[provider]; } -export { CODEX_REASONING_EFFORT_OPTIONS }; +export { REASONING_EFFORT_OPTIONS_BY_PROVIDER }; diff --git a/scripts/dev-branch.sh b/scripts/dev-branch.sh new file mode 100755 index 000000000..cd9223082 --- /dev/null +++ b/scripts/dev-branch.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKTREE_NAME="$(basename "$ROOT_DIR")" +LOCAL_NODE_BIN_DIR="$ROOT_DIR/.local/node/bin" +SHARED_NODE_BIN_DIR="${T3CODE_SHARED_NODE_BIN_DIR:-$HOME/t3code/.local/node/bin}" + +if [ -x "$LOCAL_NODE_BIN_DIR/node" ]; then + NODE_BIN_DIR="$LOCAL_NODE_BIN_DIR" +elif [ -x "$SHARED_NODE_BIN_DIR/node" ]; then + NODE_BIN_DIR="$SHARED_NODE_BIN_DIR" +else + echo "No local Node runtime found for this worktree or at $SHARED_NODE_BIN_DIR/node" >&2 + echo "Run ./scripts/install-local-node.sh or set T3CODE_SHARED_NODE_BIN_DIR" >&2 + exit 1 +fi + +export PATH="$NODE_BIN_DIR:$PATH" +export T3CODE_STATE_DIR="${T3CODE_STATE_DIR:-$HOME/.t3/dev-worktrees/$WORKTREE_NAME}" + +if [ -z "${T3CODE_PORT_OFFSET:-}" ] && [ -z "${T3CODE_DEV_INSTANCE:-}" ]; then + export T3CODE_DEV_INSTANCE="$WORKTREE_NAME" +fi + +MODE="${1:-dev}" +case "$MODE" in + dev|dev:server|dev:web|dev:desktop) + shift || true + ;; + *) + echo "usage: ./scripts/dev-branch.sh [dev|dev:server|dev:web|dev:desktop] [-- extra args]" >&2 + exit 1 + ;; +esac + +CMD=(bun run "$MODE") +if [ "$#" -gt 0 ]; then + CMD+=(-- "$@") +fi + +exec "${CMD[@]}" diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 704a28541..478cfeb95 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -98,6 +98,28 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); + it.effect("uses explicit remote hosts for default dev URLs in web modes", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev", + baseEnv: {}, + serverOffset: 20, + webOffset: 20, + stateDir: undefined, + authToken: undefined, + noBrowser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: "100.88.10.4", + port: undefined, + devUrl: undefined, + }); + + assert.equal(env.VITE_WS_URL, "ws://100.88.10.4:3793"); + assert.equal(env.VITE_DEV_SERVER_URL, "http://100.88.10.4:5753"); + }), + ); + it.effect("does not force websocket logging on in dev mode when unset", () => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 89d7a104c..8bc25be1e 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -5,6 +5,7 @@ import { homedir } from "node:os"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { NetService } from "@t3tools/shared/Net"; +import { formatHostForUrl, isLoopbackHost, isWildcardHost } from "@t3tools/shared/host"; import { Config, Data, Effect, Hash, Layer, Logger, Option, Path, Schema } from "effect"; import { Argument, Command, Flag } from "effect/unstable/cli"; import { ChildProcess } from "effect/unstable/process"; @@ -115,6 +116,27 @@ function resolveStateDir(stateDir: string | undefined): Effect.Effect