docs: archive shipped openspec batches; add feature/plan/research notes

Move 13 shipped openspec change docs under openspec/changes/archived/.
Add docs/features/git-diff-panel, docs/plans/post-review-backlog, and
docs/research/cross-app-contract-ssot.md (the research behind the
@boocode/contracts SSOT work). Update BOOCHAT.md, BOOCODER.md, and
boocode_roadmap.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 21:20:33 +00:00
parent e5ce01ae72
commit 2a05d2f9fe
27 changed files with 2210 additions and 17 deletions

View File

@@ -0,0 +1,61 @@
# Normalized external-agent status (#10, scoped)
**Status:** in progress (started 2026-06-01)
**Source:** `boocode_code_review_v2.md` §1 #10, §5j (superset, Elastic License 2.0 — PATTERN-ONLY,
clean-room; `/opt/forks/superset/.../map-event-type.ts`, `notify-hook.template.sh`, `agent-setup/*`).
**Decision (Sam, 2026-06-01):** scoped status-publish now; config-injection notify-hook as a follow-on.
## Why (corrected premise)
BooCoder already *observes* agent lifecycle (warm-acp/opencode/SDK backends know active/idle/crashed;
the permission-waiter knows blocked) but never **publishes a normalized per-`(chat,agent)` status** to the
UI — so blocked-on-permission is invisible and crash/idle aren't pushed proactively. The `AgentComposerBar`
dot only shows WS liveness. This batch publishes the status BooCoder already knows; the heavier
config-injection notify-hook (for out-of-band signals) is the documented follow-on.
## State model (clean-room from superset's `mapEventType`)
Superset collapses ~30 vendor event names → 3 signals: **Start** (working), **PermissionRequest**
(blocked), **Stop** (done). BooCoder adds idle (after done) + error (crash/fail). Normalized status:
`working | blocked | idle | error`.
## Pinned frame contract (server + web, byte-identical, parity-tested)
```ts
{ type: 'agent_status_updated', chat_id: Uuid, agent: string,
status: 'working' | 'blocked' | 'idle' | 'error', reason?: string, at: IsoTimestamp }
```
Added to `apps/server/src/types/ws-frames.ts` AND `apps/web/src/api/ws-frames.ts` (the `ws-frames` parity
test), plus the web `WsFrame` union in `apps/web/src/api/types.ts`. Published via the coder's
`broker.publishFrame` (validated against the server `WsFrameSchema`).
## Clean-room normalize helper (built now, reused by the injection follow-on)
`apps/coder/src/services/normalize-agent-status.ts`:
`normalizeAgentEvent(raw: string): 'working' | 'blocked' | 'done' | null` — a clean-room reimplementation
of the vendor-event-name → bucket mapping (the event names are facts about each agent's hooks:
`SessionStart`/`UserPromptSubmit`/`PostToolUse`→working; `PreToolUse`/`Notification`/`PermissionRequest`/
`exec_approval_request`→blocked; `Stop`/`session_end`/`task_complete`→done). The scoped publish points use
BooCoder's own already-normalized turn boundaries; this helper exists so the config-injection follow-on
(which receives raw vendor event names POSTed from agent hooks) reuses it. Unit-tested.
## Publish points (BooCoder's existing observation — no per-backend change)
- Dispatcher (`dispatcher.ts`) turn boundaries, for every external-agent path (warm-acp/opencode/sdk/pty):
`working` at turn start, `idle` on clean completion, `error` on failure.
- Permission-waiter (`permission-waiter.ts` / the `setPermissionHooks` publish in `index.ts`): `blocked`
when a permission is requested, back to `working` when resolved.
A small `publishAgentStatus(broker, chatId, agent, status, reason?)` helper centralizes the frame.
## Frontend
- `CoderPane.tsx` tracks the latest `agent_status_updated` per `(chat, agent)` (a small live map; reset on
chat switch).
- `AgentComposerBar.tsx` renders a normalized status dot beside the existing session chip (reuse the
`StatusDot` visual language: working=spinner/green, blocked=amber, idle=gray, error=red), distinct from
the WS-liveness `connected` dot.
## Follow-on (documented, not built): config-injection notify-hook
Clean-room re-derive superset's `agent-setup`: inject a notify hook into each agent's native config
(claude `~/.claude/settings.json`, opencode plugin, codex/gemini templates) that POSTs
`{agent, chat_id, eventType}` to a new `POST /api/coder/agent-status` endpoint, which runs
`normalizeAgentEvent` → publishes the SAME `agent_status_updated` frame. Reuses everything this batch
builds. Catches out-of-band signals BooCoder's dispatch can't see.
## Verify
- `pnpm -C apps/coder test` (+ normalize-agent-status tests) + `pnpm -C apps/server test` (ws-frames parity)
- `pnpm -C apps/server build && pnpm -C apps/coder build`; `npx tsc -p apps/web/tsconfig.app.json --noEmit`

View File

@@ -0,0 +1,68 @@
# Claude Agent SDK backend + clean-room PostgresSessionStore (#9)
**Status:** in progress (started 2026-06-01)
**Source:** `boocode_code_review_v2.md` §1 #9, §5h/§5i (happy + SDK `.d.ts`). Decision §6.2: lean SDK.
**SDK:** `@anthropic-ai/claude-agent-sdk@0.3.159` (installed, Commercial Terms — runtime dep OK, code
reference-only; the store is **clean-room** from the real interface, not vendored).
Replace BooCoder's one-shot PTY claude dispatch with a warm, resumable Claude-SDK backend. Two parts:
the clean-room session store (fully testable here) and the backend + wiring (live pump needs a host
smoke against real `claude`).
## Ground-truth SDK API (from the installed `sdk.d.ts`)
- `query({ prompt: string | AsyncIterable<SDKUserMessage>, options?: Options }): Query` where
`Query extends AsyncGenerator<SDKMessage, void>`.
- `Options`: `sessionStore?: SessionStore`, `resume?: string`, `model?`, `cwd?`,
`pathToClaudeCodeExecutable?`, `canUseTool?`, `permissionMode?`, `env?`, `allowedTools?`.
- `SessionStore = { append(key, entries): Promise<void>; load(key): Promise<SessionStoreEntry[]|null>;
listSessions?(projectKey): Promise<{sessionId,mtime}[]>; delete?(key): Promise<void>;
listSubkeys?({projectKey,sessionId}): Promise<string[]> }`.
- `SessionKey = { projectKey: string; sessionId: string; subpath?: string }` (undefined subpath = main
transcript; empty string invalid — store maps undefined→'' internally).
- `SessionStoreEntry = { type: string; uuid?: string; timestamp?: string; [k]: unknown }` (opaque JSONL).
- Messages: `SDKSystemMessage{subtype:'init'}` carries `session_id` (+ model/tools); `SDKResultMessage`
(success/error) ends a turn with `result`, `usage`, `total_cost_usd`; `SDKPartialAssistantMessage` /
`SDKAssistantMessage` carry text/thinking/tool blocks.
## Part 1 — Clean-room PostgresSessionStore (testable now)
- Schema (`apps/coder/src/schema.sql`): a generic append-only entry table
`claude_session_entries(id BIGSERIAL PK, project_key TEXT, session_id TEXT, subpath TEXT DEFAULT '',
entry JSONB, created_at TIMESTAMPTZ DEFAULT clock_timestamp())` + index `(project_key, session_id,
subpath, id)`. (The store is generic per the SDK's key; the chat↔session ownership lives in
`agent_sessions`, not here.)
- `apps/coder/src/services/backends/claude-session-store.ts`: `PostgresSessionStore` implementing the
real `SessionStore` type over `Sql`. `append` = ordered multi-INSERT (id = order); `load` = SELECT
ORDER BY id → array or null; `listSessions` = group main-transcript rows, mtime = max(created_at) ms;
`delete` = scoped delete (subpath given → that subpath; omitted → whole session); `listSubkeys` =
DISTINCT non-'' subpaths. Pure SQL, no SDK import needed beyond the `SessionStore` type.
- Tests `__tests__/claude-session-store.test.ts` (DB-opt-in, mirror `checkpoints.test.ts`): append→load
round-trip + order, null on unseen key, subpath isolation (main vs subagent), listSessions mtime,
delete scoping, listSubkeys.
## Part 2 — ClaudeSdkBackend + wiring (live pump needs host smoke)
- `agent_sessions.backend` CHECK adds `'claude_sdk'`.
- `apps/coder/src/services/backends/claude-sdk.ts`: a `ClaudeSdkBackend` implementing `AgentBackend`
(mirror `warm-acp.ts`/`opencode-server.ts`). `ensureSession` resolves the resume id from
`agent_sessions(chat_id,'claude').agent_session_id`; `prompt` drives one persistent `query()` in
streaming-input mode (a pushable `AsyncIterable<SDKUserMessage>` fed per turn) with
`{ sessionStore, resume, model, cwd: worktreePath, pathToClaudeCodeExecutable: installPath }`,
reads the `AsyncGenerator<SDKMessage>` until `result`, captures `session_id` from the `init` message
and persists it to `agent_sessions`. A pure `mapSdkMessage(msg): AgentEvent[]` (unit-tested) maps
partial/assistant/tool/thinking → the existing `AgentEvent` union; `result.usage`/`total_cost_usd`
accumulate onto `agent_sessions` (like opencode U.6). `isBusy`/`closeSession`/crash mirror the ACP
backend.
- Routing: add `claude` to the warm path (`warm-acp-routing.ts` or a sibling `shouldUseClaudeSdk`),
with the existing PTY `runExternalAgent` kept as the **fallback** (session-less creators + if the SDK
backend fails to start). Provider registry: claude stays selectable; transport reflects the SDK path.
- Frames + persistence identical to the warm-ACP path (`persistExternalAgentTurn`, broker frames).
## Verify
- Part 1: `pnpm -C apps/coder test` + DB-opt-in store tests against dev postgres; build clean.
- Part 2: `pnpm -C apps/coder build` + `npx tsc -p apps/coder/tsconfig.json --noEmit` (typechecks
against the REAL SDK types) + pure-mapper unit tests. **Live pump + resume across turns: host smoke
against real `claude` (auth required) — cannot run from the dev container.**
## Open flags
- SDK peer-deps want `zod@^4`; workspace is `zod@3.25.76` (installed with a warning) — watch at runtime.
- `pathToClaudeCodeExecutable` from `available_agents.install_path`; the SDK spawns the same `claude`
binary the PTY path uses. ANTHROPIC auth/env must reach the child (host concern).

View File

