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..461fa4016 --- /dev/null +++ b/.plans/19-claude-code-support-design-review.md @@ -0,0 +1,120 @@ +# Claude Code Support — Execution Plan + +## Goal + +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. + +## Architecture Fit + +This track is intentionally aligned to the current codebase, not legacy `apps/renderer` assumptions. + +### Server runtime path + +- `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` + +### Shared contracts / model path + +- `packages/contracts/src/orchestration.ts` +- `packages/contracts/src/provider.ts` +- `packages/contracts/src/providerRuntime.ts` +- `packages/contracts/src/model.ts` +- `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/.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/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 (
-
);
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-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/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&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 "$@"
diff --git a/turbo.json b/turbo.json
index 671336afa..ba50b7346 100644
--- a/turbo.json
+++ b/turbo.json
@@ -5,6 +5,7 @@
"VITE_WS_URL",
"VITE_DEV_SERVER_URL",
"ELECTRON_RENDERER_PORT",
+ "T3CODE_HOST",
"T3CODE_LOG_WS_EVENTS",
"T3CODE_MODE",
"T3CODE_PORT",