feat: MCP {env:VAR} key substitution + coder model/tool-result fixes + docs refactor (v2.7.9)

- MCP secrets: substituteEnvVars recursively resolves {env:NAME} in mcp.json string values from process.env before Zod (opencode-compatible); unset -> '' + boot warning, and invalid-config log names the unset vars (an empty {env:VAR} in a strict url/command field invalidates the whole config)
- data/mcp.json now untracked (.gitignore flips !data/mcp.json -> !data/mcp.example.json); tracked template data/mcp.example.json carries "{env:CONTEXT7_API_KEY}"; .env.example documents the key (9 mcp-config tests)
- Coder fix: message_complete frame model widened string -> string|null (server+web ws-frames parity); dispatcher publishes model: task.model at all 4 external completion points — a null model otherwise fail-closed in publishFrame and dropped the whole frame incl. status:'complete' (regression test)
- Coder fix: claude-sdk mapUserToolResults maps user-message tool_result blocks -> terminal tool_update events (completed/failed w/ output) so tool snapshots resolve instead of spinning forever
- Composer: AgentComposerBar drops §9b resumed/history/new chip + token readout, loses flex-wrap so the row stays one line; CoderPane gains a per-chat localStorage agent-config cache (restores last model on reopen) + threads model into the timeline/chip
- Docs: root CLAUDE.md slimmed (~190 lines), per-app refs split to apps/{coder,server,web}/CLAUDE.md; new docs/coder-backends.md, docs/project-discovery.md, docs/coding-standards/ (cross-app-contract-parity); ARCHITECTURE.md links the backends doc

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 17:01:03 +00:00
parent 7ca4a6b344
commit afaca9e426
23 changed files with 1284 additions and 256 deletions

372
docs/coder-backends.md Normal file
View File