@@ -0,0 +1,51 @@
# License-debt — relicense AGPL-3.0 → MIT
**Status:** in progress (started 2026-06-01)
**Decision:** Sam, 2026-05-31 — relicense BooCode back to MIT.
**Source:** `boocode_code_review_v2.md` §1 #1, §5k; roadmap `## License-debt` batch.
## Why
The tree is **currently AGPL-3.0** — root `LICENSE` is GNU Affero GPL v3 and all five
`package.json` declare `"license": "AGPL-3.0-only"`. Cause: the `v2.4.0`/`v2.4.1`
Unsloth-Studio lifts pulled in three AGPL-3.0-only files. BooCode is network-served, so
AGPL §13 network-copyleft is a live liability. Clearing the three files makes the MIT flip
valid; nothing else AGPL remains once they are gone.
## Core insight (supersedes the roadmap's staged steps)
The roadmap entangled the relicense with retiring `tool-call-parser.ts` behind a live
qwen3.6 validation window. That is **not necessary**: the Unsloth-ported algorithm
(`parseToolCallsFromText` / `scanBalancedBraces` + unused constants) is **dead code**
no production consumer imports it (verified: only the file and its test reference it). The
load-bearing parser (`extractToolCallBlocks`, under the file's own "BooCode streaming
helpers" banner) and `stripToolMarkup` are BooCode-authored. So the relicense **strips
provenance, not capability** — zero behavior change, no validation gate. The
native-llama-server-parsing retirement remains a separate, optional future optimization.
## The three AGPL-3.0-only files to clear
1. `apps/server/src/services/web/html-to-md.ts` (← `_html_to_md.py`) — **swap** to
`node-html-markdown` (MIT). A different third-party library, not a rewrite-from-memory
(which would still be a derivative). Consumed by `web_fetch` via `web/index.ts`;
`htmlToMarkdown(html): string` signature preserved.
2. `apps/server/src/services/inference/llama-args-validator.ts` (← `llama_server_args.py`)
**clean-room** re-derive the flag denylist from the public llama-server README (CLI
flag names are facts, not copyrightable); the shadowing logic is already BooCode's own.
3. `apps/server/src/services/inference/tool-call-parser.ts` (← `tool_call_parser.py`) —
**delete** the dead Unsloth-ported code; keep BooCode's streaming helpers +
`stripToolMarkup` (re-derive its strip regexes from qwen's wire format); drop the header.
No change to the live tool-call path.
## Decisions (Sam, 2026-06-01)
- html-to-md library: **node-html-markdown** (single MIT dep, GFM tables built-in).
- tool-call-parser: **relicense-only** — defer native-parsing retirement.
- MIT copyright line: **`Copyright (c) 2026 indifferentketchup`**.
- Leave `boocode_code_review*.md` (point-in-time snapshots) untouched; update the roadmap
batch (planned → shipped) and add a README License section.
## Out of scope
- Retiring `tool-call-parser` patterns 1 & 2 in favour of native llama-server parsing.
- Bumping the stale README "Latest release" line / AGENTS.md pointer.

View File

@@ -0,0 +1,51 @@
# Tasks — relicense AGPL-3.0 → MIT
Four units. A/B/C are disjoint files (parallelizable); D is the join (runs after A/B/C).
The shared `node-html-markdown` dependency swap + `pnpm install` is done before A so the
parallel agents don't race on `apps/server/package.json`.
## Pre: dependency swap (done by coordinator)
- [ ] Add `node-html-markdown` to `apps/server/package.json` dependencies; remove `parse5`
(only html-to-md consumed it).
- [ ] `pnpm install`.
## A — html-to-md → node-html-markdown
- [ ] Replace `apps/server/src/services/web/html-to-md.ts` with a thin MIT wrapper exporting
`htmlToMarkdown(sourceHtml: string): string` over `NodeHtmlMarkdown.translate`.
- [ ] Drop the AGPL/Unsloth SPDX header.
- [ ] Update `html-to-md.test.ts` to the new library's output (structure-level `.toContain`
where whitespace differs; output feeds an LLM so exact format is not load-bearing).
- [ ] Keep `web/index.ts` re-export and `web_fetch.ts` untouched.
## B — llama-args-validator → clean-room
- [ ] Rewrite `apps/server/src/services/inference/llama-args-validator.ts`: re-derive the
managed-flag denylist from the public llama-server README; keep the BooCode
shadowing-flag logic. Same exports (`validateExtraArgs`, `isManagedFlag`,
`stripShadowingFlags`, `StripOptions`).
- [ ] Drop the AGPL/Unsloth SPDX header.
- [ ] Keep `llama-args-validator.test.ts` green (it pins the contract).
## C — tool-call-parser → minimal clean (relicense-only)
- [ ] Delete dead Unsloth-ported exports: `parseToolCallsFromText`, `scanBalancedBraces`,
`OpenAiToolCall`, `hasToolSignal`, and the unused nudge constants
(`DUPLICATE_CALL_NUDGE`, `TOOL_ERROR_NUDGE`, `TOOL_ERROR_PREFIXES`,
`BUDGET_EXHAUSTED_NUDGE`).
- [ ] Keep `extractToolCallBlocks` + streaming helpers + `stripToolMarkup` (re-derive its
strip regexes from qwen's wire format). Drop the AGPL/Unsloth SPDX header.
- [ ] Remove the now-dead tests from `tool-call-parser.test.ts`; keep streaming/strip tests.
- [ ] Verify `stream-phase.ts` (`extractToolCallBlocks`) + `tool-phase.ts` / `error-handler.ts`
(`stripToolMarkup`) still compile.
## D — license flip (join)
- [ ] `LICENSE`: replace AGPL-3.0 text with MIT, `Copyright (c) 2026 indifferentketchup`.
- [ ] Flip `"license"` to `"MIT"` in all 5 `package.json` (root, server, web, coder, booterm).
- [ ] Confirm no `SPDX-License-Identifier: AGPL` header survives in the 3 files.
- [ ] Roadmap `License-debt` batch: planned → shipped (note the decoupled-from-parser-retirement
approach). Add a `## License` section to `README.md` (MIT).
- [ ] Optional guard test: assert no `AGPL` SPDX header in `apps/**` and all 5 `package.json`
are MIT.
## Verify
- [ ] `pnpm -C apps/server test`
- [ ] `pnpm -C apps/server build`
- [ ] root `npx tsc --noEmit`

View File

@@ -0,0 +1,70 @@
# MistakeTracker + file-provenance ledger (#12)
**Status:** in progress (started 2026-06-01)
**Source:** `boocode_code_review_v2.md` §1 #12, §5e (cline — algorithm-reimplemented, not vendored).
Two native-inference (apps/server) hardening features. One cohesive backend change (they share
`TurnArgs` + the tool-phase observation point) + a small frontend sentinel render.
## Part A — MistakeTracker (heterogeneous-failure recovery)
Complements the doom-loop guard (`sentinels.ts:detectDoomLoop`, which only catches *identical*
repeats) by catching a run of consecutive tool **failures** the model isn't recovering from.
- New pure `apps/server/src/services/inference/mistake-tracker.ts` (mirrors `detectDoomLoop`):
- `FailureKind = 'zod_reject' | 'tool_not_found' | 'exec_error' | 'api_error' | 'permission_denied'`
(all already distinguished in `tool-phase.ts:executeToolCall`).
- `MISTAKE_THRESHOLD = 3`.
- State `{ run: FailureKind[]; nudges: number }``run` is the current consecutive-failure streak,
reset on ANY successful tool step; `nudges` counts recovery injections not yet cleared by a success.
- `recordStep(state, outcome)` where outcome is a failure kind or `'success'`.
- `detectMistakePattern(state): 'nudge' | 'escalate' | null``run.length >= 3``'nudge'` the first
time (`nudges === 0`), `'escalate'` if it trips again while `nudges >= 1` (no intervening success).
- Lives in `TurnArgs` (loop-local, reset per `runInference`, like `recentToolCalls`).
- Integration in `turn.ts` loop: after each tool phase, `recordStep` per tool outcome; then
`detectMistakePattern`:
- `'nudge'` (decision: soft + escalate): append a transient **model-facing** recovery-guidance system
message to the NEXT turn's payload (re-read schemas, verify paths exist before acting, try a
different approach — not retry variations), insert a `mistake_recovery` UI sentinel
(`escalated:false`), bump `nudges`, reset `run`. Loop continues.
- `'escalate'`: stop the turn (break), insert a `mistake_recovery` sentinel (`escalated:true`,
`can_continue:true`, cap-hit-style), finalize. Prevents heterogeneous failures from burning the
whole step budget.
## Part B — File-provenance ledger (Read-only)
- Accumulate file paths read by `view_file`/`grep`/`find_files`/`list_dir` into `TurnArgs.filesRead:
Set<string>` (recorded at the tool-phase, like the failure outcomes).
- On compaction (`compaction.ts:buildPrompt`), inject a deterministic, sorted `## Files Read` list into
the summary prompt context so the summarizer merges it into the rolling summary — **no new
table/column**; it propagates as summary text across compactions. `compaction-prompt.ts`'s
`SUMMARY_TEMPLATE` already has a `## Relevant Files` section to extend/merge with.
- BooChat is **read-only** (no write tools on apps/server) → "Files Modified" is N/A here; only
"Files Read". (The apps/coder write side can add "Modified" later.)
## Sentinel contract (pinned — backend + frontend must match)
New sentinel kind on `MessageMetadata` in BOTH `apps/server/src/types/api.ts` AND
`apps/web/src/api/types.ts`:
```
{ kind: 'mistake_recovery'; failure_kinds: string[]; count: number; escalated: boolean; can_continue?: boolean }
```
- `role='system'`, `status='complete'`, stripped from the LLM payload via `isAnySentinel` in
`payload.ts` (UI-only) and `compaction.ts:buildHeadPayload`.
- Frontend render branch in `apps/web/src/components/MessageBubble.tsx`: `escalated:false` →
"Hit repeated different errors — recovery guidance injected, continuing." `escalated:true` →
"Repeated errors persisted — stopped the turn." (mirror the doom-loop/cap-hit branches).
## Decisions (2026-06-01)
- MistakeTracker intervention: **soft nudge + escalate**.
- **UI sentinel** for recovery (`mistake_recovery`).
## Files (backend, one agent) / (frontend, one agent)
- Backend: `mistake-tracker.ts` (new), `turn.ts`, `tool-phase.ts`, `sentinels.ts`,
`sentinel-summaries.ts`, `payload.ts`, `compaction.ts`, `compaction-prompt.ts`, `types/api.ts` +
tests (`mistake-tracker.test.ts`, ledger/compaction assertions).
- Frontend: `apps/web/src/api/types.ts` (MessageMetadata arm) + `MessageBubble.tsx` (render branch).
MUST NOT touch Sam's WIP web files.
## Verify
- `pnpm -C apps/server test`; `pnpm -C apps/server build`; `npx tsc -p apps/web/tsconfig.app.json --noEmit`

View File

@@ -0,0 +1,45 @@
# Small wins — sampling knobs + PTY stream-json + token UI
**Status:** in progress (started 2026-06-01)
**Source:** `boocode_code_review_v2.md` §1 #11 / #7 / #8 (config-adopt + qwen-code §5g + opencode §3 #4).
Three independent BooCode improvements, disjoint subsystems (apps/server / apps/coder / apps/web).
## #11 — New sampling knobs (apps/server)
Per-agent `top_n_sigma` + the `dry_*` repetition family help the doom-loop-prone local model.
Today the Agent type threads `temperature/top_p/top_k/min_p/presence_penalty` into the inference
request (`stream-phase.ts:396438`). Add `top_n_sigma`, `dry_multiplier`, `dry_base`,
`dry_allowed_length`, `dry_penalty_last_n` as first-class Agent fields (`types/api.ts`), parse them in
`agents.ts:parseFrontmatter` (same bounded per-field numeric pattern + out-of-range warn), and thread
them into the request body **via the same mechanism `top_k`/`min_p` already use** (the agent must
confirm whether that's an AI-SDK `providerOptions`/`extraBody` passthrough — these are llama.cpp
extensions, not standard OpenAI fields — and ride it; surface it if `top_k`/`min_p` turn out to be
silently dropped today). `--reasoning-budget` is a llama-server CLI flag already permitted by the
deny-list validator, so it works via `llama_extra_args: ["--reasoning-budget","N"]` now — document it
in `data/AGENTS.md`. apps/server only.
## #7 — Live PTY stream-json NDJSON parsing (apps/coder)
qwen/claude PTY dispatch slices stdout opaque (`dispatcher.ts` PTY path; qwen already runs
`--output-format stream-json`). Add a parser for the Claude-Code-compatible NDJSON
(`system`/`assistant`/`result`/`stream_event``content_block_delta` text/thinking/tool deltas +
`usage` + `session_id`) that maps to the existing `AgentEvent` union (`agent-backend.ts`). **Live
incremental** (decision 2026-06-01): line-buffer the PTY stdout `data` events, parse each complete
NDJSON line as it arrives, and emit broker frames live (text/reasoning/tool) like the ACP/opencode
paths — plus accumulate for `persistExternalAgentTurn`. claude gets `--output-format stream-json` too.
One parser serves both (same schema). apps/coder only (`pty-dispatch.ts`, `dispatcher.ts`, new
`stream-json-parser.ts` + test).
## #8 — Surface opencode token usage (apps/coder route + apps/web)
`agent_sessions.input_tokens/output_tokens/cost` are accumulated (v2.6.8) but the
`GET /api/sessions/:id/agent-sessions` SELECT + the `AgentSessionInfo` type drop them. Add the 3
columns to both, render condensed beside the existing session chip in `AgentComposerBar`
(ChatThroughput styling: `tabular-nums`, muted, e.g. "12.4K in / 3.2K out / $0.25"). MUST NOT touch
Sam's uncommitted WIP (`ChatTabBar`, `SessionLandingPage`, `Workspace`, `useWorkspacePanes`,
`PaneHeaderActions`).
## Decisions (2026-06-01)
- #7 surfacing: **live incremental** streaming (not parse-at-end).
## Verify
- `pnpm -C apps/server test` (+ new agent-parse tests); `pnpm -C apps/coder test` (+ new parser tests)
- `pnpm -C apps/server build && pnpm -C apps/coder build`; `npx tsc -p apps/web/tsconfig.app.json --noEmit`

View File