@@ -0,0 +1,372 @@
# BooCoder Dispatch Backends
<!-- How BooCoder turns a coding request into work performed by one of several pluggable agent backends, streams the turn back to the browser, and resumes each agent's context across turns. -->
- **Last Updated:** 2026-06-02 00:00
- **Authors:**
- indifferentketchup (samkintop@gmail.com)
## Summary
BooCoder is the write-capable surface of BooCode: it takes a message you type into a coder tab and gets an AI coding agent to actually do the work in an isolated copy of your project. Unlike the read-only chat surface, BooCoder can edit, create, and delete files — every change is staged for your review before it touches disk. The interesting part is that BooCoder does not have one agent; it has several, each spoken to over a different protocol (a local model, OpenCode, Goose, Claude Code, Qwen Code), and it hides those differences behind a single internal contract so the rest of the system streams them all the same way.
When you send a message, BooCoder decides which backend should handle it, keeps that agent "warm" so a follow-up message reuses the same conversation, streams the agent's text, reasoning, and tool calls back to your browser live, and records what files changed. The picker you see (provider, model, mode, slash commands) is built from a discovery pipeline that probes which agents are installed on the host.
- **Five providers, four transports.** `boocode` (native llama-swap inference), `opencode` (warm HTTP server), `goose`/`qwen` (ACP), `claude` (PTY or — behind a flag — the Claude Agent SDK).
- **One internal contract.** Every external backend implements the same `AgentBackend` interface and emits the same transport-agnostic `AgentEvent`s; the dispatcher maps those to WebSocket frames identically regardless of which agent produced them.
- **Warm vs. one-shot.** Tasks that come from a real chat tab get a long-lived, resumable agent session; session-less tasks (arena, MCP, raw API) get a fresh one-shot process per turn.
- **The tab is the unit of context.** Agent sessions are keyed `(chat_id, agent)` — two tabs in one workspace are two independent conversations that happen to share one worktree.
- **Changes are staged, never auto-applied.** Write tools queue rows in `pending_changes`; nothing hits disk until you apply.
- **BooCoder runs on the host**, not in Docker — a `boocoder.service` systemd unit on port 9502, so it can spawn agent binaries with full filesystem access.
Key files:
- `apps/coder/src/services/dispatcher.ts` — the task loop; routes each task to a backend and maps its events to WS frames
- `apps/coder/src/services/agent-backend.ts` — the `AgentBackend` interface and `AgentEvent` contract every backend implements
- `apps/coder/src/services/backends/` — the four backend implementations plus routing predicates
- `apps/coder/src/services/provider-snapshot.ts` — the provider discovery / probe pipeline that builds the picker
- `apps/coder/src/schema.sql``agent_sessions`, `worktrees`, `tasks`, `available_agents`, `pending_changes`
## Architecture
```mermaid
flowchart TD
Msg["User message<br/>(CoderPane)"] --> Route{"provider?"}
Route -->|boocode| Inf["runNativeInference<br/>in-process llama-swap"]
Route -->|external| Task[("tasks row")]
Task --> Disp["dispatcher.ts<br/>LISTEN/NOTIFY + 2s poll"]
Disp --> Pred{"routing<br/>predicates"}
Pred -->|opencode| OC["OpenCodeServerBackend<br/>(warm HTTP server)"]
Pred -->|"goose / qwen + tab"| WA["WarmAcpBackend<br/>(warm ACP process)"]
Pred -->|"claude + tab + flag"| CS["ClaudeSdkBackend<br/>(warm SDK query)"]
Pred -->|"session-less / flag off"| OS["runExternalAgent<br/>(one-shot ACP / PTY)"]
OC -->|AgentEvent| Map["onEvent → WS frames"]
WA -->|AgentEvent| Map
CS -->|AgentEvent| Map
OS -->|AgentEvent| Map
Map -->|"delta / reasoning_delta / tool_call"| Broker["broker.publishFrame"]
Map -->|"message_complete + model"| Broker
Broker -->|WebSocket| Web["apps/web CoderPane"]
OC -.session.-> AS[("agent_sessions<br/>(chat_id, agent)")]
WA -.session.-> AS
CS -.session.-> AS
Map -->|"diff worktree"| PC[("pending_changes")]
Inf --> PC
```
## How It Works
Everything starts from a row in the `tasks` table. When you send a message to a coder tab with an external provider selected, the message route writes a `tasks` row carrying the provider, model, chat id, and session id. A long-running **dispatcher** notices the row (instantly via a Postgres `LISTEN/NOTIFY` signal, with a 2-second poll as a safety net) and runs it. Only one turn runs at a time per session, so two messages in the same workspace queue rather than collide. If the provider is the native `boocode`, there is no task and no external agent — the dispatcher runs llama-swap inference in-process instead, the same way the chat surface does.
For an external provider, the dispatcher picks **which backend** should handle the task using small pure predicates. The deciding factors are: which agent it is, whether the task came from a real chat tab (has both a session id and a chat id), and — for Claude — whether a feature flag is on. OpenCode always uses its warm HTTP server. Goose and Qwen use a warm, long-lived ACP process when they come from a tab, and a fresh one-shot process otherwise. Claude uses the warm Claude Agent SDK backend only when the `CLAUDE_SDK_BACKEND` flag is set; by default it falls through to a one-shot PTY process. Anything session-less — arena contestants, MCP-created tasks, raw `POST /api/tasks` — always takes the one-shot path.
A **warm backend** keeps the agent's process and conversation alive between turns. The first turn in a tab spawns the process and creates the agent's session; later turns reuse it, so the agent remembers the conversation without re-sending it. Each backend persists a small row in `agent_sessions` keyed on the tab and agent, including a resume token and a `config_hash` of the model. If you switch models in the same tab, the hash changes and the backend transparently starts a fresh agent session while keeping the same worktree. If the process crashes, the row is marked `crashed` and the next turn re-spawns it.
No matter which backend runs, the turn streams the same way. Each backend emits a small set of **transport-agnostic events** — text, reasoning, tool-call-started, tool-call-updated, commands — and the dispatcher maps every one of them to a WebSocket frame, identically across all backends. That uniformity is the whole point of the design: OpenCode's server-sent events, an ACP process's notifications, and the Claude SDK's message stream all arrive in your browser as the same `delta`, `reasoning_delta`, and `tool_call` frames. When the turn finishes, the dispatcher publishes a `message_complete` frame (now carrying the model id, so the UI can show a model attribution chip), diffs the worktree, and queues the file changes into `pending_changes` for you to review.
## Primary Flows
### A warm external-agent turn
**Trigger:** You type a message in a coder tab with OpenCode, Goose, Qwen, or (flag-on) Claude selected.
1. **The message becomes a task.** The coder message route creates your user message row and a `tasks` row stamped with the agent, model, `chat_id`, and `session_id`, then returns `202 { task_id, dispatched: true }`. Nothing is computed yet.
2. **The dispatcher picks it up.** A Postgres `NOTIFY` wakes the dispatcher immediately (a 2-second poll is the backstop). It enforces one-turn-per-session concurrency, then evaluates the routing predicates and lands on a warm backend.
3. **The session is ensured or resumed.** The backend looks up `agent_sessions` for this `(chat_id, agent)`. If a healthy session exists and the model's `config_hash` matches, it resumes — the agent still has the conversation. Otherwise it spawns the process (or, for OpenCode, reuses the one shared server) and creates a fresh agent session against the tab's worktree.
4. **The turn streams.** The backend sends your message and emits `AgentEvent`s as the agent works. The dispatcher's `onEvent` maps each to a WebSocket frame — `text``delta`, `reasoning``reasoning_delta`, `tool_call`/`tool_update``tool_call` — and publishes them through the broker. Your browser renders the agent thinking, talking, and calling tools live. Tool snapshots accumulate so the final transcript persists with each tool's input, output, and status.
5. **Outcome.** On completion the dispatcher publishes `message_complete` (with the model id for the attribution chip), records token/context usage on the message, diffs the worktree against its base commit, and supersedes any prior pending changes with one `pending_changes` set for the turn. You review the diff and apply it when ready.
**When it fails:** If the agent stalls and emits no events for 180 seconds, an inactivity watchdog reconciles the session — it asks the server whether the turn actually finished and, if not, marks the `agent_sessions` row `crashed`. A crashed or exited backend is re-spawned on your next message. An aborted turn (you hit stop) cancels the prompt on the warm connection without killing the process, and a guard swallows any late "turn done" signal so it cannot accidentally settle your *next* turn.
### A one-shot dispatch
**Trigger:** A task with no chat tab behind it (arena contestant, MCP-created task, raw `POST /api/tasks`), or a Claude task while `CLAUDE_SDK_BACKEND` is off.
1. **No warm session.** The routing predicates return false (missing `session_id`/`chat_id`, or the Claude flag is off), so the dispatcher calls `runExternalAgent`.
2. **A fresh process per turn.** It creates a per-task worktree, then spawns the agent once — over ACP for OpenCode/Goose, or over a PTY with `--output-format stream-json` for Claude/Qwen — runs the single turn, and tears the process down.
3. **Outcome.** Events stream and persist exactly as in the warm flow (same `onEvent` mapping, same `message_complete` with model), and the worktree diff queues one `pending_changes` row. Nothing is kept warm; the next such task starts over.
### Native boocode inference
**Trigger:** You send a message with the native `boocode` provider selected.
1. **No task, no agent.** The message route sees a non-external provider, creates a streaming assistant message row, and enqueues in-process inference — there is no `tasks` row and no external process.
2. **The shared inference loop runs.** BooCoder reuses the chat surface's inference runner against llama-swap; deltas and tool calls publish through the broker just like the chat surface, and write tools queue into `pending_changes`.
3. **Outcome.** The assistant message is finalized in place with its content and token counts. (This path is the only one that tracks context fill natively; external one-shot agents report no ctx usage.)
**When it fails:** If inference is already running for the session, the route returns `409` rather than starting a second concurrent turn.
## Key Files
### Backend
| File | Purpose |
|------|---------|
| `apps/coder/src/services/dispatcher.ts` | Task loop (LISTEN/NOTIFY + poll), per-session concurrency, routes to each backend, maps `AgentEvent`→WS frames, publishes the four `message_complete` sites, diffs worktree → `pending_changes` |
| `apps/coder/src/services/agent-backend.ts` | The `AgentBackend` interface, `AgentEvent` union, `EnsureSessionOpts`/`AgentSessionHandle`/`PromptCtx`/`TurnResult` |
| `apps/coder/src/services/backends/opencode-server.ts` | Warm OpenCode HTTP server backend: one `opencode serve` per process, per-session SSE loop, `config_hash` resume, inactivity watchdog, orphan-terminal guard |
| `apps/coder/src/services/backends/warm-acp.ts` | Warm ACP backend (goose/qwen): one persistent ACP process per `(chat, agent)`, reused across turns |
| `apps/coder/src/services/backends/claude-sdk.ts` | Warm Claude Agent SDK backend: one streaming-input `query()` per `(chat, agent)`; transcript via `PostgresSessionStore` |
| `apps/coder/src/services/backends/claude-sdk-map.ts` | Maps `SDKMessage` (stream_event / assistant / user) → `AgentEvent`s; the `user` tool_result → terminal `tool_update` mapping |
| `apps/coder/src/services/backends/warm-acp-routing.ts` | `shouldUseWarmBackend` predicate + ACP `stopReason`→ok mapping |
| `apps/coder/src/services/backends/claude-sdk-routing.ts` | `shouldUseClaudeSdk` + `claudeSdkBackendEnabled` (the `CLAUDE_SDK_BACKEND` flag) |
| `apps/coder/src/services/backends/lifecycle-decisions.ts` | Pure idle/LRU/restart eviction decisions for the agent pool |
| `apps/coder/src/services/backends/turn-guard.ts` | Post-abort orphan-terminal suppression |
| `apps/coder/src/services/acp-dispatch.ts` / `pty-dispatch.ts` | One-shot ACP / PTY dispatch used by `runExternalAgent` |
| `apps/coder/src/services/acp-event-map.ts` | Shared ACP `session/update``AgentEvent` normalization (warm + one-shot) |
| `apps/coder/src/services/acp-tool-snapshot.ts` | `AcpToolSnapshot` shape, `mergeToolSnapshot`, `snapshotToWireToolCall`, lifecycle mapping |
| `apps/coder/src/services/agent-pool.ts` | Holds live backends keyed `(primaryKey, agent)`; lazy spawn, idle/LRU eviction, never evicts a busy backend |
| `apps/coder/src/services/provider-registry.ts` | Static `PROVIDERS` registry (label/transport/model source) |
| `apps/coder/src/services/provider-snapshot.ts` | Two-tier probe → the snapshot the picker renders; `persistProbedModels` |
| `apps/coder/src/services/agent-probe.ts` | Startup discovery of installed agents/versions/ACP/models → `available_agents` |
| `apps/coder/src/services/write_guard.ts` / `pending_changes.ts` | Write-path validation (escape + secret-file block) and the stage/apply/rewind queue |
| `apps/coder/src/routes/providers.ts` / `messages.ts` | Provider snapshot/config/refresh endpoints; coder message read/post |
| `apps/coder/src/schema.sql` | `agent_sessions`, `worktrees`, `tasks`, `available_agents`, `pending_changes`, `claude_session_entries` |
### Frontend
| File | Purpose |
|------|---------|
| `apps/web/src/components/AgentComposerBar.tsx` | Renders the provider/model/mode/command picker from the provider snapshot |
| `apps/web/src/components/panes/CoderPane.tsx` | Coder tab: live `message_complete` reducer, slash-command groups, message timeline mapping |
| `apps/web/src/components/panes/CoderMessageList.tsx` | Message rendering (`CoderMessageWire`), model-attribution chip |
| `apps/web/src/api/types.ts` | Web wire copy of `ProviderSnapshotEntry` / `AgentCommand` (parity with `provider-types.ts`) |
### Infrastructure
| File | Purpose |
|------|---------|
| `/etc/systemd/system/boocoder.service` | Host service (port 9502); only `NoNewPrivileges=true` is safe — `ProtectSystem`/`ProtectHome`/`PrivateTmp` break agent dispatch |
| `apps/coder/.env.host` | Production env (DATABASE_URL, LLAMA_SWAP_URL, CODER_PROVIDERS_PATH, CLAUDE_SDK_BACKEND, …) |
| `data/coder-providers.json` | Live runtime provider overrides (gitignored); template is `data/coder-providers.example.json` |
**Build & deploy.** `apps/coder` imports the server's compiled `dist/` (`createInferenceRunner`, `createBroker`, `ALL_TOOLS`), so **`apps/server` must build first**: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. The server's `package.json` `exports` map needs both `types` and `default` conditions per subpath (and `declaration: true` in its tsconfig) or NodeNext can't find the `.d.ts` and tsc fails "Cannot find module" here. Agent dispatch spawns binaries **directly**`spawn(fullBinaryPath, argsArray, { cwd })` using `install_path` — never `spawn('sh', ['-c', ...])`, which fails under systemd.
## Configuration
| Variable | Description | Default |
|----------|-------------|---------|
| `DATABASE_URL` | Postgres connection (shared `boochat` DB) | _required_ |
| `LLAMA_SWAP_URL` | llama-swap base; `/v1/models` for native + opencode model discovery | _required_ |
| `CLAUDE_SDK_BACKEND` | Truthy opts a deployment into the warm Claude Agent SDK backend; otherwise Claude uses one-shot PTY | _off_ |
| `CODER_PROVIDERS_PATH` | Provider override config file | `/data/coder-providers.json` |
| `PROVIDER_PROBE_TTL_MS` | Tier-2 cold ACP probe staleness threshold | `86400000` (24h) |
| `DEFAULT_MODEL` | Fallback model when a task carries none | _deployment-specific_ |
| `FAST_MODEL` | Cheaper model for titles/summaries; falls back to session/DEFAULT_MODEL | _unset_ |
| `AGENT_POOL_IDLE_TTL_MS` | Idle timeout before a warm backend is evicted | `1800000` (30m) |
| `AGENT_POOL_MAX_LIVE` | LRU cap on simultaneously-live warm backends | `10` |
| `LIFECYCLE_SWEEP_INTERVAL_MS` | Cadence of the idle/LRU/health sweep | `60000` |
| `ORPHAN_WORKTREE_GRACE_MS` | Grace before an untouched worktree dir is reaped | `3600000` (1h) |
| `PORT` / `HOST` | Service bind (production binds the Tailscale IP) | `9502` / `0.0.0.0` |
> BooCoder does **not** load MCP (that is BooChat only). Default values above are the documented fallbacks; production overrides live in `apps/coder/.env.host`. A config-only edit to `data/coder-providers.json` needs only the appropriate restart, not a rebuild.
## Error Handling
This table is the lookup for failure behavior; step-by-step recovery recipes for the common ones live under [Troubleshooting](#troubleshooting) in Technical Reference.
### Backend
| Scenario | Result | Behavior |
|----------|--------|----------|
| Warm turn stalls (no events ≥180s) | inactivity watchdog | Reconciles the session; if the turn isn't actually finished, marks `agent_sessions.status='crashed'` |
| Backend process exits / crashes | next turn | Row marked `crashed`; `ensureSession` re-spawns and re-initializes on the next message |
| User aborts a turn | cancel, not kill | Prompt cancelled on the warm connection (process kept); turn-guard swallows a late terminal so it can't settle the next turn |
| Model changed in a tab | `config_hash` mismatch | Fresh agent session created, worktree preserved |
| OpenCode SSE missing `directory` | zero session events | Events scope to the server `cwd` → empty turn → 180s timeout (the subscribe MUST pass the worktree dir) |
| Native inference already running | `409 Conflict` | Second concurrent turn refused |
| Write target escapes project / is a secret file | `WriteGuardError` | Change is not queued (`.env`, `*.pem`, `id_rsa`, `credentials.json`, … blocked) |
| Invalid provider config PATCH | `422` | In-memory registry untouched; on disk-write failure, `500` and state left unchanged to avoid divergence |
### Frontend
| Scenario | Handling | Behavior |
|----------|----------|----------|
| Unknown WS frame type | wire-format gate | Frames whose `type` isn't in the web `WsFrame` union drop silently at JSON-parse — add new frame types to both sides |
| New per-message field not whitelisted | `mapCoderTimelineRow` | The field silently vanishes in the coder unless every mapper is updated (this is how the model chip once disappeared) |
---
## Technical Reference
*Below this point is code-level lookup detail — schema, types, constants, endpoints, and extension recipes. Stop here if you only need to understand the backends' behavior.*
### Data Model
`apps/coder/src/schema.sql` owns the coder-side tables and extends `tasks`. (The chat-side `sessions`/`chats`/`messages` live in `apps/server/src/schema.sql`.) The defining choice: a backend session is keyed on the **tab** (`chat_id`), not the session — two tabs in one workspace are two independent contexts sharing one worktree.
```sql
-- One resumable backend session per (tab, agent). Re-keyed to (chat_id, agent)
-- in P1.5-b; session_id/worktree_id are informational SET NULL links.
CREATE TABLE agent_sessions (
session_id UUID REFERENCES sessions(id) ON DELETE SET NULL,
agent TEXT NOT NULL,
backend TEXT NOT NULL, -- CHECK IN ('opencode_server','acp_warm','claude_sdk')
agent_session_id TEXT, -- provider's resume token; null until assigned
server_port INTEGER, -- opencode HTTP port; null for ACP/SDK
status TEXT NOT NULL DEFAULT 'idle', -- 'idle'|'active'|'crashed'|'closed'
config_hash TEXT, -- sha256('opencode_server|<model>').slice(0,16); stale-detect
worktree_id UUID REFERENCES worktrees(id) ON DELETE SET NULL,
input_tokens BIGINT NOT NULL DEFAULT 0, -- accumulated per (chat_id, agent)
output_tokens BIGINT NOT NULL DEFAULT 0,
cost DOUBLE PRECISION NOT NULL DEFAULT 0,
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
PRIMARY KEY (chat_id, agent) -- closing a tab CASCADEs its context away
);
-- First-class worktree entity: one per session, survives session delete.
CREATE TABLE worktrees (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID REFERENCES sessions(id) ON DELETE SET NULL,
project_id UUID, path TEXT NOT NULL, branch TEXT, base_commit TEXT, slug TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived'))
);
-- Dispatcher work units (external agents only; native boocode uses messages+inference).
-- tasks is created by apps/server schema; the coder adds these columns.
-- Key coder columns: agent, model, execution_path ('native'|'acp'|'pty'|'qwen'),
-- session_id (REFERENCES sessions), chat_id (REFERENCES chats ON DELETE SET NULL),
-- state ('pending'|'running'|'completed'|'failed'|'blocked'|'cancelled').
-- Probed agent registry (UPSERT by name at startup).
-- available_agents(name PK, install_path, version, supports_acp, models JSONB,
-- label, transport, commands JSONB, last_probed_at)
-- Claude Agent SDK transcript store (resume materialization).
-- claude_session_entries(id BIGSERIAL, project_key, session_id, subpath, ... )
-- Staged file changes — nothing hits disk until apply_pending.
-- pending_changes(id, session_id, task_id, file_path, operation('create'|'edit'|'delete'),
-- diff, status('pending'|'applied'|'rejected'|'reverted'), agent)
```
Idempotent FK-action flips and PK swaps in this file guard on `pg_constraint` so re-runs are no-ops — see the P1.5-b re-key block.
### Core Types
The contract every external backend implements. Backends emit `AgentEvent`s without any WS envelope; the dispatcher owns the mapping to frames. `tool_call` and `tool_update` are kept distinct because OpenCode's SSE distinguishes tool-start from tool-result.
See `apps/coder/src/services/agent-backend.ts` for the full definitions. Key shapes:
```typescript
type AgentEvent =
| { type: 'text'; text: string }
| { type: 'reasoning'; text: string }
| { type: 'tool_call'; toolCall: AcpToolSnapshot } // tool started
| { type: 'tool_update'; toolCall: AcpToolSnapshot } // tool result / status change
| { type: 'commands'; commands: AgentCommand[] }; // ACP available_commands_update
interface AgentBackend {
ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle>;
prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise<TurnResult>;
closeSession(handle: AgentSessionHandle): Promise<void>;
dispose(): Promise<void>;
health(): 'up' | 'down';
isBusy?(): boolean; // pool never evicts a busy backend
tickHealth?(now?: number): Promise<void>; // proactive restart (opencode only)
}
```
`AcpToolSnapshot` (`apps/coder/src/services/acp-tool-snapshot.ts`) is the accumulating shape for a tool call — `{ toolCallId, title, kind?, status?, rawInput?, rawOutput? }` — merged incrementally and rendered via `snapshotToWireToolCall`.
The provider picker is driven by `ProviderSnapshotEntry` / `AgentCommand` in `apps/coder/src/services/provider-types.ts`, which must stay byte-identical to the web copy in `apps/web/src/api/types.ts` (see Testing).
### Constants
| Constant | Value | Description |
|----------|-------|-------------|
| poll interval | `2000` ms | Dispatcher fallback poll between `NOTIFY` signals |
| turn inactivity watchdog | `180000` ms | OpenCode turn with no events → reconcile |
| snapshot cache TTL | `5 min` | Per-cwd provider snapshot memory cache |
| `PROVIDER_PROBE_TTL_MS` | `86400000` ms (24h) | Tier-2 cold ACP probe staleness |
| `config_hash` length | 16 hex chars | `sha256('opencode_server|<model>')` prefix; excludes port |
| agent pool idle TTL | `1800000` ms (30m) | Default warm-backend eviction |
| agent pool max live | `10` | Default LRU cap |
### Implementation Notes
#### Routing predicates
`shouldUseWarmBackend(task)` (`backends/warm-acp-routing.ts`) returns true only for `goose`/`qwen` tasks that carry **both** a `session_id` and a `chat_id` — i.e. they came from a real chat tab. `shouldUseClaudeSdk(task, env)` (`backends/claude-sdk-routing.ts`) is the same shape for `claude`, additionally gated behind `claudeSdkBackendEnabled` (the `CLAUDE_SDK_BACKEND` flag, default off). OpenCode tasks always route to the warm server. Everything that fails these predicates — arena, MCP, raw `POST /api/tasks`, flag-off Claude — falls through to `runExternalAgent`'s one-shot path. Both predicates are pure so they unit-test without a live process.
#### The `user` tool_result mapping (Claude SDK)
The Claude Agent SDK feeds tool **results** back in as `type:'user'` messages containing `tool_result` blocks. `mapSdkMessage` must map the `user` case to a terminal `tool_update` (completed, or failed on `is_error`) carrying the tool's output. Without it, the tool call persists `status:'running'` forever and the UI spinner never stops. See `mapUserToolResults` / `toolResultText` in `apps/coder/src/services/backends/claude-sdk-map.ts`. The same mapper dedups text/thinking already streamed via partials and assembles buffered `input_json_delta` fragments on `content_block_stop`.
#### OpenCode SSE and `config_hash` resume
OpenCode runs as one warm `opencode serve` HTTP server per BooCoder process, multiplexed across sessions. Live streaming reads `session.next.text.delta` / `.reasoning.delta` / `.tool.{called,success,failed}` (not the post-hoc `message.part.*`), and the subscribe **must** pass the session's worktree `directory` or events scope to the server cwd and the turn times out. Resume hinges on `config_hash = sha256('opencode_server|<model>').slice(0,16)` — deliberately excluding the random per-boot server port so resume survives restarts; a model change flips the hash and forces a fresh opencode session while keeping the worktree. A `streamedPartKeys` set drops post-hoc duplicate deltas; a `sessionID` demux guard drops cross-session events when two sessions share a server.
#### Warm vs. one-shot lifetime
The agent pool (`agent-pool.ts`) holds OpenCode once per process (one server, many sessions) and warm-ACP / Claude-SDK once per `(chat, agent)`. Idle-TTL and LRU eviction are computed by the pure functions in `lifecycle-decisions.ts` and never evict a backend whose `isBusy()` is true. One-shot dispatch holds nothing warm: `runExternalAgent` spawns, runs one turn, and tears down.
#### Worktree diff → pending changes
All paths run in a git worktree (per-session for warm backends, per-task for one-shot). At turn end the dispatcher diffs against the worktree's base commit and queues the result as a single `pending_changes` set, superseding the previous one (latest-wins). Write tools validate every path through `write_guard.ts` (`resolveWritePath``resolve` + prefix check, no `realpath` since created files may not exist yet — plus `isSecretPath`).
### API Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/api/providers` | Installed providers with models; transport reflects actual capability (`supports_acp`) |
| `GET` | `/api/providers/snapshot?cwd=` | Full picker shape (providers + models + modes + commands); 5-min cache |
| `GET` | `/api/providers/config` | Raw `data/coder-providers.json` (`{ providers: {} }` if absent) |
| `PATCH` | `/api/providers/config` | Per-id **wholesale** override replace → save → reload → clear cache |
| `POST` | `/api/providers/refresh` | Force cold ACP re-probe of installed providers |
| `GET` | `/api/providers/:id/diagnostic` | Read-only diagnostics (no probe) |
| `GET` | `/api/sessions/:sessionId/messages?chat_id=` | Coder message list via `mapCoderMessageRow` |
| `POST` | `/api/sessions/:sessionId/messages` | Send: external → `202 { task_id, dispatched }`; native → `202 { assistant_message_id }` |
| `GET` | `/api/health` | `{ ok, db, tools }` (down ~1520s after restart while the agent probe runs) |
`PATCH /api/providers/config` replaces a provider id's override object wholesale (per-id shallow merge) — to flip one field, send `{...existing, field}` or you wipe the rest. A custom ACP entry requires `extends: 'acp'` + `label` + `command` or it drops out of the resolved registry.
### Provider discovery pipeline
The picker is built by a four-stage pipeline: `provider-config.ts` (never-throws Zod load of the overrides file) → `provider-config-registry.ts` (`buildResolvedRegistry`, a singleton merging built-ins with overrides) → `provider-snapshot.ts` (two-tier probe) → `routes/providers.ts`. Tier 1 is a fast presence check; tier 2 is a cold ACP probe, skipped unless forced, stale past `PROVIDER_PROBE_TTL_MS`, or the DB has no models yet. Model sources differ per provider: `boocode`/`opencode` from llama-swap `/v1/models` (opencode IDs prefixed `llama-swap/`), `claude` from static registry entries, `qwen` from `~/.qwen/settings.json`, `goose` from the cold ACP probe. Startup `agent-probe.ts` UPSERTs all of this into `available_agents`. Commands come from the static `PROVIDER_COMMANDS` hints merged with live ACP `available_commands_update` (async — must poll after `newSession`); Claude, a PTY provider, discovers commands from disk via `claude-command-discovery.ts` (`~/.claude/commands` + enabled plugin skills). `AgentCommand.kind` (`'command'` vs `'skill'`) drives the slash-menu icon split in `CoderPane`.
### Testing
`apps/coder` has its own vitest suite (`pnpm -C apps/coder test`). Config: `globals: false` (import `describe`/`it`/`expect` from `vitest`), include glob `src/**/__tests__/**/*.test.ts` (files outside it silently don't run), `fileParallelism: false` so DB-integration suites serialize. The pattern is to extract pure helpers and unit-test them in isolation.
- `services/backends/__tests__/claude-sdk-map.test.ts` — SDK stream assembly, text/thinking dedup, tool input buffering, the `user` tool_result mapping
- `services/backends/__tests__/warm-acp-routing.test.ts` / `claude-sdk-routing.test.ts` — routing predicates
- `services/backends/__tests__/turn-guard.test.ts` — abort orphan-terminal suppression
- `services/backends/__tests__/lifecycle-decisions.test.ts` — idle/LRU/restart eviction
- `services/__tests__/acp-event-map.test.ts` / `acp-tool-snapshot.test.ts` — ACP normalization + snapshot merge
- `services/__tests__/provider-types-parity.test.ts` — text-identity parity between `provider-types.ts` and the web `api/types.ts` copy (compile-time cross-import is blocked by TS6307 on web's composite tsconfig)
- `services/__tests__/write_guard.test.ts` (+ `_fuzz`) — path escape + secret-file blocking
### Adding a new backend
1. **Implement `AgentBackend`** — new file under `apps/coder/src/services/backends/`; emit `AgentEvent`s, persist an `agent_sessions` row, honor `isBusy()`.
2. **Add a routing predicate** — a pure `shouldUseX(task, env?)` sibling to `warm-acp-routing.ts`, plus a unit test.
3. **Wire the dispatcher** — branch in `runTask` (`dispatcher.ts`) to construct/pool the backend and run `ensureSession`/`prompt`; reuse the shared `onEvent` mapping and `message_complete` publish.
4. **Extend the schema** — add the backend value to the `agent_sessions_backend_chk` CHECK in `apps/coder/src/schema.sql` (idempotent DROP + re-ADD).
5. **Register the provider** — entry in `provider-registry.ts` (and `provider-manifest.ts`/`provider-commands.ts` if it has modes/commands).
### Adding a new per-message field
A new per-message coder field silently drops unless **every** mapper is updated: the server read SELECT + `mapCoderMessageRow` (`routes/messages.ts`), `CoderPane.tsx` (`RawCoderMessage`/`CoderMessage`/`mapCoderTimelineRow` + the live `message_complete` reducer), `CoderMessageWire` (`CoderMessageList.tsx`), and `api/types.ts`. The client `mapCoderTimelineRow` whitelists fields — the easiest to forget (this is how the `model` chip once vanished, and what the `model: task.model` on `message_complete` restores).
### Troubleshooting
#### New routes 404 after deploy
The host service keeps running the OLD process until `sudo systemctl restart boocoder` — a stale process shows new routes `404 {error:'not found'}` while old routes still `200`. Restart, don't re-debug. `:9502/api/health` is down ~1520s after a restart while the startup agent probe runs; an early connection-refused is not a failed deploy.
#### Empty turns / 180s timeouts on OpenCode
Confirm the SSE subscribe passes the session's worktree `directory`; without it, events scope to the server cwd and the session sees zero events. Confirm the opencode model string is provider-prefixed (`llama-swap/<model>`) and present in `~/.config/opencode/opencode.json` — not merely loadable by llama-swap.
#### A stuck tool spinner that never resolves
The tool's terminal `tool_update` isn't being emitted. For the Claude SDK backend, verify the `user` tool_result mapping in `claude-sdk-map.ts`; for ACP, verify `tool_call_update` is reaching `mapSessionUpdate`.
## Related Documentation
- [Cross-App Contract Parity](./coding-standards/cross-app-contract-parity.md) — the coding standard for editing the duplicated provider-snapshot / WS-frame contracts this doc references
- [Architecture overview](./ARCHITECTURE.md) — system diagram; BooCoder execution paths in context
- [Provider picker backend plan](./superpowers/plans/2026-05-25-provider-picker-backend.md) — historical design of provider discovery (shipped v2.1.0)
- `apps/coder/CLAUDE.md` — per-app deep engineering reference (auto-loads when editing `apps/coder/`)
- `apps/server/CLAUDE.md` — the chat-side inference pipeline BooCoder's native path reuses
- `openspec/changes/v2-6-persistent-agent-sessions/``proposal.md` / `design.md` / `tasks.md`: the rationale behind keying sessions on `chat_id`, the warm-vs-one-shot decision, and the backend abstraction
- `openspec/changes/claude-sdk-sessionstore/` — the Claude Agent SDK backend + the `user` tool_result mapping batch