@@ -0,0 +1,728 @@
# v2.3 Provider lifecycle — design
Detailed implementation plan for Paseo-style provider registration, readiness probing, and enable/disable toggles in BooCoder.
> **✅ Shipped 2026-05-29 across `v2.5.4``v2.5.13` (reconciled 2026-05-31).** All 6 phases live. As-built deltas: the diagnostic ships as JSON `{ diagnostic: string }` (§6) rather than a plaintext HTTP body (§8's framing); the provider-management UI landed as a **Settings → Providers tab** (the §7.1 "or section under existing settings" path), not a standalone `ProviderSettingsDrawer`; `AddProviderModal` is at `apps/web/src/components/coder/`. **Deferred** (the §7.1 "optional phase 2" + tasks O.1O.3): WS `provider_snapshot_updated` frame, `available_agents.enabled` column, diagnostic row-click modal — tracked in `docs/DEFERRED-WORK.md`.
**Audience:** Sam + future agents implementing the batch.
**Paseo reference:** `/opt/forks/paseo/packages/server/src/server/agent/` (registry, snapshot manager, generic ACP), `/opt/forks/paseo/packages/app/src/screens/settings/providers-section.tsx` (UI behavior).
---
## 1. Current state vs target
### 1.1 BooCode today (v2.2)
```
┌─────────────────┐ startup ┌──────────────────┐
│ provider- │ ───────────────► │ available_agents │ (which, version, models JSONB)
│ registry.ts │ agent-probe │ (Postgres) │
│ (7 hardcoded) │ └────────┬─────────┘
└────────┬────────┘ │
│ │
▼ ▼
┌─────────────────┐ cache miss ┌──────────────────┐
│ getProvider │ ──────────────► │ probeAcpProvider │ (full ACP session, 30s)
│ Snapshot() │ per agent │ per installed │
└────────┬────────┘ └──────────────────┘
Omit uninstalled ──► AgentComposerBar never sees them
No enabled flag
status: ready | error only
```
**Key files:**
| File | Role |
|------|------|
| `apps/coder/src/services/provider-registry.ts` | Static `PROVIDERS[]` |
| `apps/coder/src/services/agent-probe.ts` | Boot `which` + DB upsert |
| `apps/coder/src/services/provider-snapshot.ts` | Cache + cold probe + merge |
| `apps/coder/src/services/acp-spawn.ts` | Per-agent argv switch |
| `apps/coder/src/routes/providers.ts` | snapshot + refresh |
| `apps/web/src/components/AgentComposerBar.tsx` | Picker UI |
### 1.2 Target (Paseo-aligned, BooCode-native)
```
┌──────────────────┐
│ Built-in registry│──┐
│ (provider- │ │
│ registry.ts) │ │ merge at boot + on config reload
└──────────────────┘ │
┌──────────────────┐ ┌──────────────────┐
│ /data/coder- │─►│ ResolvedProvider │
│ providers.json │ │ Registry (in-mem) │
└──────────────────┘ └────────┬───────────┘
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
agent-probe (fast) getProviderSnapshot dispatch
which + version tier-1: isAvailable generic ACP for
→ available_agents tier-2: cold ACP config entries
enabled filter
Always emit entry per registered provider
loading → ready | unavailable | error
```
**Principles copied from Paseo** (`docs/providers.md` in fork):
1. **Registration ≠ installation** — config lists what you *want*; probe tells you whats *ready*.
2. **Warm until refresh** — no TTL re-probe on picker open; explicit `POST /api/providers/refresh` only.
3. **Disabled skips probe**`enabled: false``unavailable` without spawning.
4. **Config reload replaces registry** — no redeploy to add an ACP wrapper.
---
## 2. Config file: `/data/coder-providers.json`
### 2.1 Location and loading
| Env var | Default | Notes |
|---------|---------|-------|
| `CODER_PROVIDERS_PATH` | `/data/coder-providers.json` | Same bind-mount pattern as `SKILLS_ROOT`, `MCP_CONFIG_PATH` |
- BooCoder runs on **host systemd** — path resolves to `/opt/boocode/data/coder-providers.json` in dev (add to repo as `data/coder-providers.json` + `.env.host`).
- Missing file → `{}` (built-ins only, all enabled).
- Invalid JSON → log error, fall back to `{}` (do not crash boot).
- **Reload:** on `POST /api/providers/config` success, or `SIGHUP` optional later; v1: restart `boocoder.service` after manual edit is acceptable for solo use.
### 2.2 Schema (Zod)
New file: `apps/coder/src/services/provider-config.ts`
```typescript
const ProviderOverrideSchema = z.object({
extends: z.enum(['acp']).optional(), // v2.3: only 'acp' for custom; built-ins omit extends
label: z.string().min(1).optional(),
description: z.string().optional(),
command: z.array(z.string().min(1)).min(1).optional(), // [binary, ...args]
env: z.record(z.string()).optional(),
enabled: z.boolean().optional(), // default true
order: z.number().int().optional(), // UI sort key
models: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
additionalModels: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
});
const CoderProvidersFileSchema = z.object({
providers: z.record(ProviderOverrideSchema).default({}),
});
```
**Rules:**
| Case | Behavior |
|------|----------|
| Built-in id (e.g. `goose`) | Override merges: `enabled`, `label`, `command` (replace spawn), `env` |
| New id + `extends: "acp"` | New registry entry; requires `label` + `command` |
| New id without `extends` | Reject at load with log (v2.3) |
| `enabled: false` on built-in | Stays in registry; snapshot `enabled: false`, status `unavailable` |
| Custom id collision with built-in | Config wins for overrides only; cannot redefine `boocode` transport |
### 2.3 Example file (ship in `data/coder-providers.json`)
```json
{
"providers": {
"goose": { "enabled": true },
"copilot": { "enabled": false },
"amp-acp": {
"extends": "acp",
"label": "Amp",
"description": "ACP wrapper for Amp",
"command": ["amp-acp"],
"enabled": true
}
}
}
```
### 2.4 Paseo parity notes
Paseo uses `~/.paseo/config.json` under `agents.providers` with the same fields (`extends`, `command`, `enabled`, `models`, …). We intentionally use a **repo-adjacent data file** instead of dotfile — matches `AGENTS.md` / skills layout and survives container/host split (coder reads host path).
---
## 3. Resolved provider registry
### 3.1 New module: `provider-config-registry.ts`
**Responsibility:** Single in-memory source of truth after merge.
```typescript
export interface ResolvedProviderDef extends ProviderDef {
id: string;
enabled: boolean;
isBuiltin: boolean;
isCustomAcp: boolean;
/** Full argv for spawn: [binary, ...args] */
launchCommand: [string, ...string[]] | null;
env: Record<string, string> | undefined;
configLabel?: string;
configDescription?: string;
}
export function buildResolvedRegistry(
builtins: ProviderDef[],
config: CoderProvidersFile,
): Map<string, ResolvedProviderDef>;
export function loadProviderConfig(path: string): CoderProvidersFile;
export function reloadProviderConfig(): void; // called after PATCH
```
**Merge algorithm** (mirror Paseo `buildProviderRegistry` / `addDerivedProviders`):
1. For each built-in in `PROVIDERS`:
- Apply config override if present
- `enabled = override.enabled !== false`
- `launchCommand` = override.command ?? default from `acp-spawn` + `install_path` at dispatch time
2. For each config key not in built-ins:
- Require `extends: "acp"`, `label`, `command`
- Insert as `isCustomAcp: true`, `transport: 'acp'`, `modelSource: 'probe'`
3. **`boocode`** always enabled; ignore `enabled: false` with warn log
**Consumers:** `agent-probe`, `provider-snapshot`, `dispatcher`, `acp-dispatch`, routes.
### 3.2 agent-probe changes
File: `apps/coder/src/services/agent-probe.ts`
- Iterate **`getResolvedProviderIds()`** instead of `PROBED_AGENT_NAMES` only.
- For custom ACP: probe `command[0]` via `which` (not agent name).
- Upsert `available_agents` for custom ids (new rows).
- Store `label`, `transport: 'acp'` from resolved def.
- Skip probe entirely when `enabled: false` (optional: delete row or keep stale — **keep row**, set `install_path null` on disable refresh).
### 3.3 Schema migration (optional column)
File: `apps/coder/src/schema.sql`
```sql
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS source TEXT DEFAULT 'builtin';
-- source: 'builtin' | 'config'
```
Mirror `enabled` from config on each probe pass. Custom providers get `source = 'config'`.
**Alternative (simpler v2.3.0):** dont add DB column; read `enabled` only from in-memory registry at snapshot time. DB holds install facts only. Prefer this for phase 1; add column if settings page needs to show state after coder restart without re-reading JSON.
---
## 4. Snapshot lifecycle
### 4.1 Type changes
Files: `apps/coder/src/services/provider-types.ts`, `apps/web/src/api/types.ts`
```typescript
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
export interface ProviderSnapshotEntry {
name: string;
label: string;
description?: string;
transport: string;
status: ProviderSnapshotStatus;
enabled: boolean;
installed: boolean; // binary found on last fast probe
models: ProviderModel[];
modes: ProviderMode[];
defaultModeId: string | null;
commands: AgentCommand[];
error?: string;
fetchedAt?: string; // ISO — when tier-2 probe completed
}
```
Restore `unavailable` (removed in stale cleanup — intentional regression for this batch).
### 4.2 `buildProviderEntry` rewrite
File: `apps/coder/src/services/provider-snapshot.ts`
**Stop returning `null` for uninstalled.** Always return an entry for every resolved registry id.
```
for each resolvedProvider:
if !enabled:
return { status: 'unavailable', enabled: false, installed: false, models: [], ... }
if native boocode:
return { status: 'ready', enabled: true, installed: true, models: llamaSwap, ... }
fast = agentRow?.install_path != null // or isCommandAvailable(launchCommand[0])
if !fast:
return { status: 'unavailable', enabled: true, installed: false, models: [], modes: manifest, commands: manifest }
if tier2_skipped: // see §4.3
return { status: 'ready', enabled: true, installed: true, models: from DB, modes: manifest or DB, ... }
cold ACP probe:
ok → ready + models/modes/commands merge
fail → error + error message
```
### 4.3 Two-tier probe (implements deferred work §2)
**Tier 1 — fast (always on cold read if enabled + installed):**
```typescript
async function isProviderAvailable(resolved: ResolvedProviderDef, agentRow: AgentRow): Promise<boolean> {
if (resolved.isNative) return true;
if (agentRow?.install_path) return true;
if (resolved.launchCommand) return isCommandAvailable(resolved.launchCommand[0]);
return false;
}
```
New util: `apps/coder/src/services/command-availability.ts``which`-style check (lift idea from Paseo `utils/executable.ts`, ~20 lines, no full port).
**Tier 2 — slow (ACP session):**
Run only when:
| Condition | Action |
|-----------|--------|
| `force === true` (`POST /refresh`) | Always cold probe installed enabled providers |
| `last_probed_at` older than `PROVIDER_PROBE_TTL_MS` (default 24h, env override) | Cold probe |
| DB models empty AND installed | Cold probe |
| Otherwise | Use `available_agents.models` + manifest modes/commands |
Env: `PROVIDER_PROBE_TTL_MS` default `86400000` (24h). Paseo uses warm-forever until refresh; 24h is a homelab compromise so stale model lists self-heal.
**Paseo contract (adopt explicitly):**
- Opening `AgentComposerBar` does **not** call refresh or force probe.
- `POST /api/providers/refresh` clears cache + forces tier-2 for home cwd.
- Document in `BOOCODER.md`.
### 4.4 Loading state
On cache miss, before async probe completes:
1. Return entries with `status: 'loading'` immediately (sync).
2. Singleflight inflight map (already exists) — on completion, flip to terminal status + emit…
**Tier 2 optional:** WS frame `provider_snapshot_updated` — defer to follow-up; v2.3 can rely on client polling 2s while any entry `loading` (CoderPane already polls when WS disconnected; extend: poll while snapshot has `loading`).
### 4.5 Cache keys
Keep cwd-keyed cache (`resolvedCwd = cwd ?? homedir()`). Settings UI uses snapshot with **no cwd** or explicit `cwd=~` — same as Paseo home-directory snapshot for provider management.
---
## 5. Generic ACP dispatch
### 5.1 Problem
`acp-spawn.ts` switch grows with every agent. Custom config entries cannot dispatch today.
### 5.2 Solution
File: `apps/coder/src/services/acp-spawn.ts`
```typescript
export function resolveLaunchSpec(
resolved: ResolvedProviderDef,
installPath: string | null,
): { binary: string; args: string[]; env?: Record<string, string> } | null {
if (resolved.launchCommand) {
return {
binary: resolved.launchCommand[0],
args: resolved.launchCommand.slice(1),
env: resolved.env,
};
}
// built-in fallback
const args = resolveAcpSpawnArgs(resolved.id);
if (!args || !installPath) return null;
return { binary: installPath, args, env: resolved.env };
}
```
File: `apps/coder/src/services/acp-dispatch.ts`
- Replace `resolveAcpSpawnArgs(agent)` + `spawn(installPath, args)` with `resolveLaunchSpec(resolved, installPath)`.
- Merge `env` into spawn `env: { ...process.env, ...spec.env }`.
- Dispatcher loads resolved def by task.agent name.
**Do not port** Paseo `GenericACPAgentClient` class — keep procedural dispatch + existing `acp-stream.ts`.
---
## 6. HTTP API
File: `apps/coder/src/routes/providers.ts`
| Method | Path | Body | Response |
|--------|------|------|----------|
| GET | `/api/providers/snapshot?cwd=` | — | `ProviderSnapshotEntry[]` (unchanged path) |
| POST | `/api/providers/refresh` | `{ providers?: string[] }` optional | `{ refreshed: number }` — if `providers` set, refresh subset only (Paseo pattern) |
| GET | `/api/providers/config` | — | `{ providers: Record<string, ProviderOverride> }` |
| PATCH | `/api/providers/config` | partial providers map | merged file written, registry reload, `{ ok: true }` |
| GET | `/api/providers/:id/diagnostic` | — | `{ diagnostic: string }` Tier 2 |
**PATCH semantics:** shallow merge at top level per provider id (same as Paseo `patchConfig`). Writing `enabled: false` triggers registry reload + snapshot reconcile (mark unavailable without probe).
**Proxy:** BooChat server may proxy `/api/coder/providers/*` — check `apps/server/src/index.ts` coder proxy prefix; add config routes if missing.
**Web client:** `apps/web/src/api/client.ts`
```typescript
coder: {
snapshot: ...
refreshProviders: (providers?: string[]) => ...
getProviderConfig: () => ...
patchProviderConfig: (patch) => ...
getProviderDiagnostic: (id) => ...
}
```
---
## 7. Web UI
### 7.1 Settings: Provider management drawer
New: `apps/web/src/components/coder/ProviderSettingsDrawer.tsx` (or section under existing settings)
**Behavior lifted from Paseo `providers-section.tsx`:**
| UI element | Action |
|------------|--------|
| Row per registered provider | Label, status dot, model count |
| Switch | `PATCH config { [id]: { enabled } }` |
| Refresh icon | `POST /api/providers/refresh` |
| Add provider | Opens catalog modal |
| Row click | Diagnostic sheet (optional phase 2) |
**Status labels:** Disabled · Loading · Available · Not installed · Error
Entry point: link from `AgentComposerBar` (gear icon) or CoderPane header.
### 7.2 AgentComposerBar filter
File: `apps/web/src/components/AgentComposerBar.tsx`
```typescript
const selectable = entries.filter(
(e) => e.enabled && e.status === 'ready' && e.models.length > 0
);
// boocode: allow ready with empty models if llama-swap down? keep current fallback
```
Show subtitle when current provider becomes unavailable (toast + reset to boocode).
### 7.3 Add provider modal
New: `apps/web/src/data/acp-provider-catalog.ts`
Curated entries (start with 510 you might install):
| id | command | installLink |
|----|---------|-------------|
| amp-acp | `["amp-acp"]` | github amp-acp |
| cline | `["npx","-y","cline@…","--acp"]` | cline.bot |
| pi-acp | from fork | … |
Copy **structure** from Paseo `acp-provider-catalog.ts` + `buildAcpProviderConfigPatch` — trim versions aggressively; pin only when youve verified on homelab.
Modal: search, Install → `patchProviderConfig(buildPatch(entry))``refreshProviders([entry.id])`.
**Do not port:** React Native components, remote SVG icon pipeline — use lucide fallback icon.
### 7.4 Loading UX
While any entry `status === 'loading'`, show spinner in composer provider dropdown; optional 2s poll until terminal state (reuse CoderPane poll pattern).
---
## 8. Diagnostics (Tier 2 in batch — lightweight)
Paseo `getDiagnostic()` runs version probe + short ACP initialize. For solo debugging:
File: `apps/coder/src/services/provider-diagnostic.ts`
```typescript
export async function getProviderDiagnostic(
resolved: ResolvedProviderDef,
agentRow: AgentRow | undefined,
cwd: string,
): Promise<string> {
// Plaintext report:
// - enabled, installed, binary path
// - last_probed_at, model count from DB
// - optional: 8s ACP initialize probe (reuse acp-probe with shorter timeout)
}
```
No need for Paseo `diagnostic-utils.ts` formatting library — a template string is fine.
---
## 9. Testing strategy
| Test | File |
|------|------|
| Config load + merge | `provider-config-registry.test.ts` |
| Snapshot: disabled → unavailable, no probe mock call | extend `provider-snapshot.test.ts` |
| Snapshot: uninstalled → unavailable, installed true/false | same |
| Tier-2 skip when fresh DB models | same |
| force refresh calls probe | same |
| PATCH config writes file | `routes/providers.test.ts` (optional integration) |
| resolveLaunchSpec custom command | `acp-spawn.test.ts` |
Run: `pnpm -C apps/coder test`, `npx tsc -p apps/web/tsconfig.app.json --noEmit`.
Smoke:
```bash
curl http://100.114.205.53:9502/api/providers/snapshot
curl -X PATCH http://100.114.205.53:9502/api/providers/config -d '{"providers":{"goose":{"enabled":false}}}'
curl -X POST http://100.114.205.53:9502/api/providers/refresh
```
---
## 10. Implementation phases
### Phase 1 — Config + registry (backend only)
- `provider-config.ts`, `provider-config-registry.ts`
- `data/coder-providers.json` + `CODER_PROVIDERS_PATH`
- Wire `agent-probe` to resolved ids
- Unit tests
**Exit:** custom entry in JSON → row in `available_agents` after restart.
### Phase 2 — Snapshot lifecycle
- Types: `loading`, `unavailable`, `enabled`
- Rewrite `buildProviderEntry` (never omit)
- Tier-1 fast availability
- Tier-2 skip when DB fresh
- Restore warm-cache + force refresh semantics
**Exit:** disabled goose visible in API as unavailable; picker filters it out.
### Phase 3 — Generic dispatch
- `resolveLaunchSpec`
- Dispatcher passes resolved def
- Smoke: dispatch task for config-only provider (amp-acp if installed)
### Phase 4 — HTTP config API
- GET/PATCH config
- Reload registry on PATCH
- Subset refresh
### Phase 5 — Web UI
- Provider settings drawer + toggle
- AgentComposerBar filter
- Catalog modal (minimal list)
### Phase 6 — Docs + deploy
- `BOOCODER.md` section: Provider config
- `CHANGELOG.md` entry
- `docs/DEFERRED-WORK.md` — mark cold-probe item resolved
- `pnpm -C apps/coder build && sudo systemctl restart boocoder`
---
## 11. Tier 2 follow-ups (document, dont build in v2.3)
| Item | Paseo source | When |
|------|--------------|------|
| WS `provider_snapshot_updated` | `ProviderSnapshotManager` EventEmitter | When loading poll feels hacky |
| MCP `list_providers` / `inspect_provider` | `mcp-server.ts` | When BooCoder MCP orchestration matures |
| Profile overrides (`extends: "claude"`) | `provider-registry.ts` derived providers | When you run Z.AI / multi-endpoint |
| `order` field UI sort | config schema | When catalog >10 entries |
| Per-workspace snapshot in picker | cwd param | Already partial — verify project path passed from CoderPane |
---
## 12. Tier 3 reference — what Paseo has and why we dont port it
This section is **reference only**. These are large subsystems in `/opt/forks/paseo` that solve problems BooCode doesnt have at solo scale, or that BooCode already solved differently.
### 12.1 `ACPAgentClient` base class (~2,800 lines)
**Path:** `packages/server/src/server/agent/providers/acp-agent.ts`
**What it does:** Full ACP lifecycle — spawn, initialize, session/new, streaming, permissions, tool calls, MCP injection, revert, persisted agent import, probe sessions.
**Why Paseo needs it:** Paseo is the primary runtime for dozens of providers; one abstraction reduces duplication across copilot, cursor, generic ACP, etc.
**Why BooCode skips it:** `acp-dispatch.ts` + `acp-stream.ts` + `acp-probe.ts` already cover dispatch and probe as **scripts** (~400 lines total). Replacing with the class hierarchy is a multi-week rewrite with high regression risk on v2.2 dispatch that works on homelab.
**What we take instead:** Patterns only — `isAvailable()` = resolve binary; permission waiter (already shipped); derive models/modes (already shipped).
---
### 12.2 Per-provider client classes (claude, codex, opencode, pi, copilot, cursor…)
**Paths:** `packages/server/src/server/agent/providers/*/agent.ts`, `codex-app-server-agent.ts` (5,000+ lines)
**What they do:** Native SDK/RPC integration — not just CLI spawn. Codex uses app-server RPC; Claude uses Claude Agent SDK; OpenCode manages a sidecar server.
**Why Paseo needs it:** Deep integration — voice, revert, persisted sessions, feature toggles, OAuth diagnostics.
**Why BooCode skips it:** BooCode **delegates** to existing CLIs in worktrees. No embedded SDKs. PTY path for claude/qwen is stdin pipe; ACP path uses `@agentclientprotocol/sdk` at dispatch boundary only.
**Lift risk:** Importing codex-app-server-agent would drag thousands of lines + unknown deps.
---
### 12.3 `ProviderSnapshotManager` class (full port)
**Path:** `packages/server/src/server/agent/provider-snapshot-manager.ts`
**What it does:** Per-cwd Maps, loading states, singleflight, event emitter, reconcile on registry replace, settings vs workspace refresh split.
**Why not full port:** BooCodes `provider-snapshot.ts` is ~250 lines and already has cache + inflight. **Selective lift:** loading status, reconcile on config reload, subset refresh — not a class-for-class rewrite.
---
### 12.4 React Native settings app (`packages/app`)
**Paths:** `providers-section.tsx`, `add-provider-modal.tsx`, `use-providers-snapshot.ts`
**What it does:** Mobile/desktop cross-platform provider UI with Unistyles, native Switch, adaptive sheets.
**Why BooCode skips it:** BooChat is React web + Tailwind. Port **interaction design** (toggle, status dots, add flow), not components.
---
### 12.5 Daemon config system (`patchConfig`, migrations, Zod wire messages)
**Path:** `packages/server/src/shared/messages.ts` (4000+ lines), daemon config patch RPC
**What it does:** Every settings change is a typed WS/HTTP patch to daemon with validation, persistence, broadcast.
**Why BooCode simplifies:** Single-user — PATCH writes JSON file + reloads in-process Map. No multi-client sync requirement. If BooChat and CLI both edit, last-write-wins on file is acceptable.
---
### 12.6 Full ACP catalog (30+ providers, version-pinned npx)
**Path:** `packages/app/src/data/acp-provider-catalog.ts` (~400 lines)
**Why trim:** Maintenance burden — every upstream version bump is a PR in Paseo. Solo homelab: 510 entries you actually install, update when you install.
---
### 12.7 Voice provider stack
**Path:** `packages/server/src/server/speech/*`
**Why skip:** BooCode has no voice surface; unrelated to coder provider lifecycle.
---
### 12.8 Workspace git service inside agents
**Path:** Codex client integration with `WorkspaceGitService`
**Why skip:** BooCode worktrees (`worktrees.ts`) are explicit per-task; agents run in worktree cwd. Different architecture.
---
### 12.9 OpenCode server manager sidecar
**Path:** `packages/server/src/server/agent/providers/opencode/server-manager.ts`
**What it does:** Manages long-lived OpenCode server process.
**Why skip:** BooCode spawns `opencode acp` per dispatch — stateless, simpler, good enough for single user.
---
### 12.10 Pi RPC agent + session import from JSONL
**Paths:** `packages/server/src/server/agent/providers/pi/agent.ts` (1,500+ lines)
**Why skip until needed:** Only lift if you add `pi` as a built-in with import/revert requirements. Otherwise generic ACP + `extends: "acp"` + pi-acp catalog entry suffices.
---
### 12.11 Summary table
| Paseo subsystem | Lines (approx) | BooCode v2.3 approach |
|-----------------|----------------|------------------------|
| ACPAgentClient | 2,800 | Keep acp-dispatch |
| Codex app server agent | 5,500 | Don't import |
| Provider registry merge | 700 | New 200-line module |
| Snapshot manager | 490 | Extend existing snapshot |
| Generic ACP agent | 300 | resolveLaunchSpec only |
| RN providers UI | 400 | Web drawer ~200 lines |
| MCP list_providers | 200 | Defer |
| Config wire protocol | 4,000+ | JSON file PATCH |
**Rule of thumb for solo project:** Lift **data models and lifecycle rules**, not **class hierarchies**.
---
## 13. Risk register
| Risk | Mitigation |
|------|------------|
| Custom npx provider slow cold start | Show loading; subset refresh; dont block picker on whole snapshot |
| Config file edit while coder running | PATCH API primary; manual edit requires restart (document) |
| `enabled: false` but task in flight | Allow running task to finish; block new sends (picker filter) |
| Type drift web/coder | Update both `provider-types.ts` and `api/types.ts`; optional zod parity test |
| Security: arbitrary command in config | Single-user trusted path; same trust as `AGENTS.md` — no app-layer auth |
| Re-enabling cold probe slowness on refresh | Expected; refresh is explicit user action |
---
## 14. File map (new + touched)
| Action | Path |
|--------|------|
| **New** | `apps/coder/src/services/provider-config.ts` |
| **New** | `apps/coder/src/services/provider-config-registry.ts` |
| **New** | `apps/coder/src/services/command-availability.ts` |
| **New** | `apps/coder/src/services/provider-diagnostic.ts` |
| **New** | `apps/coder/src/services/__tests__/provider-config-registry.test.ts` |
| **New** | `data/coder-providers.json` |
| **New** | `apps/web/src/data/acp-provider-catalog.ts` |
| **New** | `apps/web/src/components/coder/ProviderSettingsDrawer.tsx` |
| **New** | `apps/web/src/components/coder/AddProviderModal.tsx` |
| **Edit** | `apps/coder/src/services/provider-snapshot.ts` |
| **Edit** | `apps/coder/src/services/agent-probe.ts` |
| **Edit** | `apps/coder/src/services/acp-spawn.ts` |
| **Edit** | `apps/coder/src/services/acp-dispatch.ts` |
| **Edit** | `apps/coder/src/services/dispatcher.ts` |
| **Edit** | `apps/coder/src/routes/providers.ts` |
| **Edit** | `apps/coder/src/config.ts``CODER_PROVIDERS_PATH` |
| **Edit** | `apps/coder/.env.host` |
| **Edit** | `apps/coder/src/services/provider-types.ts` |
| **Edit** | `apps/web/src/api/types.ts` |
| **Edit** | `apps/web/src/api/client.ts` |
| **Edit** | `apps/web/src/components/AgentComposerBar.tsx` |
| **Edit** | `BOOCODER.md` |
| **Edit** | `docs/DEFERRED-WORK.md` |
---
## 15. Attribution
Design patterns from [Paseo](https://github.com/getpaseo/paseo) (`/opt/forks/paseo`), especially:
- `provider-registry.ts` — merge built-ins + config + `enabled`
- `provider-snapshot-manager.ts` — loading/unavailable/ready lifecycle
- `provider-launch-config.ts` — override schema
- `providers-section.tsx` — settings UX
- `public-docs/custom-providers.md` — config file semantics
BooCode implementation remains original code — no copy-paste of Paseo sources required; licensing treated as irrelevant per project owner directive.

View File

@@ -0,0 +1,63 @@
# v2.3 Provider lifecycle (Paseo-style registry)
**Status:****Shipped** across `v2.5.4``v2.5.13` (2026-05-29; reconciled 2026-05-31) — all 6 phases live; only the 3 optional Tier-2 items deferred
**Depends on:** v2.2 Paseo providers (snapshot, modes, commands, ACP dispatch)
**Reference fork:** `/opt/forks/paseo`
**Related deferred work:** [`docs/DEFERRED-WORK.md`](../../../docs/DEFERRED-WORK.md) §2 (cold-probe skip)
> **Shipped mapping (reconciled 2026-05-31):** Phase 1 → `v2.5.4`, Phase 2 → `v2.5.5`, Phase 3 → `v2.5.6`, Phase 4 → `v2.5.12`, Phase 5 → `v2.5.13`, Phase 6 docs → `v2.5.13`/`v2.5.14`. **Deferred (tasks O.1O.3):** WS `provider_snapshot_updated` frame, `available_agents.enabled` column, diagnostic row-click modal — tracked in `docs/DEFERRED-WORK.md`. (Cursor was retired in `v2.5.3`, so the success-criterion mention below is historical.)
## Why
BooCode v2.2 copied Paseos **snapshot wire shape** (modes, thinking, commands) but not Paseos **provider lifecycle**:
- Providers are hardcoded in `provider-registry.ts`; adding one requires a code change and redeploy.
- Uninstalled agents **disappear** from the picker instead of showing “not installed.”
- There is no **enable/disable** toggle — every probed binary appears.
- Every snapshot cache miss runs a **full cold ACP probe** for all installed agents (530s).
Paseos model (see `/opt/forks/paseo/public-docs/providers.md`) treats providers as **registered entries** in a config-backed registry, then probes the machine for readiness, then lets the user toggle visibility. That fits a one-person homelab: edit JSON, refresh, flip a switch — no TypeScript deploy for each new ACP CLI.
## Scope
### In scope
1. **Config file** `/data/coder-providers.json` — add/disable/custom ACP providers without code changes
2. **Merged registry** — built-ins + config overrides at runtime
3. **Snapshot lifecycle**`loading` | `ready` | `unavailable` | `error`; always list registered providers; `enabled` flag
4. **Two-tier probe** — fast binary check vs slow ACP session (DB `last_probed_at` gate)
5. **Generic ACP dispatch** — config entries spawn via `{ command, env }` without new `acp-spawn` cases
6. **HTTP API** — read/patch config, per-provider refresh, optional diagnostic
7. **Web UI** — settings drawer: provider list, enable toggle, refresh, add-from-catalog (curated ~510 entries)
8. **Tests + docs** — snapshot unit tests, `BOOCODER.md` refresh contract
### Out of scope (this batch)
- Full Paseo ACP catalog (30+ agents) — curate a small local catalog only
- React Native settings app port
- Replacing `acp-dispatch.ts` with Paseos `ACPAgentClient` hierarchy
- Voice provider stack
- MCP `list_providers` / `inspect_provider` tools (Tier 2 follow-up)
- WS push of snapshot updates (Tier 2 follow-up)
## Non-goals
- Multi-user provider prefs (single-user homelab)
- Installing CLIs from the UI (link to install instructions only, like Paseo)
- Removing `available_agents` table — keep it as probe cache, extend with `enabled` or mirror config
## Success criteria
- ✅ Add `amp-acp` via catalog → appears in picker after refresh without coder redeploy *(catalog smoke-test entry; per `boocode_code_review_v2.md` §5m, Amp itself is paid-cloud, not a usable local provider)*
- ✅ Disable goose in settings → gone from picker, still visible as “Disabled” in settings
- ✅ opencode not on PATH → shows “Not installed” in settings, hidden from picker
- ✅ Second snapshot open within warm window completes in &lt;500ms (no ACP spawns)
-`POST /api/providers/refresh` still runs full cold probe
- ✅ Existing v2.2 dispatch unchanged for built-ins *(opencode, claude, qwen, goose — cursor + copilot retired `v2.5.3`)*
## Deliverables
| Doc | Purpose |
|-----|---------|
| [`design.md`](./design.md) | Full architecture, schemas, file map, Tier 3 reference |
| [`tasks.md`](./tasks.md) | Numbered implementation checklist |

View File

@@ -0,0 +1,73 @@
# v2.3 Provider lifecycle — tasks
Implement in phase order from [`design.md`](./design.md). Do not commit unless Sam asks.
> **✅ SHIPPED across `v2.5.4``v2.5.13` (reconciled 2026-05-31).** All 6 phases done; the 3 Optional items (O.1O.3) deferred (tracked in `docs/DEFERRED-WORK.md`). Verified in tree: `provider-config.ts`, `provider-config-registry.ts`, `command-availability.ts`, `provider-diagnostic.ts`, `acp-provider-catalog.ts`, `components/coder/AddProviderModal.tsx`, Settings→Providers tab.
## Phase 1 — Config + registry — ✅ `v2.5.4-provider-lifecycle-phase1`
- [x] 1.1 Add `CODER_PROVIDERS_PATH` to `apps/coder/src/config.ts` (default `/data/coder-providers.json`)
- [x] 1.2 Add `data/coder-providers.json` example + wire in `apps/coder/.env.host`
- [x] 1.3 Implement `provider-config.ts` (Zod schema + load/merge/save)
- [x] 1.4 Implement `provider-config-registry.ts` (`buildResolvedRegistry`, module singleton + reload)
- [x] 1.5 Unit tests: built-in override, custom ACP add, enabled false, invalid entry skipped
- [x] 1.6 Update `agent-probe.ts` to iterate resolved registry (include custom ids, respect enabled)
## Phase 2 — Snapshot lifecycle — ✅ `v2.5.5-provider-lifecycle-phase2`
- [x] 2.1 Extend `ProviderSnapshotEntry` / status union in coder + web types (`loading`, `unavailable`, `enabled`)
- [x] 2.2 Add `command-availability.ts` (`isCommandAvailable`)
- [x] 2.3 Rewrite `buildProviderEntry`: never return null; handle disabled/uninstalled/loading
- [x] 2.4 Implement tier-2 skip using `available_agents.last_probed_at` + `PROVIDER_PROBE_TTL_MS`
- [x] 2.5 Return `loading` entries synchronously on cache miss; complete via inflight promise *(client-side poll deferred to Phase 5; cache miss returns `loading` then settles)*
- [x] 2.6 Extend `provider-snapshot.test.ts` for disabled, uninstalled, fresh DB skip, force refresh
- [x] 2.7 Verify warm cache: second snapshot call does not invoke `probeAcpProvider` (mock assert)
## Phase 3 — Generic dispatch — ✅ `v2.5.6-provider-lifecycle-phase3`
- [x] 3.1 Add `resolveLaunchSpec()` to `acp-spawn.ts`
- [x] 3.2 Wire `acp-dispatch.ts` to use launch spec + env merge
- [x] 3.3 Wire `dispatcher.ts` to load resolved def by agent name
- [x] 3.4 Unit test: custom command argv reaches spawn (built-in dispatch byte-identical)
- [x] 3.5 Smoke: task dispatch for one custom catalog provider (if installed on host)
## Phase 4 — HTTP API — ✅ `v2.5.12-provider-lifecycle-phase4`
- [x] 4.1 `GET /api/providers/config`
- [x] 4.2 `PATCH /api/providers/config` (merge + write file + reload registry + clear snapshot cache)
- [x] 4.3 `POST /api/providers/refresh` optional body `{ providers?: string[] }`
- [x] 4.4 `GET /api/providers/:id/diagnostic` *(ships as JSON `{ diagnostic: string }`, not plaintext — see design §8 delta)*
- [x] 4.5 Extend `apps/web/src/api/client.ts` coder namespace
- [x] 4.6 Confirm BooChat proxy forwards new routes (blanket `/api/coder/*` forward)
## Phase 5 — Web UI — ✅ `v2.5.13-provider-lifecycle-phase5`
- [x] 5.1 Create `apps/web/src/data/acp-provider-catalog.ts` (510 curated entries)
- [x] 5.2 `AddProviderModal.tsx` — search, install → patch + refresh subset *(at `components/coder/`)*
- [x] 5.3 Provider management UI *(shipped as a **Settings → Providers tab** in `SettingsPane.tsx`, not a standalone `ProviderSettingsDrawer` — design §7.1 "or section under existing settings")*
- [x] 5.4 Entry point from CoderPane / AgentComposerBar (gear or settings link)
- [x] 5.5 Filter `AgentComposerBar` selectable providers (`enabled && ready|loading`)
- [x] 5.6 Loading state while snapshot entries `loading`
- [x] 5.7 `npx tsc -p apps/web/tsconfig.app.json --noEmit`
## Phase 6 — Docs, deploy, closeout — ✅ `v2.5.13` / docs `v2.5.14`
- [x] 6.1 `BOOCODER.md` — config file, refresh contract, enable/disable
- [x] 6.2 Update `docs/DEFERRED-WORK.md` — tier-2 cold-probe item marked addressed
- [x] 6.3 `CHANGELOG.md` entries (per-phase tags, not a single tag)
- [x] 6.4 `pnpm -C apps/coder test && pnpm -C apps/coder build`
- [x] 6.5 `sudo systemctl restart boocoder`
- [x] 6.6 Smoke via Tailscale (snapshot / disable goose / refresh / add-catalog)
## Optional — ⬜ DEFERRED (tracked in `docs/DEFERRED-WORK.md`)
- [ ] O.1 WS frame `provider_snapshot_updated` (skip polling) — **deferred**; `AgentComposerBar:219` polls instead (comment notes the absence)
- [ ] O.2 `available_agents.enabled` column mirror — **deferred**; `enabled` read from config memory only (no DB column)
- [ ] O.3 Diagnostic sheet UI (row click → modal) — **deferred**; the plaintext/JSON diagnostic API + Settings surface shipped, the modal polish did not
## Explicitly out of scope
- Port Paseo `ACPAgentClient` / per-provider SDK clients (see design §12)
- Full 30+ ACP catalog
- MCP `list_providers` tools
- Voice providers

View File

@@ -0,0 +1,298 @@
# v2.6 Design — Persistent agent sessions
Reference implementations: `/opt/forks/opencode` (server + SDK),
`/opt/forks/paseo` (warm ACP + opencode server-manager + reasoning dedup).
> **⚠️ Reconciled 2026-05-31 — read the proposal's Reconciliation note first.** §2a and §3 describe the *original* design; four details were revised during implementation (per-session SSE; `(chat_id, agent)` key + `worktrees` table; `session.next.*` events; password deferred) — flagged inline. **Phases 23 and the Phase-1 UX (§2b, §6, §9) are not yet built**; updated lift sources for them are in new **§10**.
## 1. Architecture overview
```
BooCoder (systemd host service)
┌─────────────────────────────────────────────────────────────────┐
│ dispatcher (per-turn unit = tasks row) │
│ │ resolve backend + worktree + agent-session for the chat │
│ ▼ │
│ agent-pool ──────────────────────────────────────────────────┐ │
│ ├─ OpenCodeServerBackend (1 process, N sessions) │ │
│ │ `opencode serve` ◄── @opencode-ai/sdk ──► /event SSE │ │
│ └─ WarmAcpBackend[session] (1 stdio process per session) │ │
│ `goose acp` / `qwen --acp` ◄── ClientSideConnection │ │
└──────────────────────────────────────────────────────────────┘ │
│ broker.publishFrame (delta / reasoning_delta / tool_call) │
▼ │
web (CoderPane) — unchanged │
```
The **task row stays the per-turn unit**. What changes: instead of building a
fresh world per task, the dispatcher resolves the chat's *persistent* backend,
worktree, and agent-session, sends one prompt, streams events, diffs, and leaves
everything warm.
## 2. Backends
Common interface (`AgentBackend`):
```
interface AgentBackend {
ensureSession(sessionId, opts): Promise<AgentSessionHandle> // create-or-reuse
prompt(handle, input, { worktreePath, model, signal, onEvent }): Promise<TurnResult>
closeSession(handle): Promise<void>
dispose(): Promise<void> // backend teardown
health(): 'up' | 'down'
}
```
`onEvent` emits the same normalized events the current `acp-dispatch.ts` produces
(`text`, `reasoning`, `tool_call`, `tool_update`) so the broker-frame publishing and
`persistExternalAgentTurn` paths are reused unchanged.
### 2a. OpenCodeServerBackend (shared HTTP server)
> **⚠️ Shipped deltas vs the bullets below:** (a) **per-session SSE** — one `event.subscribe({directory})` per live opencode session (P1.5-a, `v2.6.2`), NOT one global `/event` loop; (b) events are **`session.next.*`** (`text.delta`/`reasoning.delta`/`tool.{called,success,failed}`), NOT `message.part.*`; (c) **`OPENCODE_SERVER_PASSWORD` deferred** — server binds loopback unsecured.
- **Spawn once per BooCoder process:** `opencode serve --hostname 127.0.0.1 --port <p>`
with `OPENCODE_SERVER_PASSWORD=<random-at-boot>` (verified: `serve.ts`, `network.ts`;
default port 4096, prints `opencode server listening on http://…`). Use the official
`@opencode-ai/sdk` (`createOpencodeServer` / `createOpencodeClient`) rather than
hand-rolling HTTP — it already parses the ready line and wraps routes.
- **One SSE subscription** to `GET /event`, consumed in a single read loop; events
demuxed by `properties.sessionID` → BooCode session. Reasoning arrives as
`message.part.delta` (`field: "reasoning"`) and `message.part.updated`
(`part.type: "reasoning"`); text as the `text` field; tool calls as tool parts.
- **One opencode session per BooCode chat.** `client.session.create()` once, store the
returned `id` in `agent_sessions.agent_session_id`. Per-turn: `client.session.prompt({
path:{id}, body:{ parts:[{type:'text',text}], model:"provider/model" }})`. Worktree
routing via the `x-opencode-directory` header (set to the session's persistent
worktree) so the agent operates inside it.
- **Reasoning dedup (port from Paseo `opencode-agent.ts`):** track
`streamedPartKeys` of `reasoning:${partID}`; when a `message.part.updated` reasoning
part arrives whose key was already streamed via delta, drop it. Prevents the
double-thought bug (covered by Paseo's `opencode-reasoning-dedup` e2e test).
### 2b. WarmAcpBackend (goose, qwen — stdio)
- **One persistent process + ACP connection per (chat, agent)** (Paseo's
`SpawnedACPProcess`): spawn `goose acp` / `qwen --acp` once, NDJSON over stdio,
`initialize``session/new` once; store the ACP session id in the
`agent_sessions` row. Each turn calls `session/prompt` on the same connection;
switching away and back resumes this same connection/session. Reuses the existing `acp-dispatch.ts`
`handleSessionUpdate` switch verbatim for `agent_message_chunk` /
`agent_thought_chunk` / `tool_call*`.
- **Child lifetime is the pool's, not a request's.** Spawn detached/managed; do not
tie the process to a single dispatch's abort signal (only the in-flight `prompt`
gets the per-turn signal). Mirrors the codecontext shim rule (CLAUDE.md): supervise
the child and react to its exit, don't let a request scope kill it.
## 3. Data model
> **⚠️ Shipped (P1.5-b, `v2.6.3``v2.6.4`):** `agent_sessions` is keyed **`(chat_id, agent)`** (the tab/chat is the agent-context unit; `chat_id` CASCADEs from `chats`), and a first-class **`worktrees`** table (one-per-session, survives session delete via `session_id` `SET NULL`) replaced `session_worktrees`. `tasks.chat_id` threads the tab id to the dispatcher. The SQL below is the original `(session_id, agent)` / `session_worktrees` shape — see `apps/coder/src/schema.sql` for the live DDL.
Agent switching is **free** within a chat (the picker is per-turn, not locked), so
the worktree is shared across agents but each agent keeps its own backend session.
That splits into two tables: one **shared worktree per chat**, and one **backend
session per (chat, agent)** pair.
```sql
-- One shared worktree per BooCode chat. All agents used in the chat operate in it.
CREATE TABLE IF NOT EXISTS session_worktrees (
session_id UUID PRIMARY KEY REFERENCES sessions(id),
worktree_path TEXT NOT NULL,
base_commit TEXT, -- project HEAD captured at create (diff baseline)
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
-- One backend session per (chat, agent). Resumed when the user switches back to
-- that agent, so each agent retains its own conversation memory across switches.
CREATE TABLE IF NOT EXISTS agent_sessions (
session_id UUID NOT NULL REFERENCES sessions(id),
agent TEXT NOT NULL, -- opencode | goose | qwen (native boocode needs no row)
backend TEXT NOT NULL, -- opencode_server | acp_warm
agent_session_id TEXT, -- opencode/ACP native session id (the memory handle)
server_port INTEGER, -- opencode server port (nullable)
status TEXT NOT NULL DEFAULT 'idle', -- idle | active | crashed | closed
last_active_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
PRIMARY KEY (session_id, agent),
CONSTRAINT agent_sessions_backend_chk CHECK (backend IN ('opencode_server','acp_warm')),
CONSTRAINT agent_sessions_status_chk CHECK (status IN ('idle','active','crashed','closed'))
);
```
Plus one column for attribution (drives the DiffPanel badges in §9):
```sql
-- Which agent staged each pending change. Stamped at queue time:
-- worktree-diff path → the task's agent; native boocode write tools → 'boocode';
-- manual RightRail create (v2.5.x) → NULL (renders as "manual").
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;
```
`tasks.worktree_path` already exists but was per-task; the persistent worktree now
lives on `session_worktrees`. `tasks` stays the per-turn record (state machine
unchanged) and gains nothing required. **Native boocode** keeps no `agent_sessions`
row — it has no warm backend; it reconstructs conversation context from the chat's
`messages` rows each turn (so it transparently sees every other agent's prior turns).
DB is the source of truth for reconnect after a BooCoder restart (the in-memory pool
rebuilds lazily from these tables on the next turn).
## 3a. Agent switching & continuity (the decided model)
Per the design review: **free switch, per-agent memory.** Concretely:
- **Picker is per-turn.** The message route already sends `provider`/`model` per
message; nothing locks a chat to one agent. v2.6 keeps that.
- **Worktree is shared.** All agents in a chat resolve the same `session_worktrees`
row, so file state carries across switches — *once applied*. (See the staging
boundary caveat below.)
- **Each agent resumes its own session.** Switching opencode → boocode → opencode
reuses opencode's stored `agent_session_id` (its memory intact), not a fresh one.
Lazy-create on first use of an agent in the chat; resume thereafter.
- **Native boocode is the universal reader.** It rebuilds from the `messages` table,
so it always sees the full transcript including other agents' turns.
- **Gap turns are NOT auto-replayed** into a resumed agent. When you return to
opencode, it sees the shared worktree + your new prompt, but did not "hear" the
boocode/goose turns in between. (A future refinement could inject a short
"changes since you last ran" preamble; out of scope for v2.6.)
- **Staging-boundary caveat (must be documented in the UI):** external agents edit
*inside the worktree*; native boocode reads/writes the *project root* via
`pending_changes`. So unapplied edits do **not** cross between a worktree agent and
native boocode — file continuity between the two only exists after apply. This is
an inherent consequence of v2.5's review-before-apply model, not a v2.6 bug.
- **No mid-turn switch.** Per-chat turns are serialized (§5); the agent is fixed for
the duration of an in-flight turn. The user can switch the picker for the *next*
turn while one is running, but it won't retarget the running turn.
## 4. Persistent worktree + incremental diff
- **Create** on the first turn of a chat (`createWorktree(projectPath, sessionId)`
— keyed by chat, not task), capturing project HEAD as `base_commit`. Persist the
`session_worktrees` row; all agents in the chat share it.
- **Reuse** every subsequent turn — no new worktree, no cleanup between turns.
- **Diff strategy (per turn):** diff the worktree against the **project HEAD baseline**
captured when the worktree was created. Each turn supersedes the prior
`pending_changes` row for that session (one accumulating unified diff, latest wins) —
mirrors how the anchored rolling summary supersedes itself. Avoids stacking N partial
diffs the user must reason about; the pending change always reflects the full current
delta of the worktree.
- **Apply** merges the worktree delta back to the project (existing `apply_pending`
path); after apply, re-baseline so the next turn's diff is relative to applied state.
- **Cleanup** on chat close/archive (new hook) and on `dispose()`; removes the
`session_worktrees` row + all `agent_sessions` rows for the chat. Orphan reaper
sweeps worktrees with no live `session_worktrees` row (extends the periodic sweeper).
## 5. Concurrency
Current dispatcher: global `running` boolean → strictly one task at a time.
Target: **per-session serialization, cross-session concurrency.**
- Replace the single `running` flag with a `Map<sessionId, Promise>` in-flight registry.
- `poll()` selects the oldest pending task whose **session has no in-flight turn**, so
two different chats run concurrently but a chat never has two turns at once (the agent
holds conversational state — overlapping prompts would corrupt it).
- The LISTEN/NOTIFY `tasks_new` fast path (v2.5.x) already triggers immediate polls;
the registry replaces the boolean guard there too.
## 6. Lifecycle & failure
- **Lazy spawn:** backend/worktree/agent-session created on first turn for a session.
- **Idle eviction:** pool evicts a backend/session after an idle TTL (e.g. 30 min);
worktree persists (DB-backed); next turn re-spawns and reattaches via stored
`agent_session_id` (opencode persists sessions on disk; ACP re-`session/new` if the
native id is gone).
- **Crash recovery:** supervise children; on exit mark `agent_sessions.status='crashed'`,
publish `chat_status='error'`, and rebuild on the next turn. opencode server crash
takes all opencode sessions down → restart server, recreate sessions.
- **Shutdown drain:** `app.addHook('onClose')` disposes the pool (close opencode server,
kill warm ACP children) after in-flight turns settle — extends the existing
dispatcher `stop()`.
- **systemd:** BooCoder already spawns agent children under `NoNewPrivileges`; long-lived
pool children are fine. Use `context.Background`-equivalent detachment so children
outlive the dispatch that created them.
## 7. Risks / open questions
- **opencode single-server blast radius:** one crash drops all opencode sessions. Mitigated
by on-disk session persistence + lazy re-create. Could later shard one server per project
if it bites.
- **Worktree disk growth:** persistent worktrees per session accumulate; the close-hook +
orphan reaper must be reliable or disk leaks. Add a max-live-worktrees cap with LRU evict.
- **SDK version coupling:** `@opencode-ai/sdk` is a new workspace dep pinned to the installed
opencode (1.15.x). Probe-time version check should warn on major drift.
- **Incremental-diff baseline correctness:** re-baselining after apply must handle the user
editing the project out-of-band; diff vs a stored base commit, not vs a moving target.
- **Reconnect fidelity:** after BooCoder restart, reattaching to a stored opencode session id
assumes the server (also restarted) still has it on disk — verify the SDK reattach path.
- **Cross-agent staging gap:** worktree agents and native boocode don't see each other's
*unapplied* edits (worktree vs project root). The UI must make this legible (e.g. show
which agent staged a pending change) so a switch doesn't look like lost work. A resumed
agent also won't have heard other agents' in-between turns — acceptable per the decided
model, but worth a small "N turns by other agents since you last ran" hint later.
- **Per-(chat,agent) session sprawl:** a chat that cycles through many agents accumulates
warm backends/worktree co-tenants; idle eviction (§6) must key on (chat,agent), and the
opencode server's session count is bounded by eviction, not per-chat.
## 8. File map (anticipated)
| File | Change |
|------|--------|
| `apps/coder/src/services/agent-pool.ts` | NEW — pool + backend interface |
| `apps/coder/src/services/backends/opencode-server.ts` | NEW — SDK + SSE demux + dedup |
| `apps/coder/src/services/backends/warm-acp.ts` | NEW — persistent ACP connection |
| `apps/coder/src/services/dispatcher.ts` | per-chat concurrency; resolve-or-create shared worktree + per-(chat,agent) backend session; no per-turn teardown |
| `apps/coder/src/services/worktrees.ts` | chat-keyed create; baseline capture; re-baseline-on-apply |
| `apps/coder/src/services/agent-turn-persist.ts` | reused as-is |
| `apps/coder/src/schema.sql` | `session_worktrees` + `agent_sessions` (per (chat,agent)) + `pending_changes.agent` column |
| `apps/coder/src/routes/sessions|tasks` | chat-close cleanup hook |
| `apps/coder/src/routes/pending.ts` | `agent` on `listPending` response; stamp `agent` in queue paths |
| `apps/coder/src/routes/agent-sessions.ts` | NEW — `GET /api/sessions/:id/agent-sessions` (§9b) |
| `apps/coder/package.json` | add `@opencode-ai/sdk` dep |
| `apps/web/src/components/panes/CoderPane.tsx` | `PendingChange.agent`; DiffPanel badges + staging hint; pass `sessionId` to composer |
| `apps/web/src/components/AgentComposerBar.tsx` | optional `sessionId` prop; resumed/new chip; export `providerIcon` |
| `apps/web/src/hooks/useAgentSessions.ts` | NEW — chat-scoped agent-session fetch |
| `apps/web/src/api/client.ts` | `api.coder.agentSessions(sessionId)` |
## 9. Frontend UX — agent attribution & switch affordances
The switching model (§3a) is only good if it's **legible**: the user must see which
agent did what, and whether switching back resumes or starts fresh. Pure read+display
over the new `agent` column and `agent_sessions` — no dispatch-logic change.
### 9a. Per-change agent attribution (DiffPanel) — Phase 1
- **Wire:** `listPending` returns the row; add `agent` to the response and to the
frontend `PendingChange` type (`CoderPane.tsx`, today `{id, file_path, operation, diff?, status}`).
- **UI:** each DiffPanel row gains a small agent badge before the file path — reuse the
`providerIcon()` switch from `AgentComposerBar` (extract to a shared helper / the new
`icons/ProviderIcons` module) + the provider label; `agent === null` → a neutral
"manual" chip. When the pending set spans >1 distinct agent, a one-line header note
("Changes from opencode, boocode") makes mixed provenance obvious.
### 9b. "Resumed" vs "new session" indicator (AgentComposerBar) — Phase 1
- **API:** `GET /api/sessions/:id/agent-sessions``[{ agent, status, has_session, last_active_at }]`
(reads `agent_sessions` for the chat). Chat-scoped, so it is NOT foldable into the
project-level provider snapshot.
- **Hook:** `useAgentSessions(sessionId)` — fetch on mount, refetch on `message_complete`
(same trigger `usePendingChanges` already uses).
- **UI:** a subtle chip right of the Provider picker:
- current provider has a live row → muted **"resumed"** (title: "Resuming <agent> · last active <relative>").
- native boocode (never has a row) → **"history"** (it reconstructs from the transcript).
- otherwise → **"new session"**.
- Render only when connected and the chat has ≥1 prior turn; hidden on a fresh chat.
- `AgentComposerBar` gains an optional `sessionId?: string` prop (CoderPane has it);
absent → render nothing, so BooChat and other callers are unaffected.
### 9c. Staging-boundary hint (DiffPanel) — Phase 3 polish
- When the selected provider is **native boocode** and pending changes were staged by a
**worktree agent** (or vice-versa), show a one-line muted caveat:
"opencode's edits live in its worktree — boocode won't see them until applied."
Derived purely from per-change `agent` + current `value.provider`; no new state.
Keeps the §3a staging caveat from biting silently.
## 10. Lift sources for the remaining phases (added 2026-05-31)
From the second external review (`boocode_code_review_v2.md`). These supersede/augment §2b, §6, §9 for the unbuilt work:
- **Phase 2 (warm ACP, goose/qwen) — `qwen --acp` is a validated reference.** qwen-code ships a real stdio multi-session ACP agent (`Map<sessionID,Session>`, `loadSession`/`unstable_resumeSession`, mid-session model/mode switch), so `warm-acp.ts` (§2b) wires qwen into the existing `acp-dispatch.ts` stack as planned. **Caveat:** goose ACP exposes **no `loadSession`/resume** → its cross-restart resume needs a different design than opencode's (re-`session/new` + accept memory loss, or replay). Cross-check qwen's `@agentclientprotocol/sdk@^0.14` vs BooCode's `^0.22` handshake before relying on `unstable_resumeSession`. (`boocode_code_review_v2.md` §5f, §5n.)
- **Phase 3 (lifecycle hardening) — lift from `openchamber` (MIT, same warm-opencode-server architecture), not Paseo.** Health-monitor + crash auto-restart + busy-aware restart (skip-while-busy + stale-grace) + port reclaim (`killProcessOnPort`/`waitForPortRelease`) + stall-detecting SSE reader — a concrete state machine for §6's "supervise children / rebuild on next turn" sketch. Worktree reaper: Paseo's worktree-archive cascade (soft-delete + `Promise.allSettled` fan-out) + superset's destroy-saga (preflight dirty/unpushed inspect + ordered failure semantics). Bound the warm server's per-session Maps (LRU) — long-lived-daemon leak class. (`boocode_code_review_v2.md` §5c, §5b, §5j.)
- **Fix-next (Phase 1/2) — the post-interrupt stale-terminal bug (confirmed live).** `opencode-server.ts:~307` settles any `session.idle` onto whatever `activeTurn` holds the session slot, with **no turn-identity guard** → after abort + new prompt, a stale `session.idle` from the cancelled turn settles the *new* turn early as success. Paseo fix `1d38aac` (suppress-terminal-until-next-user-message). **Now one-click reachable** since `v2.6.5` shipped the Send→Stop composer. (`boocode_code_review_v2.md` §1 #6, §3.)
- **Phase 1 UX (§9) — opencode already streams token/ctx usage.** `session.next.step.ended` carries `{tokens, cost}` on the wire (SDK already installed) → consume it to fill ctx/token usage for opencode sessions, closing the "no usage for external agents" gap; surfaces beside the §9b chip. (`boocode_code_review_v2.md` §1 #8, §3.)

View File

@@ -0,0 +1,122 @@
# v2.6 Persistent agent sessions (warm processes + OpenCode server)
**Status:** Phase 0 + Phase 1 + P1.5-a/b **shipped** (`v2.6.0``v2.6.4`); Phase 1-UX, Phase 2, Phase 3, and unit tests **remaining.** (Reconciled 2026-05-31.)
**Depends on:** v2.2 Paseo providers (ACP dispatch), v2.3 provider lifecycle (registry/snapshot)
**Reference fork:** `/opt/forks/paseo`, `/opt/forks/opencode`; **remaining-phase lift sources in `boocode_code_review_v2.md`** (openchamber → Phase 3, qwen-code → Phase 2).
**Pairs with:** the v2.5.x MessageBubble "Thinking" render fix — reasoning already flows; this batch is about persistence, not capability.
> **Reconciliation note (2026-05-31).** Four design details below were revised *during* implementation; the original prose/SQL is now superseded:
> 1. **Per-session SSE** — one `event.subscribe({directory})` per live opencode session (P1.5-a, `v2.6.2`) replaced the single global `/event` read loop (design §2a).
> 2. **`agent_sessions` is keyed `(chat_id, agent)`**, and a first-class **`worktrees`** table replaced `session_worktrees` (P1.5-b, `v2.6.3`); `session_id`/`worktree_id` are informational `SET NULL` (`v2.6.4`). The design §3 SQL is the *original* shape.
> 3. **opencode streams `session.next.*` events**, not `message.part.*` (design §2a's event names were wrong).
> 4. **`OPENCODE_SERVER_PASSWORD` was deferred** — the warm server binds loopback unsecured (design §2a specified a random password). Basic-auth scheme since confirmed (openchamber, `boocode_code_review_v2.md` §5c) if ever wanted.
## Why
BooCode dispatches external agents (opencode, goose, qwen) **one-shot per task**:
per task the dispatcher cuts a fresh worktree (`createWorktree(projectPath, taskId)`),
spawns `opencode acp` / `goose acp` / `qwen --acp`, runs **one** turn, then tears
down the process *and* the worktree (`dispatcher.ts:runExternalAgent`). Consequences:
- **No session continuity.** A follow-up message in the same chat creates a new
task with a new worktree and a new agent process. The agent has no memory of
the prior turn beyond what BooCode replays as chat history, and it cannot see
the files it edited last turn (fresh worktree every time).
- **Cold start every turn.** Each turn pays the process spawn + ACP `initialize`
handshake (and, for some agents, model load) before any work happens.
- **Diverges from Paseo.** Paseo runs **OpenCode as a long-lived HTTP server**
(`opencode serve` + `@opencode-ai/sdk`, SSE `/event` stream) and keeps **goose /
qwen as warm stdio-ACP processes** (`SpawnedACPProcess`: one ACP connection,
`newSession()` once, many `prompt()`s). BooCode rebuilds the world per turn.
This batch makes a BooCode chat map to a **persistent agent backend + a persistent
worktree** that live for the whole conversation, so turns are warm and the agent
sees its own accumulating edits. Reasoning passthrough is **already solved** (ACP
`agent_thought_chunk``reasoning_delta` → the new MessageBubble Thinking block);
this batch does not touch it beyond porting OpenCode's reasoning-dedup.
## Decisions locked (from design review)
- **Worktree model:** *Persistent worktree per session.* A chat owns one worktree
for the whole conversation; each turn the agent sees prior edits; pending_changes
accumulate; worktree is cleaned on session close, not per turn.
- **Agent switching:** *Free switch, per-agent memory.* The picker stays per-turn
(not locked to a chat). The worktree is shared across agents; each agent keeps its
own backend session, resumed when you switch back to it. Native boocode reconstructs
from chat history (so it sees every agent's turns); a resumed agent does not auto-
ingest the gap turns. Data model: one shared worktree per chat + one backend session
per `(chat, agent)` pair. Caveat: unapplied edits don't cross the worktree↔project
boundary between external agents and native boocode (a v2.5 review-model consequence).
- **Transport per agent (matches Paseo exactly):**
- **OpenCode** → one shared `opencode serve` HTTP server, driven via
`@opencode-ai/sdk`; one opencode *session* per BooCode chat (multi-session,
directory-routed via `x-opencode-directory`).
- **Goose / Qwen** → warm **stdio** ACP process per live session. Their HTTP
"server" modes are just ACP-over-HTTP wrappers (goose: undocumented/internal;
qwen `serve`: an HTTP bridge around a single `qwen --acp` child) — no gain over
stdio, so we keep stdio ACP like Paseo does.
## Scope
### In scope
1. **Agent process pool** (`apps/coder/src/services/agent-pool.ts`) — owns long-lived
backends, lazy spawn, idle eviction, crash restart, shutdown drain.
2. **OpenCode server backend** — spawn `opencode serve`, hold SDK client + single
SSE subscription demuxed by opencode `sessionID` → BooCode session; port +
`OPENCODE_SERVER_PASSWORD` managed at boot.
3. **Warm ACP backend** — persistent `SpawnedACPProcess`-style connection for
goose/qwen reused across turns (one `newSession()`, many prompts).
4. **Persistent worktree lifecycle** — worktree created on first turn of a session,
reused, diffed incrementally into `pending_changes`, cleaned on session close.
5. **Session ↔ backend ↔ worktree mapping** — new `agent_sessions` table.
6. **Per-session concurrency** — replace the dispatcher's global single-flight
`running` guard with per-session serialization (different sessions run
concurrently; one turn at a time within a session).
7. **OpenCode reasoning dedup** — port Paseo's `streamedPartKeys` partID dedup so
reasoning isn't double-emitted (delta + final part).
8. **Switch-aware UI** (design §9) — per-change agent attribution in the DiffPanel
(`pending_changes.agent` column + badges), a resumed/new-session chip on the
AgentComposerBar (chat-scoped `agent-sessions` endpoint), and a staging-boundary
hint so the worktree↔project gap is legible.
9. **Tests + smoke** — pool lifecycle unit tests; multi-turn opencode smoke; switch
round-trip smoke; attribution/indicator smoke.
### Out of scope (this batch)
- Claude PTY→structured transport (separate deferred work — claude stays PTY here).
- Goose/qwen HTTP server modes (intentionally not used).
- Frontend redesign — existing CoderPane multi-turn chat UI already supports
follow-ups; only backend continuity changes.
- Replacing `acp-dispatch.ts` wholesale — warm backend reuses its event handlers.
- Cross-host agent servers (opencode server stays local to the BooCoder host).
## Non-goals
- Multi-user session sharing (single-user homelab).
- Multiple concurrent turns within one agent session (the agent holds conversational
state; turns within a session are serialized).
## Success criteria
(Status reconciled 2026-05-31: ✅ met · 🟡 partial · ⬜ remaining)
- ✅ Send two messages in one external-agent chat → second turn reuses the same agent
session **and** the same worktree (verified: no second `createWorktree`, agent
references files it edited in turn 1). *(opencode; Smoke 1, `v2.6.1`)*
- ✅ Warm-start latency for turn 2 materially below turn 1 (no spawn/handshake). *(turn 2 ~9× faster, `v2.6.1`)*
- ✅ opencode reasoning shows once per thought (no dupes) in the Thinking block.
- ⬜ Killing the opencode server mid-session → pool restarts it and the next turn
recovers (opencode persists sessions on disk). *(Phase 3 — `opencode-server.ts` still comments "recovery is Phase 3")*
- 🟡 Switch opencode → boocode → opencode in one chat → opencode resumes its *same*
session (its memory intact), boocode saw opencode's turns as history, and all three
shared the one worktree. No agent is locked to the chat. *(opencode↔boocode works; goose/qwen warm side is Phase 2 → full round-trip = Smoke 2b, unshipped)*
- ⬜ Closing/archiving a session removes its worktree; BooCoder restart drains cleanly. *(delete-guard shipped `v2.6.2`, but the close→cleanup hook + orphan reaper are Phase 3)*
- ✅ Existing one-shot paths (arena, `new_task` tool, MCP create-task) still work. *(dispatcher resolve-or-create fallback)*
## Deliverables
| Doc | Purpose |
|-----|---------|
| [`design.md`](./design.md) | Architecture, backends, data model, worktree/diff strategy, lifecycle, risks |
| [`tasks.md`](./tasks.md) | Phased implementation checklist |

View File

@@ -0,0 +1,101 @@
# v2.6 Tasks — Persistent agent sessions
Phased so each phase is independently shippable and smoke-testable. Phase 1
(OpenCode server) delivers the most value on the cleanest API; goose/qwen warm
ACP follows; hardening last.
## Phase 0 — Foundations (no behavior change) — ✅ SHIPPED `v2.6.0-phase0-foundations`
- [x] 0.1 Tables added to `apps/coder/src/schema.sql` (idempotent) + `pending_changes.agent` column. *Later re-keyed to `(chat_id, agent)` + `worktrees` table in P1.5-b.*
- [x] 0.2 `AgentBackend` / `AgentSessionHandle` interface + normalized `AgentEvent` union — `apps/coder/src/services/agent-backend.ts`.
- [x] 0.3 `agent-pool.ts` scaffolded (lazy get-or-create, health, `dispose()`, `onClose` hook).
## Phase 1 — OpenCode server backend (multi-turn, warm) — ✅ SHIPPED `v2.6.1-phase1-opencode` (Smoke 1 verified)
- [x] 1.1 `@opencode-ai/sdk` added to `apps/coder/package.json`.
- [x] 1.2 `backends/opencode-server.ts`: spawn `opencode serve`, allocated port, wait for ready line. *`OPENCODE_SERVER_PASSWORD` deferred — loopback-unsecured.*
- [x] 1.3 SSE read loop + demux + text/reasoning/tool mapping. *Superseded by per-session SSE (P1.5-a); events are `session.next.*`, not `message.part.*`.*
- [x] 1.4 Paseo `streamedPartKeys` reasoning dedup (delta vs final part).
- [x] 1.5 `ensureSession` reuse/resume. *Re-keyed `(chat_id, agent)` in P1.5-b.*
- [x] 1.6 `prompt` via SDK with worktree `directory` + `model`.
- [x] 1.7 Dispatcher routes `agent==='opencode'` to the pool backend; broker frames + `persistExternalAgentTurn` identical.
- [x] 1.8 Persistent worktree, chat-keyed, base commit captured, reused across turns/agents. *Now the first-class `worktrees` table (P1.5-b).*
- [x] 1.9 Per-session concurrency: `Map<sessionId,Promise>`; `poll()` skips in-flight sessions.
- [x] 1.10 Per-turn diff supersedes prior `pending_changes` row (latest-wins).
- [x] **Smoke 1** — verified end-to-end (two turns, same session + worktree, turn 2 ~9× faster, reasoning once).
## Phase 1.5 — concurrency + chat-keying follow-ups (added during impl, not in original plan) — ✅ SHIPPED
- [x] P1.5-a **Per-session SSE** (`v2.6.2-delete-guard-and-sse`): one `event.subscribe({directory})` per live opencode session, each with an `AbortController`; `sessionID` demux guard + zombie-loop fix — replaces task 1.3's single global loop. Bundled: session-delete work-loss guard (`/worktree-risk`).
- [x] P1.5-b **Re-key `agent_sessions` → `(chat_id, agent)`** + first-class `worktrees` table (`v2.6.3-chatkey-and-skills`); `tasks.chat_id` threaded; `runOpenCodeServerTask` resolve-or-creates a chat for session-less creators; cross-chunk dcp-strip. FK convergence to `SET NULL` (`v2.6.4-agent-sessions-fk`).
## Phase 1 (UX) — Attribution & switch affordances (design §9) — ✅ SHIPPED `v2.6.8-agent-attribution` (Smoke U pending live frontend deploy)
- [x] U.1 Stamp `pending_changes.agent` at queue time — native tools default `'boocode'`, dispatched external → `task.agent`, manual RightRail → `NULL` (`pending_changes.ts`, `dispatcher.ts`).
- [x] U.2 `agent` flows through `listPending` + backend & frontend `PendingChange` types.
- [x] U.3 Shared `components/coder/providerIcons.tsx`; DiffPanel per-row agent badge + "Changes from X, Y" multi-agent note (§9a).
- [x] U.4 `GET /api/sessions/:id/agent-sessions` route + `api.coder.agentSessions` + `useAgentSessions` hook (refetch on message-complete) (§9b).
- [x] U.5 `AgentComposerBar` optional `sessionId` prop → resumed/history/new-session chip; hidden on fresh chats + other callers (§9b).
- [x] U.6 Consume opencode `session.next.step.ended` → accumulate `input_tokens`/`output_tokens`/`cost` on `agent_sessions` (new cols). Backend persist only; UI surfacing deferred.
- [ ] **Smoke U:** stage edits with opencode then boocode → DiffPanel badges each row to the
right agent; composer shows "resumed" when re-selecting opencode, "new session" for goose. *(pending live frontend deploy — Docker container rebuild)*
## Phase 2 — Warm ACP backend (goose, qwen) — ✅ SHIPPED `v2.6.9-warm-acp` (Smoke 2/2b pending live)
> **Lift (design §10):** `qwen --acp` is a validated reference (real stdio multi-session, `loadSession`/resume) — wire qwen into the existing `acp-dispatch.ts` stack. **goose ACP has no `loadSession`/resume** → cross-restart resume needs a different design (re-`session/new` + accept memory loss, or replay). Cross-check qwen `@agentclientprotocol/sdk@^0.14` vs BooCode `^0.22` before relying on `unstable_resumeSession`. Do **qwen first** to de-risk.
- [x] 2.1 `backends/warm-acp.ts` `WarmAcpBackend` — persistent spawn + `ClientSideConnection`; `initialize` + `session/new` once per `(chat,agent)`. `handleSessionUpdate` extracted to a shared pure `acp-event-map.ts` (one-shot path byte-identical).
- [x] 2.2 `prompt`: `session/prompt` on the warm connection per turn; abort = `session/cancel` the prompt only (never kills the child).
- [x] 2.3 Child supervision: pool-owned lifetime; `exit` marks `agent_sessions.status='crashed'` → re-spawn next turn.
- [x] 2.4 Dispatcher routes `goose`/`qwen` chat-tab tasks to the warm backend via pure `shouldUseWarmBackend(task)` (needs `session_id`+`chat_id`); one-shot `runExternalAgent` fallback kept for arena/MCP/`new_task`. *(SDK note resolved: installed `@agentclientprotocol/sdk@^0.22.1` has stable `resumeSession`/`loadSession`; resume moot in the warm hot path, deferred to Phase 3.)*
- [ ] **Smoke 2:** two messages in a goose chat reuse the same process + ACP session + worktree;
reasoning still renders; no per-turn respawn.
- [ ] **Smoke 2b (switch round-trip):** opencode → boocode → opencode in one chat — opencode
resumes the SAME `agent_session_id` (memory intact), boocode saw opencode's turns as
history, all three shared the one worktree, and no agent was locked to the chat.
## Phase 3 — Lifecycle hardening — ✅ COMPLETE (`v2.6.10` 3.13.6; `v2.6.11` closed 3.7 + the apps/server close-hook caller)
> **Lift (design §10):** hardening from **openchamber** (MIT, same warm-opencode-server architecture) — health-monitor + crash auto-restart + busy-aware restart + port reclaim (`killProcessOnPort`/`waitForPortRelease`) + stall-SSE = a concrete state machine for 3.1/3.2/3.6. Reaper (3.3/3.4): Paseo worktree-archive cascade + superset destroy-saga (preflight dirty/unpushed inspect) + LRU cap on warm-server Maps. Do crash-recovery + reaper together (shared supervision loop).
- [x] 3.1 Idle TTL eviction per `(chat, agent)` (`AGENT_POOL_IDLE_TTL_MS`=30min) + LRU cap (`AGENT_POOL_MAX_LIVE`=10), busy never evicted; reattach next turn. Pure `lifecycle-decisions.ts` (TDD).
- [x] 3.2 Crash recovery: openchamber health-monitor + busy-aware-restart + stale-grace state machine in `opencode-server.ts` (+ port reclaim) + `warm-acp.ts`. opencode → fresh sessions; ACP → re-`session/new`. F.1 guard + U.6 usage preserved.
- [x] 3.3 Close hooks (`/api/chats/:id/close`, `/api/sessions/:id/close`) → `closeChat` evicts backends + archives the `worktrees` row + removes the worktree. **apps/server caller wired in `v2.6.11`** (`coder-notify.ts`, fire-and-forget on session-delete + chat archive/delete).
- [x] 3.4 Orphan worktree reaper (periodic, 1h grace, superset-style dirty/unpushed preflight, Paseo soft-delete) + LRU cap on the pool.
- [x] 3.5 Re-baseline `worktrees.base_commit` after a successful `apply_pending` (both apply routes).
- [x] 3.6 Reconnect integration test (DB-opt-in): restart mid-session → next turn reattaches/recreates from `agent_sessions`/`worktrees`.
- [x] 3.7 Staging-boundary hint in DiffPanel (§9c) — `v2.6.11`: muted one-liner when the selected provider can't see another agent's unapplied worktree edits (derived from per-change `agent` + current provider; no new state).
## Tests — ⬜ REMAINING (none of T.1T.3 exist yet)
- [ ] T.1 `agent-pool` unit: get-or-create, idle evict, dispose drains in-flight (DB-opt-in pattern).
- [ ] T.2 opencode SSE demux + reasoning dedup unit (fixture event stream). *Fold in an F.1 interrupt-bug regression case.*
- [ ] T.3 per-session concurrency: two sessions run concurrently, one session serializes.
## Docs
- [~] D.1 `CLAUDE.md` BooCoder-dispatch section **done** (v2.6.1 / v2.6.4 doc-syncs); **`BOOCODER.md` health/contract still pending** (no v2.6 warm-server mentions).
- [~] D.2 `@opencode-ai/sdk` dep noted; `OPENCODE_SERVER_PASSWORD` env n/a (deferred — loopback-unsecured).
- [x] D.3 `CHANGELOG.md` entries per tag (`v2.6.0``v2.6.4`) — shipped as 5 tags, not the single planned `-persistent-agent-sessions`.
## Build / deploy gate — ✅ (per shipped tags; re-run per remaining batch)
- [x] B.1 `pnpm -C apps/server build && pnpm -C apps/coder build` clean.
- [x] B.2 `pnpm -C apps/server test` green. *(v2.6-specific T.1T.3 units still unwritten.)*
- [x] B.3 Deployed (`sudo systemctl restart boocoder`; `curl :9502/api/health`).
-----
## Fix-next (before Phase 2) — ✅ SHIPPED `v2.6.7-interrupt-guard`
- [x] F.1 **Post-interrupt stale-terminal guard.** opencode emits one trailing `session.idle`/`session.error` for a cancelled turn (sessionID only, no turn id) which settled the *next* turn early. Fixed with a pure per-session guard (`backends/turn-guard.ts`: `armAbortGuard`/`noteTurnActivity`/`consumeTerminal` over `swallowNextTerminal`) wired into `opencode-server.ts` (arm on abort, swallow the orphan terminal, self-heal on next-turn activity). 3 regression tests (`turn-guard.test.ts`), TDD. Paseo parallel: `1d38aac`.
## Remaining — recommended order (implementation plan, 2026-05-31)
1. ~~**F.1 interrupt-bug fix**~~ — ✅ shipped `v2.6.7-interrupt-guard` (3 regression tests, TDD).
2. ~~**Phase 1-UX** (U.1U.6)~~ — ✅ shipped `v2.6.8-agent-attribution` (3 parallel agents, disjoint files; 9 new tests). Smoke U pending the frontend Docker rebuild.
3. ~~**Phase 2 — warm ACP, qwen first then goose**~~ — ✅ shipped `v2.6.9-warm-acp` (15 new tests; one-shot path preserved). Smoke 2 + 2b pending live exercise post-deploy.
4. **Phase 3 — lifecycle hardening** — lift openchamber's state machine; do crash-recovery (3.1/3.2/3.6) + worktree reaper (3.3/3.4 + LRU) together (shared supervision loop). Closes the two ⬜ success criteria (server-crash recovery, close→cleanup).
5. **Tests T.1T.3 + `BOOCODER.md` (D.1 remainder)** — backfill alongside each phase, not at the end.
Each phase stays independently shippable + smoke-testable (original phasing holds). Tag monotonically from `v2.6.7`, one batch per phase.

View File

@@ -0,0 +1,101 @@
# Write/edit robustness — fuzzy patch applier + worktree checkpoints
**Status:** in progress (started 2026-06-01)
**Source:** `boocode_code_review_v2.md` §1 #3 + #4, §5b/§5d5e (cline, Apache-2.0 — algorithm clean-reimplemented, not vendored).
Two independent BooCoder hardening features for local quantized models.
## #3 — Fuzzy patch applier
**Problem:** `applyOne`'s edit case (`apps/coder/src/services/pending_changes.ts:124`) does exact
`content.includes(oldStr)` → throw, then `content.replace(oldStr, newStr)` (first occurrence).
`rewindOne` (line 206) is the same. Local models (qwen3.6) drift `old_string` by whitespace/
indentation/unicode (curly quotes, en/em-dash, nbsp), so a valid edit fails at apply with
"old_string not found" and is lost.
**Design:** new pure module `apps/coder/src/services/fuzzy-match.ts`:
`locateMatch(content: string, needle: string): { kind: 'exact'|'fuzzy'; start: number; end: number }
| { kind: 'ambiguous'; count: number } | { kind: 'not_found' }`. Match ladder:
1. **Exact** `indexOf`. If exactly one → exact span. If >1 → **ambiguous** (refuse; decision
2026-06-01: safer than silently editing the first).
2. **Per-line whitespace-insensitive** — compare `needle` lines to file line-windows ignoring per-line
`trimEnd`/leading-trailing blank lines.
3. **Unicode canonicalization** — normalize curly→straight quotes, en/em-dash→`-`, nbsp→space on both
sides, then retry the whitespace pass.
4. **Levenshtein** similarity ≥ 0.66 over line-windows sized to `needle`'s line count; best window wins.
Non-exact (fuzzy) matches return the actual file span so the caller replaces the real file text with
`new_string`. `pending_changes.ts` `applyOne`/`rewindOne` use `locateMatch`; `ambiguous`/`not_found`
return `success:false` with a clear message (no throw escaping the existing catch). Unit-tested
(`apps/coder/src/services/__tests__/fuzzy-match.test.ts`), per the `turn-guard.ts` pure-helper pattern.
## #4 — Worktree checkpoint + conversation-trim
**Problem:** `rewind` only reverses BooCode's own `pending_changes` (applied to the project root).
External agents (opencode/goose/qwen/claude) write **directly into the session worktree**
(`/tmp/booworktrees/sess-<id>`); rewind has zero coverage there.
**Schema** (`apps/coder/src/schema.sql`):
```sql
CREATE TABLE IF NOT EXISTS checkpoints (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
chat_id UUID NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
session_id UUID,
worktree_id UUID REFERENCES worktrees(id) ON DELETE SET NULL,
message_id UUID, -- anchor: the assistant turn row this checkpoint precedes
commit_sha TEXT NOT NULL, -- shadow-commit capturing the pre-turn worktree tree
label TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS checkpoints_chat_created_idx ON checkpoints(chat_id, created_at);
```
**Create** (`apps/coder/src/services/checkpoints.ts``createCheckpoint`): hooked into the three
external-agent dispatch paths in `dispatcher.ts` (`runWarmAcpTask` ~821, `runOpenCodeServerTask` ~513,
`runExternalAgent` ~255) — after `ensureSessionWorktree()` and the assistant-message insert (so the
anchor `message_id` exists), before the backend runs. Snapshot captures tracked **+ untracked** via a
temp-index shadow commit, stored in a private GC-safe ref:
```
cd <wt> && TMP=$(mktemp) && GIT_INDEX_FILE="$TMP" git read-tree HEAD \
&& GIT_INDEX_FILE="$TMP" git add -A \
&& TREE=$(GIT_INDEX_FILE="$TMP" git write-tree) \
&& SHA=$(git commit-tree "$TREE" -p HEAD -m "boocode checkpoint") \
&& git update-ref refs/boocode/checkpoints/<id> "$SHA" && rm -f "$TMP" && echo "$SHA"
```
Best-effort: a checkpoint failure logs and never breaks the turn. Native-boocode turns (project-root,
rewind-covered) get no checkpoint.
**Restore** (`POST /api/sessions/:sessionId/checkpoints/:checkpointId/restore`, proxied `/api/coder/*`):
1. Resolve + validate the checkpoint belongs to the session.
2. Reset worktree: `git -C <wt> reset --hard <commit_sha> && git -C <wt> clean -fd` (hostExec+shellEscape).
3. Trim transcript: `DELETE FROM messages WHERE chat_id = <cp.chat_id> AND created_at >=
(SELECT created_at FROM messages WHERE id = <cp.message_id>)` (+ explicit `message_parts` delete if
the FK isn't ON DELETE CASCADE — verify).
4. Reset backend (decision 2026-06-01): `UPDATE agent_sessions SET status='crashed' WHERE
chat_id=<cp.chat_id>` and evict the live pool session for `(chat,agent)` if present, so the next turn
re-establishes a fresh backend — transcript, files, and agent context all consistent at the restore
point. (Warm backends hold context server-side; no partial rewind exists.)
5. Delete now-orphaned later checkpoints: `DELETE FROM checkpoints WHERE chat_id=? AND created_at >
<cp.created_at>`.
6. Return `{ checkpoint_id, messages_deleted, worktree_reset, backend_reset }`.
**Frontend:** per-message "Restore to here" in `CoderMessageList.tsx` (via a new optional
`onRestoreCheckpoint?(chatId, messageId)` on `MessageActions` in `MessageBubble.tsx`), wired in
`CoderPane.tsx`; guarded to `status==='complete'` and to messages that have a checkpoint. After the call
returns, refetch the chat's messages (existing GET) — no new WS frame required.
## Decisions (2026-06-01)
- Multi-exact-match → **refuse as ambiguous** (#3).
- #4 **full** scope incl. conversation-trim.
- Restore **resets** the external-agent backend session (context re-established fresh).
## Parallelization
- **Unit 1 (#3)** — fully independent (`fuzzy-match.ts` + `pending_changes.ts` + test).
- **Unit 2 (#4 backend)** — schema + `checkpoints.ts` (create+restore) + 3 dispatcher hooks + restore route + backend reset. One agent owns all #4 coder backend (shared `checkpoints.ts`).
- **Unit 3 (#4 frontend)** — `CoderMessageList`/`MessageBubble`/`CoderPane`, against the pinned restore contract. Parallel with Unit 2. MUST NOT touch Sam's uncommitted WIP (`ChatTabBar`, `SessionLandingPage`, `Workspace`, `useWorkspacePanes`, `PaneHeaderActions`).
## Verify
- `pnpm -C apps/coder test` (incl. new `fuzzy-match` + any checkpoint pure-helper tests)
- `pnpm -C apps/server build` then `pnpm -C apps/coder build`
- `npx tsc -p apps/web/tsconfig.app.json --noEmit`
- Live smoke (manual, host): external-agent edit → checkpoint row; "Restore to here" → worktree reset + transcript trimmed + next turn fresh.