Compare commits

...

20 Commits

Author SHA1 Message Date
937920df06 docs(changelog): v2.6.1-phase1-opencode
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:42:39 +00:00
e05469c6ae docs(claude): v2.6 Phase 1 opencode learnings — SSE, model resolution, resume
- opencode is now a warm HTTP server (was "planned, unshipped").
- SSE: session.next.* event types + subscribe({directory}) requirement.
- Model strings need llama-swap/ prefix + presence in opencode.json.
- config_hash excludes ephemeral port; session FKs are ON DELETE CASCADE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:40:16 +00:00
0e026be5f8 fix(coder): CASCADE delete on session_worktrees + agent_sessions FKs
Deleting a session with linked session_worktrees or agent_sessions rows
threw a FK violation (500 on DELETE /api/sessions/:id). Both FKs now
ON DELETE CASCADE. Idempotent migration: drops the old constraint and
re-adds with CASCADE only if confdeltype != 'c'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:26:28 +00:00
315cdd23e2 feat: strip dcp-message-id tags from opencode output + reopen closed panes
Two independent fixes:

- opencode-server.ts: stripDcpTags() removes <dcp-message-id>…</dcp-message-id>
  tags from text deltas before they reach the frame/DB. Applied to all three
  text paths (session.next.text.delta, message.part.delta text field,
  handleUpdatedPart text type). Reasoning/tool paths untouched.
- useWorkspacePanes.ts: module-level closedPaneStack (capped at 10) captures
  pane kind + chatIds on removePane and removeTab auto-remove. reopenPane()
  pops the stack and re-attaches a new pane to the existing chat ids (chats
  survive pane close server-side). hasClosedPanes drives conditional render.
- ChatTabBar.tsx: [+] is now instant new-tab (no dropdown); split-pane
  dropdown (Columns2 icon) opens Chat/Term/Code in a new pane; reopen button
  (RotateCcw icon) appears when closed panes exist.
- Workspace.tsx: pass reopenPane + hasClosedPanes through to ChatTabBar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:26:07 +00:00
6d24726c3a feat: add systematic-debugging slash command for BooChat + BooCoder
/data/skills/boocode/systematic-debugging/SKILL.md — guided root-cause
debugging methodology (investigate before fixing). Available as
/systematic-debugging in both BooChat and BooCoder slash menus via the
shared /api/skills endpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:37:51 +00:00
1bbeaf95c7 fix: auto-name uses session model + pane auto-remove on last tab close
Two independent UI/UX fixes:

- auto_name.ts: pass the session's own model as fallbackModel to
  taskModelCompletion, so chat rename uses whatever model is already
  loaded on llama-swap instead of forcing a swap to DEFAULT_MODEL
  (which times out at 10s when a different model is active).
- useWorkspacePanes.ts: when the last tab in a pane is closed and
  other panes exist, remove the pane entirely instead of leaving an
  orphaned empty panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:37:38 +00:00
e30a9e8b23 feat(coder): v2.6 Phase 1 — OpenCode warm server backend
Persistent multi-turn opencode backend: one `opencode serve` HTTP server per
BooCoder process, one opencode session per BooCode session (resumed on
switch-back), single SSE read loop demuxed by session id.

- backends/opencode-server.ts: AgentBackend implementation — spawn with
  waitForReady, session.next.* SSE event translation (text/reasoning/tool
  deltas), Paseo-ported reasoning dedup (streamedPartKeys), promptAsync
  fire-and-forget settled by session.idle, per-turn inactivity watchdog
  (180s) + reconnect reconciliation via session.messages, stale-session
  guard (crashed-not-resumed + config_hash fingerprint on model).
- dispatcher.ts: opencode routes to pool backend (ensureSession→prompt);
  per-session concurrency Map replaces global running boolean (1.9);
  model coalesce (empty→DEFAULT_MODEL) + llama-swap/ prefix for opencode;
  diff-supersede (DELETE+INSERT pending_changes by session, stamp agent).
- worktrees.ts: ensureSessionWorktree (session-keyed, captures base_commit,
  persists to session_worktrees); diffWorktree gains optional baseRef.
- agent-probe.ts: mergeLlamaSwap branch fetches /v1/models, prefixes with
  llama-swap/, populates opencode's available_agents.models (was 0).
- provider-snapshot.ts: export fetchLlamaSwapModels for probe reuse.
- schema.sql: session_worktrees + agent_sessions tables (Phase 0) +
  config_hash column on agent_sessions, pending_changes.agent column.
- package.json: @opencode-ai/sdk ~1.15.0 (resolved 1.15.12).

Known Phase 1 limitation: single SSE stream scoped to most-recent session's
directory; concurrent opencode sessions in different worktrees collide
(warning logged, watchdog prevents hang). Phase 2 moves to per-session SSE.

Smoke 1 verified: two turns in one session, both produce real tokens, same
agent_session_id reused, same server port, turn 2 is 9x faster (no spawn).
goose/qwen/claude paths untouched (runExternalAgent md5 identical).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:37:11 +00:00
140ff26204 feat(coder): v2.6 Phase 0 — AgentBackend foundations (no behavior change)
Schema, interface, and service scaffold for v2.6 persistent agent sessions.
Nothing in this batch alters runtime behavior.

- schema.sql: add session_worktrees (one shared worktree per session, FK
  sessions(id)) and agent_sessions (one backend session per (session, agent),
  with backend/status CHECKs); add pending_changes.agent column for DiffPanel
  attribution. All three statements idempotent (IF NOT EXISTS).
- services/agent-backend.ts: AgentBackend interface + AgentSessionHandle,
  EnsureSessionOpts, PromptCtx, TurnResult, and the normalized transport-agnostic
  AgentEvent union (text/reasoning/tool_call/tool_update/commands). Types only.
- services/agent-pool.ts: lazy get-or-create AgentPool keyed by
  `${sessionId}:${agent}` + shared `agentPool` singleton. Empty in Phase 0.
- index.ts: widen onClose to await dispatcher.stop() then agentPool.dispose()
  (pool empty, so dispose() is inert).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:50:17 +00:00
a97293b5d9 Merge coder-hardening: acp-client-fs path-guard fix + untrack live provider config 2026-05-29 22:23:20 +00:00
63adb218e6 chore(coder): untrack live coder-providers.json, ship example
The live config is read AND written by the coder (UI provider toggles PATCH it),
so tracking it churned `git status`. Untrack it (now gitignored under data/*),
add a tracked data/coder-providers.example.json reference, and update the
.gitignore exception + CLAUDE.md/BOOCODER.md docs. Loader already falls back to
{providers:{}} (built-ins only) when the live file is absent. + CHANGELOG v2.5.15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:23:13 +00:00
d0334ca544 fix(coder): separator-bounded worktree path guard in acp-client-fs
The ACP fs bridge's worktree guard used an unbounded `startsWith(resolve(
worktreePath))`, so a sibling path sharing the worktree as a string prefix
(`<worktree>-evil/...`) escaped the scope. Since writeWorktreeTextFile hits disk
directly (no pending_changes gate), a confused/buggy ACP agent could write
outside its worktree. Now uses a separator-bounded check matching write_guard.ts
(resolve() + `startsWith(root + sep)` / `=== root`) via a shared resolveInWorktree,
with a regression test (../ traversal + the sibling-prefix bug). Symlink-swap
hardening intentionally skipped — consistent with write_guard's no-realpath
stance; the agent runs with host FS access so this is a containment guard, not a
trust boundary. Flagged by the automated push security review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 22:22:51 +00:00
024ffc0b92 Merge claude-md-learnings: session learnings + CHANGELOG v2.5.14 2026-05-29 21:24:18 +00:00
691eef1b30 docs(claude): session learnings — provider lifecycle, deploy + mobile gotchas
Adds to CLAUDE.md: stale boocoder-restart symptom after build (new routes 404 /
old routes 200); boocode container build: . deploys the working tree, web
dev≠prod until container rebuild; PATCH provider-config replaces override
wholesale (send full override) + coder-providers.json is live config (don't
commit drift); external agents one-shot with no ctx tracking + OpenCode-as-server
is unshipped v2.6; ui/ primitive inventory + button-role=switch / Dialog
fallbacks; mobile Dialog scroll containment. Also backfills uncommitted doc
bullets for the v2.5.7–v2.5.11 coder work. CHANGELOG v2.5.14 entry. Docs only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 21:24:10 +00:00
e92c51578d Merge v2.3-provider-lifecycle-phase5: provider settings UI + closeout
Phase 5 (Settings → Providers tab, picker filter, ACP catalog) + mobile settings
fix + Phase 6 docs. Completes the v2.3 provider-lifecycle batch
(phases 1–4: v2.5.4 / v2.5.5 / v2.5.6 / v2.5.12).
2026-05-29 20:20:38 +00:00
6d03690a65 docs: v2.3 provider-lifecycle closeout (Phase 6)
BOOCODER.md gains a Provider lifecycle section (config file + schema,
gitignored-with-exception, the 24h PROVIDER_PROBE_TTL_MS refresh contract,
enable/disable via Settings → Providers, custom-ACP add, native boocode
always-on, the honest subset-refresh known limitation, deploy + smoke).
docs/DEFERRED-WORK.md §2 (cold-probe skip) marked ADDRESSED with the still-
deferred Tier-2 follow-ups listed. CHANGELOG gets the v2.5.13 batch-closeout
entry. Docs only — no code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 20:20:31 +00:00
21384cce5b web: fix Settings pane unreachable on mobile (push ?pane= atomically)
Opening the settings pane on mobile set activePaneIdx, but the ?pane= URL-sync
effect snapped it back to the chat pane on the panes change, so the pane never
showed. toggleSettingsPane now returns the new pane id (id generated outside the
updater, strict-mode safe); Session's toggleSettingsAndSync pushes ?pane=<id> on
mobile when opening (and drops it on close) so the sync effect keeps it active —
mirrors the existing addPaneAndSwitch pattern. Desktop unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 20:20:24 +00:00
920f8b75a6 web(coder): provider settings UI — Settings → Providers tab, picker filter, ACP catalog
v2.3 Phase 5. Provider management lives in Settings → Providers: lists every
registered provider with a status badge, enable/disable toggle (sends the full
override so a custom ACP entry's command survives the wholesale-replace PATCH),
per-provider refresh, and a plaintext diagnostic. The composer provider picker
now filters to enabled && (status==='ready' || 'loading') — disabled/unavailable
providers leave the picker and are managed only in settings; native boocode
always shows. Adds a curated ACP catalog + AddProviderModal (PATCH config then
subset refresh; the modal caps to the viewport with a single overscroll-contain
scroll region). Loading state uses a capped client poll (no WS frame).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 20:20:18 +00:00
e83d9b7d5b docs(changelog): v2.5.12-provider-lifecycle-phase4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:48:28 +00:00
f302969c71 coder(providers): v2.3 provider-lifecycle phase 4 — config HTTP API (diagnostic returns JSON)
GET/PATCH /api/providers/config, subset POST /refresh, and
GET /api/providers/:id/diagnostic (JSON { diagnostic }, §6.4). PATCH order
is validate→save→reload→clear; a malformed body or invalid merged config
returns 422 without writing, and a save failure returns 500 without
reloading (no file/registry divergence). Web client + types extended.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 17:46:56 +00:00
2d997ecb6c web+coder: discover Claude's enabled commands + plugin skills; icon-split commands vs skills
claude is PTY (no ACP discovery), so claude-command-discovery.ts reads its enabled set from disk (user-global): ~/.claude/commands/*.md + every enabled plugin's skills/<name>/SKILL.md (kind=skill) and commands/*.md (kind=command), from ~/.claude/settings.json:enabledPlugins + installed_plugins.json install paths, frontmatter-parsed, bare names, deduped. The snapshot claude branch discovers these live (snapshot cache rate-limits the reads). The coder / menu now shows up to three icon'd groups: <agent> commands (Terminal), <agent> skills (Puzzle), BooCoder skills (Sparkles) via a new optional icon on SlashCommandGroup. AgentCommand gains a kind field in both coder + web copies (parity test enforces); mergeCommandsByName made generic to preserve it. Invocation unchanged (literal /name -> claude). Project-local plugins deferred. BooChat unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 16:21:32 +00:00
45 changed files with 3079 additions and 108 deletions

2
.gitignore vendored
View File

@@ -16,5 +16,5 @@ data/*
!data/AGENTS.md
!data/skills/
!data/mcp.json
!data/coder-providers.json
!data/coder-providers.example.json
codecontext/fork.tar.gz

View File

@@ -37,3 +37,81 @@ Every file modification queues in `pending_changes` before touching disk. The us
- Never count `dist/` directory sizes as source lines. Only count `src/**/*.ts` files. Compiled output is inflated by inlined types and transpilation artifacts.
- Before claiming a feature works, run the actual command and show the output. "Should work" is not verification. Acceptable evidence: test output (`pnpm test`), build output (`pnpm build`), curl response, docker logs, `\d tablename` output. If you can't run it, say so explicitly — don't assert success without evidence.
- When reporting counts (tools, tests, files, routes, lines), derive the number from a command (`grep -c`, `wc -l`, test runner output) — not from memory or approximation.
## Provider lifecycle (v2.3)
BooCoder's coding agents are a **config-backed registry**: built-ins live in `provider-registry.ts`, and `data/coder-providers.json` layers overrides + custom entries on top. Registration ≠ installation — the config lists what you *want*; a probe reports what's *ready*.
### Config file: `data/coder-providers.json`
Resolved from `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`; dev/host path `/opt/boocode/data/coder-providers.json`). It is **gitignored** — it's live runtime config that the coder reads *and writes* (UI toggles `PATCH` it), so tracking it would churn `git status`. The tracked reference is `data/coder-providers.example.json`; copy it to `coder-providers.json` to seed overrides. A missing file, invalid JSON, or a schema mismatch all fall back to built-ins-only — loading never throws at startup.
```json
{
"providers": {
"goose": { "enabled": false },
"amp-acp": {
"extends": "acp",
"label": "Amp",
"description": "ACP wrapper for Amp",
"command": ["amp-acp"],
"enabled": true
}
}
}
```
Per-provider override fields (all optional):
| Field | Meaning |
|-------|---------|
| `extends` | `"acp"` — required for a NEW (custom) provider; built-in overrides omit it |
| `label` | Display name (required for custom) |
| `description` | Sub-label shown in the picker / settings |
| `command` | `[binary, ...args]` to spawn (required for custom; overrides a built-in's default argv) |
| `env` | Extra env vars merged into the spawn |
| `enabled` | Default `true`; `false` hides it from the composer |
| `order` | UI sort key |
| `models` / `additionalModels` | Replace / merge onto the discovered model list |
A PATCH to one provider id **replaces that id's override object wholesale** (per-id shallow merge), so to flip a single field keep the rest; a `null` value for an id deletes its override (reverts to the built-in default).
### Refresh contract
The snapshot is cached and a provider's cold ACP probe (tier-2) is **skipped** while `available_agents.last_probed_at` is younger than `PROVIDER_PROBE_TTL_MS` (default `86400000` = 24h). Opening the composer is therefore fast and does not re-probe. To force a cold re-probe (after installing a CLI or editing models): **`POST /api/providers/refresh`** (the Refresh button in the Providers settings tab), which clears the cache and re-probes.
### Enable / disable
Two ways:
- **Settings → Providers tab** — open the sidebar → **Settings****Providers**: toggle a provider on/off, refresh it, or open its diagnostic. (Earlier builds exposed a gear in the composer; that control was moved into Settings.)
- **Edit the config** (`"enabled": false`) then `POST /api/providers/refresh`.
A **disabled** provider leaves the composer's provider picker but stays listed in the Providers tab (status "Disabled") so you can re-enable it. **Native `boocode` is always-on** — an `enabled:false` on it is ignored (with a warn log) and it is never rendered as toggleable.
### Adding a custom ACP provider
- **Catalog modal**: Providers tab → **Add provider** → pick an entry → it PATCHes the config (`extends:'acp'` + label + command, enabled) and refreshes that provider.
- **Hand-edit** `data/coder-providers.json`: add an id with `extends:'acp'`, `label`, and `command`, then `POST /api/providers/refresh`.
Either way, **adding to config does NOT install the binary.** Until the CLI is on `PATH` the provider shows **"Not installed"** (status `unavailable`) and does not appear in the composer picker.
### Known limitation — subset refresh
`POST /api/providers/refresh` accepts an optional `{ "providers": ["id", ...] }` body and returns a `refreshed` count scoped to that subset — **but the underlying cold re-probe currently covers ALL installed providers**, not just the requested subset. True per-provider force is a future change (it needs a snapshot-internal parameter). This is intentional for now, not a bug: a subset refresh still re-probes everything; only the reported count is scoped.
### Deploy + smoke
Two deploy targets:
- **Routes (host service):** `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`
- **Web UI (container):** `docker compose up --build -d boocode`
Green gate (verified across phases 15): `pnpm -C apps/coder test` (134 passing) `&& pnpm -C apps/coder build`.
Smoke (via Tailscale):
```bash
curl http://100.114.205.53:9502/api/providers/snapshot # lists every registered provider
curl http://100.114.205.53:9500/api/coder/providers/config # raw config, through the BooChat proxy
# Settings → Providers: disable goose → it leaves the composer picker, stays in the tab
# POST refresh → models repopulate; Add a catalog entry → it appears after refresh (unavailable until its CLI is installed)
```

View File

@@ -2,6 +2,30 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.6.1-phase1-opencode — 2026-05-30
v2.6 Phase 1: opencode runs as a warm HTTP server (`apps/coder/src/services/backends/opencode-server.ts`) — one `opencode serve` per BooCoder process, one opencode session per BooCode session resumed across turns via the new `agent_sessions` table, with a single SSE read loop, reasoning dedup ported from Paseo, an inactivity watchdog, and a stale-session guard (crashed-not-resumed + a `config_hash` fingerprint over `opencode_server|<model>`, deliberately excluding the ephemeral server port so cross-restart resume survives). Builds on the `v2.6.0-phase0-foundations` schema/interface scaffold. The batch's hard-won fixes: opencode streams `session.next.*` events (not `message.part.*`), and `event.subscribe()` must pass the session's worktree `directory` or events route to the server CWD and turns come back empty; model strings must be `llama-swap/`-prefixed and present in opencode's own config, with `agent-probe` now populating `available_agents.models` via `mergeLlamaSwap` so the frontend stops sending an empty model; `session_worktrees`/`agent_sessions` FKs are `ON DELETE CASCADE` so session deletion no longer 500s. Also bundled: dcp-message-id tag stripping from opencode text output, a reopen-closed-pane control, the `[+]`/split-pane button separation, auto-name using the session's loaded model, and a `systematic-debugging` slash command. Smoke 1 verified end-to-end (two turns, session reuse, turn 2 ~9x faster). Known Phase 1 limit: one SSE stream scoped to the most-recent session's directory — concurrent opencode sessions in different worktrees collide (warns; per-session SSE is Phase 2).
## v2.5.15-acp-path-guard — 2026-05-29
Security fix + repo hygiene. Fixes a path-traversal in the ACP filesystem bridge (`acp-client-fs.ts`, flagged by the automated push security review): the worktree guard used an unbounded `startsWith(resolve(worktreePath))`, so a sibling path sharing the worktree as a string prefix (`<worktree>-evil/…`) escaped the scope — and `writeWorktreeTextFile` writes to disk directly (no `pending_changes` gate), so a confused/buggy ACP agent could write outside its worktree. Now uses a separator-bounded check matching `write_guard.ts` (`resolve()` + `startsWith(root + sep)` / `=== root`) via a shared `resolveInWorktree`, with a regression test covering `../` traversal and the sibling-prefix bug. Symlink-swap/`O_NOFOLLOW` hardening was intentionally skipped — consistent with `write_guard`'s no-realpath stance, and the agent already runs with host FS access so this is a containment guard, not a trust boundary. Separately, stops tracking the live `data/coder-providers.json` (it's runtime config the UI reads *and writes* on provider toggles, which churned `git status`) — it's now gitignored with a tracked `data/coder-providers.example.json` reference; the loader falls back to built-ins-only when the live file is absent. The provider-type duplication (coder ↔ web) stays guarded by the existing text-identity `provider-types-parity.test.ts` — a shared package was considered and declined (drift is already prevented; not worth the Docker/build-order risk at solo scale).
## v2.5.14-claude-md — 2026-05-29
Docs-only — CLAUDE.md session-learnings update, no code. Adds gotchas surfaced while shipping the v2.3 provider-lifecycle batch: the host `boocoder.service` keeps running the old process after `pnpm -C apps/coder build` (stale-process tell = new routes 404 while old routes 200, restart don't re-debug); the `boocode` container `build: .` deploys the working tree, so web edits are live on the Vite dev server but not production until `docker compose up --build -d boocode`; `PATCH /api/providers/config` replaces a provider's override wholesale (send `{...existing, enabled}` or a custom ACP entry's command is wiped) and `data/coder-providers.json` is live config not to be committed as code; external agents dispatch one-shot with no context/token tracking (only native `boocode` tracks ctx; OpenCode-as-server is the unshipped `v2-6-persistent-agent-sessions` plan); the `ui/` primitive inventory with `button role=switch` / Dialog fallbacks for the absent switch/sheet; and the mobile Dialog-with-list scroll-containment recipe. Also backfills previously-uncommitted doc bullets for the `v2.5.7``v2.5.11` coder work (provider-type parity test, async ACP command discovery, AgentComposerBar `installed` filter, provider-registry path disambiguation).
## v2.5.13-provider-lifecycle-phase5 — 2026-05-29
Closeout of the v2.3 provider-lifecycle batch — the web UI (Phase 5) plus docs (Phase 6). Provider management moved into **Settings → Providers**: a tab listing every registered provider with a status badge (Available / Disabled / Not installed / Error / Loading), an enable/disable toggle, a per-provider refresh, and a plaintext diagnostic; toggling sends the provider's *full* override (preserving a custom ACP entry's command under the wholesale-replace PATCH merge) then refetches the snapshot. The composer's provider picker now filters to `enabled && (status === 'ready' || 'loading')`, so disabled and unavailable providers drop out of the picker and are managed only in settings (native `boocode` always shows). A curated ACP catalog (`apps/web/src/data/acp-provider-catalog.ts`) + `AddProviderModal` register custom providers via `PATCH /api/providers/config` then a subset refresh, and the web client gained `getProvidersConfig` / `patchProvidersConfig` / `refreshProviders` / `getProviderDiagnostic`. Two mobile fixes ship alongside: the Settings pane is now reachable on phones (opening it pushes `?pane=` atomically so the mobile URL-sync effect keeps it active instead of snapping back to the chat pane), and the Add-provider modal caps to the viewport with a single `overscroll-contain` scroll region so the list scrolls instead of dragging the whole modal. This completes the arc begun in `v2.5.4-provider-lifecycle-phase1` (config-backed registry over the built-ins) → `v2.5.5-provider-lifecycle-phase2` (loading/unavailable snapshot lifecycle + tier-2 probe TTL gate) → `v2.5.6-provider-lifecycle-phase3` (generic `resolveLaunchSpec` ACP dispatch) → `v2.5.12-provider-lifecycle-phase4` (config GET/PATCH, subset refresh, diagnostic HTTP API). Docs landed in `BOOCODER.md` (config file, refresh contract, enable/disable, custom ACP, the honest subset-refresh known limitation) and `docs/DEFERRED-WORK.md` §2 is marked addressed; the remaining Tier-2 follow-ups (WS `provider_snapshot_updated` frame, `available_agents.enabled` column, shared types package, MCP provider tools) stay deferred.
## v2.5.12-provider-lifecycle-phase4 — 2026-05-29
Phase 4 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §6): the HTTP API to read, patch, refresh, and diagnose providers. `routes/providers.ts` gains `GET /api/providers/config` (the raw loaded `CoderProvidersFile`), `PATCH /api/providers/config` (a partial providers map — an id's override object is replaced wholesale, a `null` value deletes it), an optional `{ providers?: string[] }` body on `POST /api/providers/refresh` (the `refreshed` count reflects the requested subset; the force probe itself still covers all installed providers, since per-provider force is a snapshot-internal change left to a later phase), and `GET /api/providers/:id/diagnostic` returning JSON `{ diagnostic: string }` — a read-only report (resolved def, install_path, last_probed_at, enabled, `which` availability, last cached probe error) with no probe spawn. PATCH correctness is the whole story: the order is validate→save→reload→clear, a malformed body or an invalid merged config returns 422 without writing the file, and a `save()` failure returns 500 without reloading the registry or clearing the snapshot cache, so on-disk and in-memory state can never diverge. New pure `mergeProviderConfigPatch` + `ProviderConfigPatchSchema` in `provider-config.ts`, a read-only `peekSnapshotEntry` cache accessor (source of the diagnostic's last-error — no probe/cache logic change), and a new `provider-diagnostic.ts` formatter. The web client gains `api.coder.getProvidersConfig` / `patchProvidersConfig` / `refreshProviders(providers?)` / `getProviderDiagnostic`, with mirrored `ProviderOverride` / `CoderProvidersFile` / `ProviderConfigPatch` types; the existing `/api/coder/*` proxy blanket-forwards the new routes with no change. +28 tests (134 coder total: pure merge/validate, the diagnostic formatter, and `app.inject` route tests proving the 422-no-write and save-fail-no-divergence guards). The diagnostic returns JSON rather than the §8 plaintext so it flows through the JSON `request` client helper (reconciling design §6.4's `{ diagnostic }` with §8's string report). No UI (Phase 5). Builds on `v2.5.6-provider-lifecycle-phase3`.
## v2.5.11-claude-skill-discovery — 2026-05-29
Surface Claude Code's real enabled commands + plugin skills in the coder slash menu, with icons separating commands from plugin skills. New `claude-command-discovery.ts` reads (user-global scope) `~/.claude/commands/*.md` plus every enabled plugin in `~/.claude/settings.json:enabledPlugins` — each plugin's user-scope install path contributes `skills/<name>/SKILL.md` (kind `skill`) and `commands/*.md` (kind `command`), parsed from frontmatter, bare names, deduped. The snapshot's claude branch discovers these **live** (claude is PTY, no ACP probe; the snapshot cache rate-limits the fs reads). The `/` menu now renders up to three icon'd groups: **`<agent> commands`** (Terminal), **`<agent> skills`** (Puzzle — claude's plugin skills / opencode is all commands), and **BooCoder skills** (Sparkles), via a new optional `icon` on `SlashCommandGroup`. `AgentCommand` gains a `kind` field, added identically to the coder and web copies (the `provider-types-parity` test enforces it); `mergeCommandsByName` is now generic so it preserves the tag. Invocation is unchanged — picking a claude command/skill sends `/name` to claude (PTY), which executes it. Project-local plugins + `<cwd>/.claude/commands` deferred. BooChat unaffected (flat skills). Smoke-test the claude skill slash-execution on the host.
## v2.5.10-opencode-live-commands — 2026-05-29
Surface opencode's real (live ACP) command set in the coder slash menu without needing a dispatch. Two fixes: (1) the cold ACP probe (`acp-probe.ts`) captured `available_commands` but read `probedCommands` synchronously right after `newSession` — racing opencode's async `available_commands_update` notification, so it captured **zero** and only the 7-item static manifest showed. The probe now waits briefly (poll up to 3s for the first batch + a 300ms settle, capped under the 30s probe timeout) so the commands are actually captured. (2) Captured commands are persisted to a new `available_agents.commands` JSONB column and served (merged with the manifest) on the tier-2-probe-skip path, so the agent's discovered commands survive once the model list is warm and show without a dispatch. Boot warms this via the `force: true` startup snapshot. apps/coder only (probe + schema + snapshot). Caveat: depends on opencode emitting `available_commands_update` on session creation rather than only after a prompt — to be confirmed on the host. Claude (PTY) disk/plugin discovery deferred.

View File

@@ -68,9 +68,9 @@ Key services:
- **`messages_with_parts` view** (v1.13.1-B; `schema.sql`). Read sites that need `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` directly. v1.13.20 dropped the legacy `messages.tool_calls` / `messages.tool_results` JSON columns; the view now reads parts-only subselects. Writes target `message_parts` exclusively via `insertParts` (or via the helpers `partsFromAssistantMessage` / `partsFromToolMessage`). The `Message` wire type still carries `tool_calls?` / `tool_results?` because the view synthesizes them from parts — frontend reads are unchanged. Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`. If you ever need to UPDATE a message and return its full Message shape, do a two-step UPDATE returning `id` followed by SELECT from the view — RETURNING off the bare `messages` table no longer carries the tool fields.
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
- **`services/provider-registry.ts`** — Static registry of provider metadata (label, transport, model source). `PROVIDERS` array, `PROVIDERS_BY_NAME` map. 5 providers: boocode (native), opencode (acp), goose (pty), claude (pty), qwen (pty).
- **`services/agent-probe.ts`** — Startup probe using direct `exec()` (not SSH). Discovers installed agents on host, their versions, ACP support, and models. Qwen models read from `~/.qwen/settings.json`. Claude models are static from the registry. Results persisted to `available_agents` table.
- **`routes/providers.ts`** — `GET /api/providers` returns installed providers with models. Transport field reflects actual capability (checks `supports_acp` from DB, not just registry preference).
- **`apps/coder/src/services/provider-registry.ts`** (BooCoder, NOT apps/server) — Static registry of provider metadata (label, transport, model source). `PROVIDERS` array, `PROVIDERS_BY_NAME` map. 5 providers: boocode (native), opencode (acp), goose (pty), claude (pty), qwen (pty).
- **`apps/coder/src/services/agent-probe.ts`** (BooCoder) — Startup probe using direct `exec()` (not SSH). Discovers installed agents on host, their versions, ACP support, and models. Qwen models read from `~/.qwen/settings.json`. Claude models are static from the registry. Results persisted to `available_agents` table.
- **`apps/coder/src/routes/providers.ts`** (BooCoder)`GET /api/providers` returns installed providers with models. Transport field reflects actual capability (checks `supports_acp` from DB, not just registry preference). The apps/server side of this flow is the "Provider picker dispatch" bullet below.
- **Provider picker dispatch**: when `provider !== 'boocode'`, the message route creates a `tasks` row (with `session_id` set) instead of calling `inference.enqueue`. The dispatcher picks it up and dispatches via ACP or PTY using the agent's `install_path`.
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
@@ -80,12 +80,19 @@ Route registration: all routes registered in `index.ts` via `register*Routes(app
- Write-capable coding agent. Runs as a **systemd service on the host** (`boocoder.service`), NOT in Docker. Fastify server at port 9502, connects to postgres at `127.0.0.1:5500`.
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. apps/server must build FIRST.
- Build + deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`.
- After `pnpm -C apps/coder build` the host `boocoder.service` keeps running the OLD process until `sudo systemctl restart boocoder` — a stale process shows **new routes 404 with `{error:'not found'}` while old routes still 200** (the `/api` not-found handler returns that shape). Restart, don't re-debug.
- Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Follows Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`.
- systemd hardening: only `NoNewPrivileges=true` is safe. `ProtectSystem`, `ProtectHome`, `PrivateTmp` all break agent dispatch (agents need full filesystem access to read configs, write to worktrees).
- `apps/server/tsconfig.json` has `declaration: true` so `.d.ts` files exist for workspace consumers.
- Write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`) queue in `pending_changes` table. Nothing hits disk until `apply_pending` is called. `write_guard.ts` validates paths (resolve + prefix-check, no realpath since files may not exist for creates).
- Frontend: NOT a separate SPA. BooCoder is a `'coder'` pane type within BooChat's SPA (`apps/web/`). `CoderPane.tsx` in `apps/web/src/components/panes/`. API requests go through `/api/coder/*` proxy (Vite dev + Fastify production) which rewrites to the boocoder host service (`BOOCODER_URL` env var, default `http://100.114.205.53:9502`). WS connects directly to `:9502`.
- `apps/coder/web/` is a STANDALONE fallback SPA served at `:9502` directly. The PRIMARY BooCoder frontend is the `CoderPane` in BooChat's SPA (`apps/web/src/components/panes/CoderPane.tsx`), accessible via the "Coder" pane in the workspace at `code.indifferentketchup.com`. Both exist; the pane is what Sam uses.
- **Provider snapshot lifecycle** (`apps/coder/src/services/`): `provider-config.ts` (Zod config, never-throws on bad input) → `provider-config-registry.ts` (`buildResolvedRegistry`, singleton) → `provider-snapshot.ts` (two-tier probe: tier-1 fast presence, tier-2 cold ACP probe skipped unless force / stale `PROVIDER_PROBE_TTL_MS` 24h / dbEmpty; cached). Verify live: `curl http://100.114.205.53:9502/api/providers/snapshot` — returns providers + models + commands, the exact shape `AgentComposerBar` renders.
- `PATCH /api/providers/config` replaces a provider id's override object **wholesale** (per-id shallow merge) — to flip one field send `{...existing, enabled}`, or a custom ACP entry's `command`/`label` is wiped and it drops out of the resolved registry. `data/coder-providers.json` is **gitignored** (it's live runtime config — the coder reads AND writes it on UI toggles); the tracked reference is `data/coder-providers.example.json`. The loader falls back to `{providers:{}}` (built-ins only) when the live file is absent, so a fresh checkout needs no copy.
- **opencode** runs as a warm HTTP server (v2.6 Phase 1, `services/backends/opencode-server.ts``opencode serve` per BooCoder process, one opencode session per BooCode session, resumed via `agent_sessions`). goose/qwen/claude still dispatch **one-shot** ACP/PTY with no ctx/token usage; only native `boocode` (llama-swap engine) tracks ctx. Paseo's per-provider native clients (design §12) deliberately not ported.
- **opencode SSE** (`opencode-server.ts`): live streaming arrives as `session.next.text.delta` / `session.next.reasoning.delta` / `session.next.tool.{called,success,failed}` — NOT `message.part.*` (those are terminal/post-hoc). `client.event.subscribe({ directory })` MUST pass the session's worktree directory; omit it and opencode scopes events to the server's `process.cwd()` → zero session events (empty turns, 180s watchdog timeout). One SSE stream at a time scoped to the last session's dir — concurrent opencode sessions in different worktrees collide (known Phase 1 limit, warns). Turn completes on `session.idle`; `promptAsync` is fire-and-forget (204).
- **opencode model strings** must be provider-prefixed (`llama-swap/<model>`) AND exist in `~/.config/opencode/opencode.json` `provider.llama-swap.models` — not merely loadable by llama-swap. `parseModel` infers `llama-swap/` for a bare id; the dispatcher coalesces empty→DEFAULT_MODEL then prefixes. `agent-probe` populates opencode's `available_agents.models` via `mergeLlamaSwap` (fetches `/v1/models`); empty model list → frontend sends `''` → no inference (`input:0`, empty turn).
- **agent_sessions resume**: `config_hash = sha256('opencode_server|<model>')` — must NOT include the server port (random per boot; including it breaks cross-restart resume). `session_worktrees` + `agent_sessions` FKs to `sessions(id)` are `ON DELETE CASCADE` (else DELETE /api/sessions/:id 500s on FK violation). The `@opencode-ai/sdk` v2 client takes flattened params (`{sessionID, directory, parts, model:{providerID,modelID}}`), imports `createOpencodeClient` from `@opencode-ai/sdk/v2/client`.
### Frontend (`apps/web/src/`)
@@ -145,6 +152,7 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
- Tag naming: `vMAJOR.MINOR.PATCH-slug` (e.g. `v1.13.13-ws-publish`). Monotonic per minor — the slug describes the batch's content so the tag name alone is enough to recall what shipped. No letter suffixes (`-a`/`-b`), no pseudo-ranges (`v1.11.x`), no slug-only sub-versions sharing a number (`v1.13.15-tools` + `-openspec` + `-agentlint` — split into sequential patches instead).
- `CHANGELOG.md` is the per-tag release log, most-recent on top. When a new tag is created, add a `## <tag> — <YYYY-MM-DD>` section with a 36 sentence paragraph summarizing what shipped, drawn from the commit body. Cross-reference other tags by name when the batch builds on, fixes, or pairs with prior work (e.g. "pairs with `v1.13.12-ws-schemas`", "fixed in `v1.13.5-stability-bundle`"). No nested bullets — one paragraph.
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
- The `boocode` container is `build: .` — it builds web+server from the **working tree**, so uncommitted changes deploy. Web edits are live on the Vite dev server (HMR) but NOT on production (`:9500` / code.indifferentketchup.com) until `docker compose up --build -d boocode`.
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. `psql` is not on the host PATH — for an interactive query use `docker exec boocode_db psql -U boocode -d boochat -c "..."`. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
@@ -172,10 +180,12 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
- **Adding a new WS frame type** requires updating BOTH the server's `InferenceFrame` (loose `type:` union + optional fields in `services/inference/turn.ts`) AND the web `WsFrame` (strict discriminated union in `apps/web/src/api/types.ts`). Server publish is permissive; the frontend type is the wire-format gate. The `'usage'` frame added in v1.12.2 needed both sides; missing the web side silently drops the frame at JSON-parse.
- shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive.
- `ui/` primitives present: button, card, context-menu, dialog, dropdown-menu, input, label, radio-group, sonner, textarea. No switch/sheet/drawer/badge/checkbox — use a `<button role="switch" aria-checked>` toggle (a hand-rolled `Switch` already lives in `SettingsPane.tsx`) and a Dialog-based panel for "drawers".
- `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names.
- Two UI event buses: `hooks/sessionEvents.ts` for DB-state events (chat_created, session_updated); `lib/events.ts` for ephemeral UI (`sendToTerminal`, `terminalsRegistry`). Don't merge — different subscriber lifecycles.
- `vite.config.ts` proxy entries are order-sensitive: more-specific prefixes (`/api/term`, `/ws/term`) must come BEFORE `/api`.
- Mobile pane URL sync (`Session.tsx`): the `?pane=<id>` effect resets `activePaneIdx` whenever `panes` changes. New-pane creation on mobile must push `?pane=` atomically — `addPaneAndSwitch` is the wrapper that does this. `addSplitPane` returns the new pane id for callers.
- A scrollable list inside a Dialog on mobile: cap `DialogContent` (`max-h-[85vh]` + `grid-rows-[auto_minmax(0,1fr)_auto]`) and make the list the single scroll region with `overscroll-contain` — otherwise touch-scroll drags the whole fixed modal / chains to the page.
- xterm.js v5 uses canvas rendering — browser doesn't see xterm's selection; the native right-click menu has no working Copy for terminal text. App keybindings (`Cmd/Ctrl-C`, `Cmd/Ctrl-Shift-C`) are the path.
- **New tools** live in their own `services/<name>.ts` file (see `web_search.ts`, `web_fetch.ts`) — exports a pure `executeFoo(input, ...deps)` for direct test access plus a `ToolDef` wrapper that `loadConfig()`s its real dependencies. Register the ToolDef in `tools.ts` `ALL_TOOLS` (and `READ_ONLY_TOOL_NAMES` if applicable). Inject `fetcher: typeof fetch = fetch` rather than `vi.spyOn(globalThis, 'fetch')` — cleanup is simpler and the production call site stays unchanged.
- **Sentinels** are `role='system'` rows with structured `metadata.kind` (`cap_hit`, `doom_loop`). UI-only — `buildMessagesPayload` strips them via `isAnySentinel` so the LLM never sees them. A new kind requires arms in `MessageMetadata` in BOTH `apps/server/src/types/api.ts` AND `apps/web/src/api/types.ts`, plus a render branch in `apps/web/src/components/MessageBubble.tsx`.
@@ -191,6 +201,9 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
- **CoderPane uses ChatInput** (`components/panes/CoderPane.tsx`): shares the same `ChatInput` component as BooChat for full parity — attachments, paste-to-chip, auto-grow textarea, queued messages during send. CoderPane's `sendOneMessage` is the send callback; queued messages drain via `useEffect` when `sending` goes false.
- **Adding a new `SessionEvent` type**: add the interface, add it to the `SessionEvent` union, add a `case` in `useSidebar.ts` `applyEvent` switch (no-op `return prev` is fine), and subscribe in any hook that needs it (e.g. `useSessionStream` for `refetch_messages`).
- **BooCoder provider registry** (`apps/coder/src/services/provider-registry.ts`): static list of provider defs (boocode, opencode, goose, claude, qwen). `PROBED_AGENT_NAMES` derives from it. Adding/removing providers means editing this file, not the frontend.
- **AgentComposerBar filters `e.installed`**: provider snapshot entries with `installed:false` (loading/unavailable) are dropped from the dropdown. `getProviderSnapshot` must await the full build — returning synchronous `loading` placeholders makes every provider vanish (the v2.5.7 "no providers showing up" regression); surfacing loading states needs a client poll.
- **Coder↔web provider-type parity** (`apps/coder/src/services/provider-types.ts``apps/web/src/api/types.ts`): enforced by runtime `provider-types-parity.test.ts` (compile-time cross-import is blocked by TS6307 on web's composite tsconfig). Mirror of the ws-frames parity pattern — edit both copies together or the test fails.
- **ACP command discovery is async**: `acp-probe.ts` must poll after `newSession` for `available_commands_update` (commands arrive in a later notification; reading synchronously captures 0). PTY providers (claude) instead discover from disk via `claude-command-discovery.ts` (`~/.claude/commands` + `enabledPlugins` `skills/`+`commands/`, bare names, deduped). `AgentCommand.kind` tags `'command'` vs `'skill'`; `CoderPane`'s `slashGroups` splits them into icon'd groups. `SlashCommandPicker`'s `groups?` prop is opt-in — BooChat passes flat `items` (unchanged).
- **Pane header architecture (mobile vs desktop)**: Desktop coder pane header (BooCode label + [+] [×]) lives in `Workspace.tsx` gated by `isCoder && !isMobile`. Mobile coder controls (● ×) live in `Session.tsx` header row next to `MobileTabSwitcher`/`NewPaneMenu`. `AgentComposerBar` (provider/mode/model pickers) renders inside `CoderPane.tsx` on both. The ● status dot is passed via `connected` prop from CoderPane to AgentComposerBar.
- **MessageBubble shared between BooChat and BooCoder** (`components/MessageBubble.tsx`): accepts optional `actions?: MessageActions` callbacks (onRegenerate, onResend, onFork, onDelete) and `hideActions?: ('fork'|'delete'|'openInPane')[]`. Defaults use BooChat API; CoderPane overrides via `CoderMessageList` props. `CoderTextBubble` was removed. **`CoderMessageList` passes `CoderMessageWire as unknown as Message`** — the coder wire shape lacks `metadata`/`kind`/`summary`, so those fields are `undefined` (not `null`) on coder messages. Null-guards on any `Message` field MUST use loose `!= null`, not strict `!== null` (`undefined !== null` is `true``.kind` throws → blank-screen crash). The `as unknown as` cast hides this from tsc; build + typecheck pass while runtime crashes.
- **llama-sidecar** (`/opt/forks/llama-sidecar/`): Go daemon for per-agent llama-server process pool. Cross-compile: `GOOS=windows GOARCH=amd64 /snap/go/current/bin/go build -o bin/llama-sidecar.exe ./cmd/llama-sidecar`. Gitea: `indifferentketchup/llama-sidecar`. Windows child process gotchas: use `context.Background()` for child lifetime (not request ctx), `os.Open(os.DevNull)` for stdin, `os.Pipe()` for stdout with drain goroutine, `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` creation flags. SSH to sam-desktop: `ssh samki@100.101.41.16`; use `schtasks` for persistent process spawning (SSH `start /B` doesn't survive session close).

View File

@@ -16,6 +16,7 @@
"@agentclientprotocol/sdk": "^0.22.1",
"@boocode/server": "workspace:*",
"@fastify/static": "^7.0.4",
"@opencode-ai/sdk": "~1.15.0",
"@fastify/websocket": "^10.0.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"fastify": "^4.28.1",

View File

@@ -33,6 +33,7 @@ import { registerProviderRoutes } from './routes/providers.js';
import { registerWebSocket } from './routes/ws.js';
// Phase 4: dispatcher + agent probe
import { createDispatcher } from './services/dispatcher.js';
import { agentPool } from './services/agent-pool.js';
import { probeAgents } from './services/agent-probe.js';
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
import { setPermissionHooks } from './services/permission-waiter.js';
@@ -178,7 +179,12 @@ async function main() {
// Phase 4: dispatcher — polls tasks table and runs inference
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
dispatcher.start();
app.addHook('onClose', () => dispatcher.stop());
app.addHook('onClose', async () => {
// stop() first so in-flight dispatcher turns settle, then drain the pool.
// Pool is empty in Phase 0 (nothing spawns yet) — dispose() is inert.
await dispatcher.stop();
await agentPool.dispose();
});
// Register routes
registerMessageRoutes(app, sql, broker, inferenceApi);

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import { existsSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { registerProviderRoutes } from '../providers.js';
import { load } from '../../services/provider-config.js';
import { loadProviderConfig } from '../../services/provider-config-registry.js';
import { clearProviderSnapshotCache } from '../../services/provider-snapshot.js';
import type { Config } from '../../config.js';
import type { Sql } from '../../db.js';
/** Minimal sql stub: available_agents reads return []. */
function mockSql(): Sql {
return vi.fn((strings: TemplateStringsArray) => {
const q = strings.join('');
if (q.includes('available_agents')) return Promise.resolve([]);
return Promise.resolve([]);
}) as unknown as Sql;
}
let tmpCounter = 0;
function freshPath(): string {
tmpCounter += 1;
return join(tmpdir(), `coder-providers-routes-${process.pid}-${tmpCounter}.json`);
}
function buildApp(providersPath: string): FastifyInstance {
const app = Fastify();
// Mirror index.ts: tolerate empty JSON bodies.
app.removeContentTypeParser(['application/json']);
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
const str = (body as string) ?? '';
if (str.trim().length === 0) return done(null, {});
try {
done(null, JSON.parse(str));
} catch (err) {
done(err as Error, undefined);
}
});
const config = {
CODER_PROVIDERS_PATH: providersPath,
LLAMA_SWAP_URL: 'http://llama-swap.test',
PROVIDER_PROBE_TTL_MS: 86_400_000,
} as unknown as Config;
registerProviderRoutes(app, mockSql(), config);
return app;
}
const JSON_HEADERS = { 'content-type': 'application/json' };
const createdPaths: string[] = [];
beforeEach(() => {
clearProviderSnapshotCache();
loadProviderConfig('/nonexistent-coder-providers.json'); // reset registry to built-ins
vi.restoreAllMocks();
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('no network in test')));
});
afterEach(() => {
for (const p of createdPaths.splice(0)) {
try {
rmSync(p, { force: true });
} catch {
/* ignore */
}
}
});
describe('GET /api/providers/config', () => {
it('returns the current config file (built-ins-only when missing)', async () => {
const path = freshPath();
createdPaths.push(path);
const app = buildApp(path);
const res = await app.inject({ method: 'GET', url: '/api/providers/config' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ providers: {} });
await app.close();
});
it('reflects an existing file', async () => {
const path = freshPath();
createdPaths.push(path);
writeFileSync(path, JSON.stringify({ providers: { goose: { enabled: false } } }));
const app = buildApp(path);
const res = await app.inject({ method: 'GET', url: '/api/providers/config' });
expect(res.json()).toEqual({ providers: { goose: { enabled: false } } });
await app.close();
});
});
describe('PATCH /api/providers/config', () => {
it('valid patch → 200, writes the merged file (order: validate→save→reload→clear)', async () => {
const path = freshPath();
createdPaths.push(path);
writeFileSync(path, JSON.stringify({ providers: { goose: { label: 'Goose' } } }));
const app = buildApp(path);
const res = await app.inject({
method: 'PATCH',
url: '/api/providers/config',
headers: JSON_HEADERS,
payload: JSON.stringify({ providers: { opencode: { enabled: false } } }),
});
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ ok: true });
// File written + merged (goose untouched, opencode added).
const onDisk = load(path);
expect(onDisk.providers).toEqual({
goose: { label: 'Goose' },
opencode: { enabled: false },
});
await app.close();
});
it('null value deletes the override', async () => {
const path = freshPath();
createdPaths.push(path);
writeFileSync(path, JSON.stringify({ providers: { goose: { enabled: false }, opencode: { enabled: false } } }));
const app = buildApp(path);
const res = await app.inject({
method: 'PATCH',
url: '/api/providers/config',
headers: JSON_HEADERS,
payload: JSON.stringify({ providers: { goose: null } }),
});
expect(res.statusCode).toBe(200);
expect(load(path).providers).toEqual({ opencode: { enabled: false } });
await app.close();
});
it('INVALID body → 422 and the file is NOT written (validate before save)', async () => {
const path = freshPath();
createdPaths.push(path);
const before = JSON.stringify({ providers: { goose: { enabled: true } } });
writeFileSync(path, before);
const app = buildApp(path);
const res = await app.inject({
method: 'PATCH',
url: '/api/providers/config',
headers: JSON_HEADERS,
payload: JSON.stringify({ providers: { goose: { enabled: 'yes' } } }), // bad type
});
expect(res.statusCode).toBe(422);
// File must be byte-for-byte unchanged — nothing written on a 422.
expect(readFileSync(path, 'utf8')).toBe(before);
await app.close();
});
it('save failure → 500 and the file is NOT created (no state divergence)', async () => {
const path = join(tmpdir(), `no-such-dir-${process.pid}-${Date.now()}`, 'coder-providers.json');
const app = buildApp(path);
const res = await app.inject({
method: 'PATCH',
url: '/api/providers/config',
headers: JSON_HEADERS,
payload: JSON.stringify({ providers: { goose: { enabled: false } } }),
});
expect(res.statusCode).toBe(500);
expect(existsSync(path)).toBe(false);
await app.close();
});
});
describe('POST /api/providers/refresh', () => {
it('no body → refreshes all registered providers', async () => {
const app = buildApp(freshPath());
const res = await app.inject({ method: 'POST', url: '/api/providers/refresh' });
expect(res.statusCode).toBe(200);
expect(res.json().refreshed).toBeGreaterThan(0);
await app.close();
});
it('subset body → refreshed count reflects only the requested providers', async () => {
const app = buildApp(freshPath());
const res = await app.inject({
method: 'POST',
url: '/api/providers/refresh',
headers: JSON_HEADERS,
payload: JSON.stringify({ providers: ['boocode'] }),
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ refreshed: 1 });
await app.close();
});
});
describe('GET /api/providers/:id/diagnostic', () => {
it('known provider → 200 JSON { diagnostic }', async () => {
const app = buildApp(freshPath());
const res = await app.inject({ method: 'GET', url: '/api/providers/boocode/diagnostic' });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('application/json');
expect(res.json().diagnostic).toContain('provider: boocode');
await app.close();
});
it('unknown provider → 404', async () => {
const app = buildApp(freshPath());
const res = await app.inject({ method: 'GET', url: '/api/providers/nope/diagnostic' });
expect(res.statusCode).toBe(404);
await app.close();
});
});

View File

@@ -1,7 +1,29 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import { getProviderSnapshot, clearProviderSnapshotCache } from '../services/provider-snapshot.js';
import {
getProviderSnapshot,
clearProviderSnapshotCache,
peekSnapshotEntry,
} from '../services/provider-snapshot.js';
import {
load,
save,
CoderProvidersFileSchema,
ProviderConfigPatchSchema,
mergeProviderConfigPatch,
} from '../services/provider-config.js';
import {
reloadProviderConfig,
getResolvedRegistry,
} from '../services/provider-config-registry.js';
import {
getProviderDiagnostic,
type DiagnosticAgentRow,
} from '../services/provider-diagnostic.js';
const RefreshBodySchema = z.object({ providers: z.array(z.string()).optional() });
export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
app.get<{ Querystring: { cwd?: string } }>('/api/providers/snapshot', async (req, _reply) => {
@@ -9,9 +31,97 @@ export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: C
return getProviderSnapshot(sql, config, cwd);
});
app.post('/api/providers/refresh', async (_req, _reply) => {
// 4.1 — current loaded config file (raw CoderProvidersFile, not the resolved registry).
app.get('/api/providers/config', async (_req, _reply) => {
return load(config.CODER_PROVIDERS_PATH);
});
// 4.2 — patch the config file (design.md §6.2). Strict order is the whole
// correctness story: validate → save → reload → clear. A malformed body or an
// invalid merged result returns 422 and NEVER writes; a save failure returns
// 500 and leaves in-memory state untouched (no file/registry divergence).
app.patch('/api/providers/config', async (req, reply) => {
// 1. Validate the PATCH body shape (malformed → 422, never reaches merge).
const parsed = ProviderConfigPatchSchema.safeParse(req.body);
if (!parsed.success) {
return reply.code(422).send({
error: 'invalid provider config patch',
issues: parsed.error.flatten(),
});
}
// 2. Shallow per-id merge over the current file (null deletes; object replaces).
const current = load(config.CODER_PROVIDERS_PATH);
const merged = mergeProviderConfigPatch(current, parsed.data);
// 3. Validate the merged result — refuse to write a config that won't load.
const validated = CoderProvidersFileSchema.safeParse(merged);
if (!validated.success) {
return reply.code(422).send({
error: 'merged provider config is invalid',
issues: validated.error.flatten(),
});
}
// 4. Persist. If save throws, STOP here — do NOT reload/clear, so the file on
// disk and the in-memory resolved registry can never diverge.
try {
save(config.CODER_PROVIDERS_PATH, validated.data);
} catch (err) {
req.log.error(
{ err: err instanceof Error ? err.message : String(err), path: config.CODER_PROVIDERS_PATH },
'provider-config: save failed — in-memory state untouched',
);
return reply.code(500).send({ error: 'failed to write provider config' });
}
// 5 + 6. Rebuild the in-memory resolved registry from the new file, then drop
// the snapshot cache so the next /snapshot reflects the change.
reloadProviderConfig();
clearProviderSnapshotCache();
// 7. Return the new config (per §6.2 `{ ok: true }`, plus the merged providers
// so the client can update without a follow-up GET).
return { ok: true, providers: validated.data.providers };
});
// 4.3 — force a cold probe. Optional { providers?: string[] } narrows the
// reported subset (design.md §6.3 Paseo pattern). The force=true snapshot is
// the only existing re-probe primitive (per-provider force would be a
// snapshot-internal change, out of Phase 4 scope), so the probe runs for all
// installed providers; the `refreshed` count reflects the requested subset.
app.post('/api/providers/refresh', async (req, reply) => {
const parsed = RefreshBodySchema.safeParse(req.body ?? {});
if (!parsed.success) {
return reply.code(422).send({ error: 'invalid refresh body', issues: parsed.error.flatten() });
}
const subset = parsed.data.providers;
clearProviderSnapshotCache();
const entries = await getProviderSnapshot(sql, config, undefined, true);
return { refreshed: entries.length };
const refreshed =
subset && subset.length > 0
? entries.filter((e) => subset.includes(e.name)).length
: entries.length;
return { refreshed };
});
// 4.4 — per-provider diagnostic (design.md §6.4 → JSON `{ diagnostic: string }`).
// Read-only: reports cached state (resolved def + available_agents row + warm
// snapshot cache for the last probe error) plus a `which` PATH check. No probe
// spawn. The report itself is a plaintext block (§8); the route wraps it as JSON.
app.get<{ Params: { id: string } }>('/api/providers/:id/diagnostic', async (req, reply) => {
const id = req.params.id;
const resolved = getResolvedRegistry().get(id);
if (!resolved) {
return reply.code(404).send({ error: `unknown provider '${id}'` });
}
const rows = await sql<DiagnosticAgentRow[]>`
SELECT name, install_path, supports_acp, models, last_probed_at
FROM available_agents WHERE name = ${id}
`;
const report = await getProviderDiagnostic(resolved, rows[0], {
cachedEntry: peekSnapshotEntry(id),
});
return { diagnostic: report };
});
}

View File

@@ -76,6 +76,60 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT;
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB;
-- v2.6: one shared worktree per session (all agents/panes in the session operate in it).
CREATE TABLE IF NOT EXISTS session_worktrees (
session_id UUID PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
worktree_path TEXT NOT NULL,
base_commit TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
-- Migrate existing FK to CASCADE (idempotent: drops the old constraint if present).
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'session_worktrees_session_id_fkey'
AND confdeltype <> 'c'
) THEN
ALTER TABLE session_worktrees DROP CONSTRAINT session_worktrees_session_id_fkey;
ALTER TABLE session_worktrees ADD CONSTRAINT session_worktrees_session_id_fkey
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
END IF;
END $$;
-- v2.6: one backend session per (session, agent); resumed on switch-back.
CREATE TABLE IF NOT EXISTS agent_sessions (
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
agent TEXT NOT NULL,
backend TEXT NOT NULL,
agent_session_id TEXT,
server_port INTEGER,
status TEXT NOT NULL DEFAULT 'idle',
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'))
);
-- Migrate existing agent_sessions FK to CASCADE.
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'agent_sessions_session_id_fkey'
AND confdeltype <> 'c'
) THEN
ALTER TABLE agent_sessions DROP CONSTRAINT agent_sessions_session_id_fkey;
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_session_id_fkey
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
END IF;
END $$;
-- v2.6: config fingerprint for stale-session detection (auto-recover on model change).
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS config_hash TEXT;
-- v2.6: attribution for DiffPanel badges (Phase 1 UX reads this).
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;
-- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes,
-- new_task tool, arena, MCP server) fires pg_notify('tasks_new') in the same
-- transaction, so the dispatcher reacts immediately instead of waiting for the

View File

@@ -0,0 +1,50 @@
import { describe, it, expect, afterEach } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { readWorktreeTextFile, writeWorktreeTextFile } from '../acp-client-fs.js';
const created: string[] = [];
function freshWorktree(): string {
const wt = mkdtempSync(join(tmpdir(), 'acp-wt-'));
created.push(wt);
return wt;
}
afterEach(() => {
for (const d of created.splice(0)) {
try {
rmSync(d, { recursive: true, force: true });
rmSync(`${d}-evil`, { recursive: true, force: true });
} catch {
/* ignore */
}
}
});
describe('acp-client-fs worktree scoping', () => {
it('writes then reads a file inside the worktree', async () => {
const wt = freshWorktree();
await writeWorktreeTextFile(wt, 'sub/dir/note.txt', 'hello');
expect(await readWorktreeTextFile(wt, 'sub/dir/note.txt')).toBe('hello');
});
it('rejects ../ traversal on read', async () => {
const wt = freshWorktree();
await expect(readWorktreeTextFile(wt, '../../etc/passwd')).rejects.toThrow(/escapes worktree/);
});
it('rejects ../ traversal on write', async () => {
const wt = freshWorktree();
await expect(writeWorktreeTextFile(wt, '../escape.txt', 'x')).rejects.toThrow(/escapes worktree/);
});
it('rejects a sibling-prefix path (the unbounded-startsWith bug)', async () => {
const wt = freshWorktree();
// Absolute path that shares the worktree as a STRING prefix but is a sibling
// dir: `<wt>-evil/...`. A bare `startsWith(<wt>)` wrongly admits it.
await expect(readWorktreeTextFile(wt, `${wt}-evil/secret.txt`)).rejects.toThrow(/escapes worktree/);
await expect(writeWorktreeTextFile(wt, `${wt}-evil/secret.txt`, 'x')).rejects.toThrow(
/escapes worktree/,
);
});
});

View File

@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import {
mergeProviderConfigPatch,
ProviderConfigPatchSchema,
CoderProvidersFileSchema,
type CoderProvidersFile,
} from '../provider-config.js';
describe('ProviderConfigPatchSchema', () => {
it('accepts a per-provider override patch', () => {
const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: { enabled: false } } });
expect(parsed.success).toBe(true);
});
it('accepts a null value (delete-the-override sentinel)', () => {
const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: null } });
expect(parsed.success).toBe(true);
});
it('defaults providers to {} on an empty body', () => {
const parsed = ProviderConfigPatchSchema.safeParse({});
expect(parsed.success).toBe(true);
if (parsed.success) expect(parsed.data.providers).toEqual({});
});
it('rejects a malformed override (wrong field type)', () => {
const parsed = ProviderConfigPatchSchema.safeParse({ providers: { goose: { enabled: 'yes' } } });
expect(parsed.success).toBe(false);
});
it('rejects a non-object providers map', () => {
const parsed = ProviderConfigPatchSchema.safeParse({ providers: 123 });
expect(parsed.success).toBe(false);
});
});
describe('mergeProviderConfigPatch', () => {
const current: CoderProvidersFile = {
providers: {
goose: { enabled: true, label: 'Goose' },
opencode: { enabled: true },
},
};
it('replaces an existing override object wholesale (not deep-merge)', () => {
const merged = mergeProviderConfigPatch(current, { providers: { goose: { enabled: false } } });
// Whole override replaced — the prior `label` is gone, only `enabled` remains.
expect(merged.providers.goose).toEqual({ enabled: false });
});
it('adds a brand-new override id', () => {
const merged = mergeProviderConfigPatch(current, {
providers: { 'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp'] } },
});
expect(merged.providers['amp-acp']).toEqual({ extends: 'acp', label: 'Amp', command: ['amp-acp'] });
});
it('deletes an override when the value is null', () => {
const merged = mergeProviderConfigPatch(current, { providers: { goose: null } });
expect(merged.providers.goose).toBeUndefined();
expect(Object.keys(merged.providers)).toEqual(['opencode']);
});
it('leaves ids absent from the patch untouched', () => {
const merged = mergeProviderConfigPatch(current, { providers: { goose: { enabled: false } } });
expect(merged.providers.opencode).toEqual({ enabled: true });
});
it('does not mutate the input config', () => {
const snapshot = JSON.parse(JSON.stringify(current));
mergeProviderConfigPatch(current, { providers: { goose: null, opencode: { enabled: false } } });
expect(current).toEqual(snapshot);
});
it('empty patch returns an equivalent config', () => {
const merged = mergeProviderConfigPatch(current, { providers: {} });
expect(merged).toEqual(current);
});
});
describe('CoderProvidersFileSchema (validate-before-save guard)', () => {
it('accepts a clean merged config', () => {
const merged = mergeProviderConfigPatch(
{ providers: {} },
{ providers: { goose: { enabled: false } } },
);
expect(CoderProvidersFileSchema.safeParse(merged).success).toBe(true);
});
it('rejects a config carrying an invalid override (never written)', () => {
// A merged object that somehow holds a bad override must fail validation
// so the PATCH route returns 422 and never calls save().
const invalid = { providers: { goose: { enabled: 'nope' } } };
expect(CoderProvidersFileSchema.safeParse(invalid).success).toBe(false);
});
});

View File

@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import { getProviderDiagnostic, type DiagnosticAgentRow } from '../provider-diagnostic.js';
import { buildResolvedRegistry } from '../provider-config-registry.js';
import { PROVIDERS } from '../provider-registry.js';
import type { ProviderSnapshotEntry } from '../provider-types.js';
const registry = buildResolvedRegistry(PROVIDERS, {
providers: {
goose: { enabled: false },
'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp', '--acp'] },
},
});
const alwaysAvailable = () => Promise.resolve(true);
const neverAvailable = () => Promise.resolve(false);
describe('getProviderDiagnostic', () => {
it('reports a disabled built-in (enabled:false, no install)', async () => {
const report = await getProviderDiagnostic(registry.get('goose')!, undefined, {
checkAvailable: neverAvailable,
});
expect(report).toContain('provider: goose');
expect(report).toContain('enabled: false');
expect(report).toContain('installed: false');
expect(report).toMatch(/command_available:\s*false/);
});
it('reports an installed built-in with its install_path, last_probed_at, model count', async () => {
const agentRow: DiagnosticAgentRow = {
name: 'opencode',
install_path: '/usr/bin/opencode',
supports_acp: true,
models: [
{ id: 'm1', label: 'M1' },
{ id: 'm2', label: 'M2' },
],
last_probed_at: '2026-05-29T12:00:00.000Z',
};
const report = await getProviderDiagnostic(registry.get('opencode')!, agentRow, {
checkAvailable: alwaysAvailable,
});
expect(report).toContain('install_path: /usr/bin/opencode');
expect(report).toContain('2026-05-29T12:00:00.000Z');
expect(report).toContain('installed: true');
expect(report).toMatch(/models_in_db:\s*2/);
expect(report).toMatch(/command_available:\s*true/);
});
it('reports a custom ACP launch command + its binary', async () => {
const report = await getProviderDiagnostic(registry.get('amp-acp')!, undefined, {
checkAvailable: alwaysAvailable,
});
expect(report).toContain('provider: amp-acp');
expect(report).toContain('amp-acp --acp');
expect(report).toContain('customAcp: true');
});
it('surfaces the last probe error from a cached snapshot entry', async () => {
const cachedEntry: ProviderSnapshotEntry = {
name: 'opencode',
label: 'OpenCode',
transport: 'acp',
status: 'error',
enabled: true,
installed: true,
models: [],
modes: [],
defaultModeId: null,
commands: [],
error: 'ACP initialize timed out',
};
const report = await getProviderDiagnostic(registry.get('opencode')!, undefined, {
cachedEntry,
checkAvailable: alwaysAvailable,
});
expect(report).toContain('ACP initialize timed out');
});
it('reports no error when none is cached', async () => {
const report = await getProviderDiagnostic(registry.get('opencode')!, undefined, {
checkAvailable: alwaysAvailable,
});
expect(report).toMatch(/last_probe_error:\s*\(none/);
});
});

View File

@@ -7,6 +7,7 @@ import {
prefixLlamaSwapModels,
clearProviderSnapshotCache,
getProviderSnapshot,
peekSnapshotEntry,
} from '../provider-snapshot.js';
import { loadProviderConfig } from '../provider-config-registry.js';
@@ -324,6 +325,18 @@ describe('getProviderSnapshot', () => {
expect(claude!.models.find((m) => m.id === 'claude-opus-4-8')?.thinkingOptions?.length).toBeGreaterThan(0);
});
it('peekSnapshotEntry returns a cached entry (read-only) and undefined when cold/unknown', async () => {
loadConfigFixture({});
// Cold cache → undefined (no build triggered).
expect(peekSnapshotEntry('boocode', '/tmp/peek')).toBeUndefined();
const sql = mockSql([]);
await getProviderSnapshot(sql, config, '/tmp/peek', true);
expect(peekSnapshotEntry('boocode', '/tmp/peek')?.name).toBe('boocode');
expect(peekSnapshotEntry('does-not-exist', '/tmp/peek')).toBeUndefined();
});
it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => {
loadConfigFixture({});
mockProbe.mockResolvedValue({

View File

@@ -1,5 +1,25 @@
import { promises as fs } from 'node:fs';
import { dirname, isAbsolute, join, resolve } from 'node:path';
import { dirname, isAbsolute, resolve, sep } from 'node:path';
/**
* Resolve an ACP-supplied path against the agent worktree and reject anything
* that escapes it. Mirrors `write_guard.ts`'s check: `resolve()` to normalize
* `../` segments, then a **separator-bounded** prefix test — a bare
* `startsWith(root)` wrongly admits a sibling dir like `<root>-evil/...`.
*
* No realpath (consistent with `write_guard.ts`: the target may not exist yet on
* write). This is a containment guard for the ACP fs bridge, not a hard trust
* boundary — the agent process already runs with host FS access; symlink-swap
* hardening (`O_NOFOLLOW`/realpath) is out of scope here.
*/
function resolveInWorktree(worktreePath: string, filePath: string): string {
const root = resolve(worktreePath);
const absolute = isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath);
if (absolute !== root && !absolute.startsWith(root + sep)) {
throw new Error(`path escapes worktree: ${filePath}`);
}
return absolute;
}
/** Resolve an ACP path against the agent worktree and read a slice of lines. */
export async function readWorktreeTextFile(
@@ -8,10 +28,7 @@ export async function readWorktreeTextFile(
line?: number | null,
limit?: number | null,
): Promise<string> {
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
if (!absolute.startsWith(resolve(worktreePath))) {
throw new Error(`path escapes worktree: ${filePath}`);
}
const absolute = resolveInWorktree(worktreePath, filePath);
const raw = await fs.readFile(absolute, 'utf8');
if (!line && !limit) return raw;
const lines = raw.split(/\r?\n/);
@@ -26,10 +43,7 @@ export async function writeWorktreeTextFile(
filePath: string,
content: string,
): Promise<void> {
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
if (!absolute.startsWith(resolve(worktreePath))) {
throw new Error(`path escapes worktree: ${filePath}`);
}
const absolute = resolveInWorktree(worktreePath, filePath);
await fs.mkdir(dirname(absolute), { recursive: true });
await fs.writeFile(absolute, content, 'utf8');
}

View File

@@ -0,0 +1,85 @@
/**
* v2.6 — AgentBackend abstraction (Phase 0 scaffold; types only, zero runtime logic).
*
* The core abstraction for persistent agent sessions. Two implementations land
* later: `OpenCodeServerBackend` (Phase 1, opencode HTTP server) and
* `WarmAcpBackend` (Phase 2, long-lived ACP process). Backends emit
* transport-agnostic `AgentEvent`s; the dispatcher maps them to WS frames.
*
* Nothing imports this file yet — it must compile standalone.
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2.
*/
import type { AcpToolSnapshot } from './acp-tool-snapshot.js';
import type { AgentCommand } from './provider-types.js';
/** Backend transport kind. Mirrors `agent_sessions.backend` CHECK in schema.sql. */
export type AgentBackendKind = 'opencode_server' | 'acp_warm';
/**
* Normalized, transport-agnostic events a backend emits during a turn (§2).
* Derived from acp-dispatch's session-update handling, but WITHOUT the WS
* envelope (message_id/chat_id) — the dispatcher owns frame mapping.
*
* `tool_call` vs `tool_update` are kept distinct on purpose: acp-dispatch
* currently merges both into one snapshot frame, but opencode's SSE
* distinguishes tool-start from tool-result, so the contract carries both.
* `commands` mirrors the ACP `available_commands_update` path (v2.5.10).
*/
export type AgentEvent =
| { type: 'text'; text: string }
| { type: 'reasoning'; text: string }
| { type: 'tool_call'; toolCall: AcpToolSnapshot }
| { type: 'tool_update'; toolCall: AcpToolSnapshot }
| { type: 'commands'; commands: AgentCommand[] };
/** Params to establish (or look up) a backend session (§2). */
export interface EnsureSessionOpts {
agent: string;
/** Resolved model id. */
model: string;
/** Shared per-session worktree (one per `sessions.id`, not per pane). */
worktreePath: string;
projectId: string;
}
/** Opaque handle to a live backend session, persisted to `agent_sessions` (§2). */
export interface AgentSessionHandle {
sessionId: string;
agent: string;
backend: AgentBackendKind;
/** Provider's own session id (resume token); null until the backend assigns one. */
agentSessionId: string | null;
/** opencode HTTP server port; null for ACP backends. */
serverPort: number | null;
}
/** Per-turn context passed to `prompt` (§2). */
export interface PromptCtx {
worktreePath: string;
model: string;
signal: AbortSignal;
onEvent: (e: AgentEvent) => void;
}
/** Result of a completed turn (§2). Diff/persist happen outside the backend. */
export interface TurnResult {
ok: boolean;
error?: string;
}
/**
* The core backend abstraction (§2). Implementations: OpenCodeServerBackend
* (Phase 1), WarmAcpBackend (Phase 2).
*/
export interface AgentBackend {
/** Lazy: spawn server / warm process if not already up for this (session, agent). §2 */
ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle>;
/** Send a prompt; stream events via ctx.onEvent; resolves when the turn completes. §2 */
prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise<TurnResult>;
/** Graceful teardown of one session (session close or idle timeout). §2 */
closeSession(handle: AgentSessionHandle): Promise<void>;
/** Full teardown — kills all spawned servers/processes. §2 */
dispose(): Promise<void>;
/** Liveness for health endpoint + dispatcher fallback decision. §2 */
health(): 'up' | 'down';
}

View File

@@ -0,0 +1,44 @@
/**
* v2.6 — AgentPool (Phase 0 scaffold).
*
* Lazy get-or-create registry of `AgentBackend` instances keyed by
* `${sessionId}:${agent}`. Phase 0 ships the skeleton only: an in-memory Map,
* lookup / register / health, and clean disposal wired to the server's onClose.
* Spawning lands in Phase 1/2; nothing populates the map yet.
*
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2.
*/
import type { AgentBackend } from './agent-backend.js';
export class AgentPool {
private readonly backends = new Map<string, AgentBackend>();
private key(sessionId: string, agent: string): string {
return `${sessionId}:${agent}`;
}
/** Map lookup only. Spawning is Phase 1/2 — never creates here. */
get(sessionId: string, agent: string): AgentBackend | undefined {
return this.backends.get(this.key(sessionId, agent));
}
/** Store a backend instance for this (session, agent). */
register(sessionId: string, agent: string, backend: AgentBackend): void {
this.backends.set(this.key(sessionId, agent), backend);
}
/** Summary for the health endpoint. */
health(): { size: number } {
return { size: this.backends.size };
}
/** Dispose every backend and clear the map. Tolerates throwing backends. */
async dispose(): Promise<void> {
const entries = [...this.backends.values()];
this.backends.clear();
await Promise.allSettled(entries.map((b) => b.dispose()));
}
}
/** Single shared instance — referenced only by the server's onClose hook in Phase 0. */
export const agentPool = new AgentPool();

View File

@@ -4,7 +4,7 @@ import { exec as execCb, execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
import { PROVIDERS_BY_NAME } from './provider-registry.js';
import { resolveAcpProbeBinaries } from './acp-spawn.js';
import { clearProviderSnapshotCache } from './provider-snapshot.js';
import { clearProviderSnapshotCache, fetchLlamaSwapModels, prefixLlamaSwapModels } from './provider-snapshot.js';
import { readQwenSettingsModels } from './qwen-settings.js';
import { loadConfig } from '../config.js';
import { loadProviderConfig } from './provider-config-registry.js';
@@ -117,6 +117,15 @@ export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<voi
if (agentName === 'qwen') {
models = await readQwenSettingsModels();
}
if (providerDef?.mergeLlamaSwap) {
try {
const config = loadConfig();
const llamaModels = prefixLlamaSwapModels(await fetchLlamaSwapModels(config));
models = [...models, ...llamaModels];
} catch (err) {
log.warn({ agent: agentName, err: err instanceof Error ? err.message : String(err) }, 'agent-probe: llama-swap model fetch failed (non-fatal)');
}
}
}
const label = resolved.configLabel ?? resolved.label;

View File

@@ -0,0 +1,748 @@
/**
* v2.6 Phase 1 — OpenCodeServerBackend.
*
* Warm, multi-turn backend for the `opencode` agent. One `opencode serve` HTTP
* server per BooCoder process; one opencode session per BooCode session (resumed
* on switch-back); a single SSE read loop demuxes all sessions' events.
*
* Implements the Phase 0 `AgentBackend` interface. Emits transport-agnostic
* `AgentEvent`s — the dispatcher (Phase 1.7, NOT wired in this batch) maps them
* to WS frames. No dispatcher/route references this file yet.
*
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2 / §2a.
* SDK shapes verified by direct read of @opencode-ai/sdk@1.15.12 dist .d.ts:
* - client methods take FLATTENED params (sessionID/directory/body all inline),
* not {path,query,body}. create→{directory}, promptAsync→{sessionID,directory,
* parts,model}, abort→{sessionID,directory}. model is {providerID,modelID}.
* - client.event() resolves to { stream: AsyncGenerator<GlobalEvent> }; the
* real event is chunk.payload (discriminate on chunk.payload.type).
* - promptAsync is fire-and-forget (204); the turn completes via a
* 'session.idle' event for that opencode session id.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { createHash } from 'node:crypto';
import { createServer } from 'node:net';
import type { FastifyBaseLogger } from 'fastify';
import {
createOpencodeClient,
type OpencodeClient,
type Event,
type Part,
type ToolPart,
type ToolState,
type AssistantMessage,
} from '@opencode-ai/sdk/v2/client';
import type { ToolCallStatus } from '@agentclientprotocol/sdk';
import type { Sql } from '../../db.js';
import type { AcpToolSnapshot } from '../acp-tool-snapshot.js';
import type {
AgentBackend,
AgentEvent,
AgentSessionHandle,
EnsureSessionOpts,
PromptCtx,
TurnResult,
} from '../agent-backend.js';
const READY_TIMEOUT_MS = 30_000;
const SSE_RECONNECT_DELAY_MS = 1_000;
/**
* No-activity backstop for an in-flight turn. opencode streams reasoning/text/tool
* deltas continuously while working, so "zero events for this long" means the turn
* is wedged or its terminal event (session.idle) was lost (see the reconnect race
* below). Generous so a legitimately slow turn never trips it.
*/
const TURN_INACTIVITY_MS = 180_000;
/** One in-flight turn's emitter + completion settler. */
interface TurnState {
onEvent: (e: AgentEvent) => void;
settle: (r: TurnResult) => void;
}
/** Per-(opencode session) demux state. dedup sets scoped here, cleared per turn. */
interface SessionState {
boocodeSessionId: string;
agentSessionId: string;
/** Worktree directory for SDK `directory` routing; refreshed each turn from ctx. */
worktreePath: string;
/** dedup gate: `${type}:${id}` added on delta, deleted-and-tested on updated. Cleared at turn end. */
streamedPartKeys: Set<string>;
/** partID → 'text' | 'reasoning', so a delta with a non-'reasoning' field is still classed right. Cleared at turn end. */
partTypeById: Map<string, string>;
activeTurn: TurnState | null;
/** Inactivity backstop timer for the active turn; null when no turn in flight. */
watchdog: ReturnType<typeof setTimeout> | null;
}
export interface OpenCodeServerBackendDeps {
sql: Sql;
log: FastifyBaseLogger;
/** Absolute path to the opencode binary (resolved from available_agents at wiring time, Phase 1.7). */
opencodeBinary: string;
}
export class OpenCodeServerBackend implements AgentBackend {
readonly backend = 'opencode_server' as const;
private readonly sql: Sql;
private readonly log: FastifyBaseLogger;
private readonly opencodeBinary: string;
private child: ChildProcess | null = null;
private client: OpencodeClient | null = null;
private port: number | null = null;
private up = false;
private serverStarting: Promise<void> | null = null;
private sseRunning = false;
/** opencode session id → demux state. Maintained by ensureSession; read by the SSE loop. */
private readonly byOpencodeId = new Map<string, SessionState>();
constructor(deps: OpenCodeServerBackendDeps) {
this.sql = deps.sql;
this.log = deps.log;
this.opencodeBinary = deps.opencodeBinary;
}
/** §2: liveness for the health endpoint + dispatcher fallback decision. */
health(): 'up' | 'down' {
return this.up ? 'up' : 'down';
}
// ─── Server lifecycle (1.2: spawn once + client + ready) ─────────────────────
/** Lazy: start the single server on first use. Idempotent — one server per backend. */
private ensureServer(): Promise<void> {
if (!this.serverStarting) this.serverStarting = this.startServer();
return this.serverStarting;
}
private async startServer(): Promise<void> {
const port = await freePort();
// Phase 1: run unsecured on loopback (opencode's documented default — serve.ts
// only WARNS when OPENCODE_SERVER_PASSWORD is unset). The real boundary is the
// 127.0.0.1 bind. Defense-in-depth basic-auth is deferred: the hey-api client's
// auth wiring + opencode's exact scheme must be confirmed against a live server
// first, else every request 401s. Recon explicitly said "do NOT block on it".
const child = spawn(this.opencodeBinary, ['serve', '--hostname', '127.0.0.1', '--port', String(port)], {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env },
});
this.child = child;
this.port = port;
// Child lifetime is the backend's (the pool's), NOT a request's. We never tie
// it to a per-turn abort signal. On unexpected exit we mark down + log; crash
// recovery is Phase 3.
child.on('exit', (code, signal) => {
this.up = false;
this.log.warn({ code, signal, port }, 'opencode-server: child exited (recovery is Phase 3)');
});
await waitForReady(child, READY_TIMEOUT_MS);
this.client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` });
this.up = true;
this.log.info({ port }, 'opencode-server: ready');
}
// ─── SSE read loop + demux + translate (1.3) + dedup (1.4) ───────────────────
/** Per-directory SSE subscription. opencode scopes events by directory (defaults
* to process.cwd if omitted) — so we must subscribe with the same directory used
* to create the session. Called from ensureSession; reconnects while up. */
private startEventLoop(directory: string): void {
if (this.sseRunning) return;
this.sseRunning = true;
this.sseDirectory = directory;
void this.runEventLoop(directory);
}
private sseDirectory: string | null = null;
private async runEventLoop(directory: string): Promise<void> {
while (this.up && this.client) {
try {
const sub = await this.client.event.subscribe({ directory });
for await (const ev of sub.stream) {
this.dispatchEvent(ev);
}
if (this.up) {
await this.reconcileInFlight();
await sleep(SSE_RECONNECT_DELAY_MS);
}
} catch (err) {
if (!this.up) break;
this.log.warn({ err: errMsg(err) }, 'opencode-server: event loop error; reconnecting');
await this.reconcileInFlight();
await sleep(SSE_RECONNECT_DELAY_MS);
}
}
this.sseRunning = false;
}
/** Demux one event to the owning session's active turn. Unknown/between-turns → drop. */
private dispatchEvent(ev: Event): void {
switch (ev.type) {
// ─── session.next.* — live streaming events (the primary path) ─────────
case 'session.next.text.delta': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
const cleaned = stripDcpTags(p.delta);
if (cleaned) st.activeTurn.onEvent({ type: 'text', text: cleaned });
return;
}
case 'session.next.reasoning.delta': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
st.activeTurn.onEvent({ type: 'reasoning', text: p.delta });
return;
}
case 'session.next.tool.called': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
const snap: AcpToolSnapshot = {
toolCallId: p.callID,
title: p.tool,
kind: null,
status: 'in_progress',
rawInput: p.input,
rawOutput: undefined,
};
st.activeTurn.onEvent({ type: 'tool_call', toolCall: snap });
return;
}
case 'session.next.tool.success': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
const output = p.content?.map((c) => ('text' in c ? (c as { text: string }).text : '')).join('') ?? '';
const snap: AcpToolSnapshot = {
toolCallId: p.callID,
title: p.callID,
kind: null,
status: 'completed',
rawInput: undefined,
rawOutput: output,
};
st.activeTurn.onEvent({ type: 'tool_update', toolCall: snap });
return;
}
case 'session.next.tool.failed': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
const snap: AcpToolSnapshot = {
toolCallId: p.callID,
title: p.callID,
kind: null,
status: 'failed',
rawInput: undefined,
rawOutput: errToString(p.error),
};
st.activeTurn.onEvent({ type: 'tool_update', toolCall: snap });
return;
}
// ─── message.part.* — terminal/post-hoc events (dedup gate) ────────────
case 'message.part.delta': {
const p = ev.properties;
const st = this.byOpencodeId.get(p.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
const isReasoning = p.field === 'reasoning' || st.partTypeById.get(p.partID) === 'reasoning';
if (isReasoning) {
st.streamedPartKeys.add(`reasoning:${p.partID}`);
st.activeTurn.onEvent({ type: 'reasoning', text: p.delta });
} else if (p.field === 'text') {
st.streamedPartKeys.add(`text:${p.partID}`);
const cleaned = stripDcpTags(p.delta);
if (cleaned) st.activeTurn.onEvent({ type: 'text', text: cleaned });
}
return;
}
case 'message.part.updated': {
const part = ev.properties.part;
const st = this.byOpencodeId.get(part.sessionID);
if (!st?.activeTurn) return;
this.bumpActivity(st);
this.handleUpdatedPart(part, st);
return;
}
// ─── lifecycle ─────────────────────────────────────────────────────────
case 'session.idle': {
this.byOpencodeId.get(ev.properties.sessionID)?.activeTurn?.settle({ ok: true });
return;
}
case 'session.error': {
const sid = ev.properties.sessionID;
if (!sid) return;
this.byOpencodeId.get(sid)?.activeTurn?.settle({ ok: false, error: errToString(ev.properties.error) });
return;
}
default:
return;
}
}
/** Terminal part: dedup gate for text/reasoning; tool parts → tool_call/tool_update. */
private handleUpdatedPart(part: Part, st: SessionState): void {
const turn = st.activeTurn;
if (!turn) return;
if (part.type === 'text' || part.type === 'reasoning') {
st.partTypeById.set(part.id, part.type);
const key = resolvePartDedupeKey(part, part.type);
if (key && st.streamedPartKeys.delete(key)) return; // already streamed via delta
const raw = part.text ?? '';
const text = part.type === 'text' ? stripDcpTags(raw) : raw;
if (text && part.time?.end != null) {
turn.onEvent({ type: part.type, text });
}
return;
}
if (part.type === 'tool') {
const snap = toolPartToSnapshot(part);
const status = part.state?.status;
// tool_call on start (pending/running), tool_update on terminal (completed/error).
// The current ACP path merges both into one frame; the contract keeps them
// distinct because opencode's SSE distinguishes start from result.
const event: AgentEvent =
status === 'completed' || status === 'error'
? { type: 'tool_update', toolCall: snap }
: { type: 'tool_call', toolCall: snap };
turn.onEvent(event);
return;
}
// NOTE: opencode's SSE payload union carries no available-commands event, so the
// AgentEvent 'commands' arm is intentionally never emitted here (1.3).
}
// ─── turn-completion resilience (watchdog + reconnect reconcile) ─────────────
/** Reset the inactivity backstop on any event routed to a session's active turn. */
private bumpActivity(st: SessionState): void {
if (!st.activeTurn) return;
if (st.watchdog) clearTimeout(st.watchdog);
st.watchdog = setTimeout(() => {
void this.onTurnStall(st);
}, TURN_INACTIVITY_MS);
st.watchdog.unref?.();
}
/** Watchdog fired: reconcile once; if the server says still-running we can't tell, so fail closed.
* Also mark the agent_sessions row crashed so a stale session isn't resumed next turn. */
private async onTurnStall(st: SessionState): Promise<void> {
const settled = await this.reconcile(st);
if (!settled) {
this.log.warn({ agentSessionId: st.agentSessionId }, 'opencode-server: turn stalled (no activity), failing + marking crashed');
await this.sql`
UPDATE agent_sessions SET status = 'crashed'
WHERE agent_session_id = ${st.agentSessionId}
`.catch(() => {});
st.activeTurn?.settle({ ok: false, error: 'turn timed out (no activity)' });
}
}
/** Reconcile every in-flight turn against the server (called after an SSE drop). */
private async reconcileInFlight(): Promise<void> {
const states = [...this.byOpencodeId.values()].filter((s) => s.activeTurn);
if (states.length === 0) return;
await Promise.allSettled(states.map((s) => this.reconcile(s)));
}
/**
* Ask the server whether this session's turn already finished — recovers a
* session.idle/error lost during an SSE gap. Returns true if it settled the turn.
* Inconclusive (still running / call failed) → false; the watchdog covers that.
*/
private async reconcile(st: SessionState): Promise<boolean> {
const turn = st.activeTurn;
if (!turn || !this.client) return false;
try {
const res = await this.client.session.messages({
sessionID: st.agentSessionId,
directory: st.worktreePath,
});
if (res.error || !res.data) return false;
let lastAssistant: AssistantMessage | undefined;
for (let i = res.data.length - 1; i >= 0; i--) {
const info = res.data[i]!.info;
if (info.role === 'assistant') {
lastAssistant = info;
break;
}
}
if (!lastAssistant) return false;
if (lastAssistant.error != null) {
turn.settle({ ok: false, error: errToString(lastAssistant.error) });
return true;
}
if (lastAssistant.time.completed != null) {
turn.settle({ ok: true });
return true;
}
return false; // still running — the live stream will deliver session.idle
} catch {
return false; // inconclusive — watchdog backstop covers it
}
}
// ─── ensureSession: create-or-resume against agent_sessions (1.5) ────────────
async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle> {
await this.ensureServer();
if (!this.client) throw new Error('opencode-server: client not ready after ensureServer');
const configHash = sessionConfigHash(opts.model);
const [row] = await this.sql<{ agent_session_id: string | null; status: string; config_hash: string | null }[]>`
SELECT agent_session_id, status, config_hash FROM agent_sessions
WHERE session_id = ${sessionId} AND agent = ${opts.agent}
`;
let agentSessionId = row?.agent_session_id ?? null;
// Don't resume crashed sessions or sessions whose config drifted (model change).
const shouldResume = agentSessionId
&& row!.status !== 'crashed'
&& (row!.config_hash == null || row!.config_hash === configHash);
if (!shouldResume) {
if (agentSessionId) {
this.log.info({ sessionId, oldStatus: row!.status, hashMatch: row!.config_hash === configHash },
'opencode-server: not resuming stale session, creating fresh');
this.byOpencodeId.delete(agentSessionId);
}
const created = await this.client.session.create({ directory: opts.worktreePath });
if (created.error || !created.data) {
throw new Error(`opencode-server: session.create failed: ${errToString(created.error)}`);
}
agentSessionId = created.data.id;
await this.sql`
INSERT INTO agent_sessions
(session_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash)
VALUES
(${sessionId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash})
ON CONFLICT (session_id, agent) DO UPDATE SET
backend = 'opencode_server',
agent_session_id = EXCLUDED.agent_session_id,
server_port = EXCLUDED.server_port,
status = 'active',
last_active_at = clock_timestamp(),
config_hash = EXCLUDED.config_hash
`;
} else {
await this.sql`
UPDATE agent_sessions
SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.port}, config_hash = ${configHash}
WHERE session_id = ${sessionId} AND agent = ${opts.agent}
`;
}
// Both branches above guarantee agentSessionId is non-null.
const ocSessionId = agentSessionId!;
// Start (or re-start) the SSE event loop scoped to this session's directory.
// opencode scopes events by the `directory` query param; without it events
// default to the server's CWD which doesn't match our worktree paths.
//
// KNOWN Phase 1 LIMITATION: one SSE stream at a time, scoped to a single
// directory. Under 1.9 concurrency, if two opencode sessions use different
// worktree directories simultaneously, re-subscribing for the second drops
// the first session's events (the watchdog backstop prevents a full hang,
// but streamed content is lost). Phase 2 should move to per-session SSE
// subscriptions or a directory-agnostic event path.
if (!this.sseRunning || this.sseDirectory !== opts.worktreePath) {
if (this.sseRunning && this.sseDirectory && this.sseDirectory !== opts.worktreePath) {
this.log.warn(
{ prev: this.sseDirectory, next: opts.worktreePath },
'opencode-server: SSE directory changed — concurrent sessions will lose events from the previous directory',
);
}
this.sseRunning = false;
this.startEventLoop(opts.worktreePath);
}
// Register / refresh the demux entry the SSE loop keys on. Preserve an existing
// entry (and any in-flight turn) — just refresh the routing fields.
const existing = this.byOpencodeId.get(ocSessionId);
if (existing) {
existing.boocodeSessionId = sessionId;
existing.worktreePath = opts.worktreePath;
} else {
this.byOpencodeId.set(ocSessionId, {
boocodeSessionId: sessionId,
agentSessionId: ocSessionId,
worktreePath: opts.worktreePath,
streamedPartKeys: new Set(),
partTypeById: new Map(),
activeTurn: null,
watchdog: null,
});
}
return {
sessionId,
agent: opts.agent,
backend: 'opencode_server',
agentSessionId: ocSessionId,
serverPort: this.port,
};
}
// ─── prompt: send one turn (1.6) ─────────────────────────────────────────────
async prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise<TurnResult> {
if (!this.client) throw new Error('opencode-server: client not ready');
const oc = handle.agentSessionId;
if (!oc) throw new Error('opencode-server: handle has no agentSessionId');
let state = this.byOpencodeId.get(oc);
if (!state) {
state = {
boocodeSessionId: handle.sessionId,
agentSessionId: oc,
worktreePath: ctx.worktreePath,
streamedPartKeys: new Set(),
partTypeById: new Map(),
activeTurn: null,
watchdog: null,
};
this.byOpencodeId.set(oc, state);
}
const session = state;
// Authoritative per-turn directory for SDK routing + reconcile.
session.worktreePath = ctx.worktreePath;
const client = this.client;
return await new Promise<TurnResult>((resolve) => {
let settled = false;
const cleanup = () => {
session.activeTurn = null;
if (session.watchdog) {
clearTimeout(session.watchdog);
session.watchdog = null;
}
session.streamedPartKeys.clear();
session.partTypeById.clear();
ctx.signal.removeEventListener('abort', onAbort);
};
const settle = (r: TurnResult) => {
if (settled) return;
settled = true;
cleanup();
resolve(r);
};
const onAbort = () => {
// Abort the turn only — never the server.
client.session.abort({ sessionID: oc, directory: ctx.worktreePath }).catch(() => {});
settle({ ok: false, error: 'aborted' });
};
session.activeTurn = { onEvent: ctx.onEvent, settle };
this.bumpActivity(session); // arm the inactivity backstop
if (ctx.signal.aborted) {
onAbort();
return;
}
ctx.signal.addEventListener('abort', onAbort, { once: true });
const model = parseModel(ctx.model);
client.session
.promptAsync({
sessionID: oc,
directory: ctx.worktreePath,
parts: [{ type: 'text', text: input }],
...(model ? { model } : {}),
})
.then((res) => {
// promptAsync is fire-and-forget (204); the turn completes via session.idle.
// Only a submission error settles here.
if (res.error) settle({ ok: false, error: errToString(res.error) });
})
.catch((err) => settle({ ok: false, error: errMsg(err) }));
});
}
// ─── teardown ────────────────────────────────────────────────────────────────
async closeSession(handle: AgentSessionHandle): Promise<void> {
if (handle.agentSessionId) this.byOpencodeId.delete(handle.agentSessionId);
await this.sql`
UPDATE agent_sessions SET status = 'closed'
WHERE session_id = ${handle.sessionId} AND agent = ${handle.agent}
`.catch(() => {});
}
async dispose(): Promise<void> {
this.up = false;
const child = this.child;
this.child = null;
this.client = null;
this.byOpencodeId.clear();
if (child && !child.killed) {
child.kill('SIGTERM');
const t = setTimeout(() => {
if (!child.killed) child.kill('SIGKILL');
}, 5_000);
t.unref();
}
}
}
// ─── helpers ──────────────────────────────────────────────────────────────────
/** BooCoder model string "provider/model" → opencode's structured {providerID, modelID}. */
function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined {
if (!model || !model.trim()) return undefined;
const trimmed = model.trim();
const idx = trimmed.indexOf('/');
if (idx > 0 && idx < trimmed.length - 1) {
return { providerID: trimmed.slice(0, idx), modelID: trimmed.slice(idx + 1) };
}
// No slash but non-empty → infer llama-swap (the only configured provider).
// Guard against bare '/' or trailing/leading slash.
if (idx < 0 && trimmed.length > 0) {
return { providerID: 'llama-swap', modelID: trimmed };
}
return undefined;
}
/** Ported verbatim from Paseo opencode-agent.ts: id → message-id fallback → null. */
function resolvePartDedupeKey(part: { id: string; messageID: string }, type: string): string | null {
if (part.id.trim().length > 0) return `${type}:${part.id}`;
if (part.messageID.trim().length > 0) return `${type}:message:${part.messageID}`;
return null;
}
/** opencode ToolPart → ACP-shaped snapshot (reuses the existing persist/render path). */
function toolPartToSnapshot(part: ToolPart): AcpToolSnapshot {
const state = part.state;
let rawInput: unknown;
let rawOutput: unknown;
let title: string | undefined;
if (state) {
if ('input' in state) rawInput = (state as { input?: unknown }).input;
if ('output' in state) rawOutput = (state as { output?: unknown }).output;
else if ('error' in state) rawOutput = (state as { error?: unknown }).error;
if ('title' in state) title = (state as { title?: string }).title;
}
return {
toolCallId: part.callID,
title: title ?? part.tool,
kind: null,
status: mapToolStatus(state?.status),
rawInput,
rawOutput,
};
}
function mapToolStatus(s: ToolState['status'] | undefined): ToolCallStatus | null {
switch (s) {
case 'pending':
return 'pending';
case 'running':
return 'in_progress';
case 'completed':
return 'completed';
case 'error':
return 'failed';
default:
return null;
}
}
/** Bind-probe an ephemeral port on loopback. */
function freePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = createServer();
srv.unref();
srv.on('error', reject);
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address();
if (addr && typeof addr === 'object') {
const { port } = addr;
srv.close(() => resolve(port));
} else {
srv.close(() => reject(new Error('opencode-server: could not determine a free port')));
}
});
});
}
/** Resolve when the child prints the ready line; reject on timeout or early exit. */
function waitForReady(child: ChildProcess, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
let done = false;
let stderrBuf = '';
const finish = (err?: Error) => {
if (done) return;
done = true;
clearTimeout(timer);
child.stdout?.off('data', onOut);
child.stderr?.off('data', onErr);
child.off('exit', onExit);
if (err) reject(err);
else resolve();
};
const onOut = (buf: Buffer) => {
if (buf.toString().includes('opencode server listening on')) finish();
};
const onErr = (buf: Buffer) => {
stderrBuf += buf.toString();
};
const onExit = (code: number | null) =>
finish(new Error(`opencode serve exited before ready (code ${code}); stderr: ${stderrBuf.slice(-2000)}`));
const timer = setTimeout(
() => finish(new Error(`opencode serve not ready in ${timeoutMs}ms; stderr: ${stderrBuf.slice(-2000)}`)),
timeoutMs,
);
child.stdout?.on('data', onOut);
child.stderr?.on('data', onErr);
child.on('exit', onExit);
});
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
/** Strip opencode-dcp plugin tags that render as literal text in the UI. */
function stripDcpTags(s: string): string {
return s.replace(/<dcp-message-id>[^<]*<\/dcp-message-id>/g, '');
}
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
function errToString(e: unknown): string {
if (e == null) return 'unknown error';
if (typeof e === 'string') return e;
if (e instanceof Error) return e.message;
try {
return JSON.stringify(e);
} catch {
return String(e);
}
}
/** Hash of stable config — detects model changes across sessions without
* invalidating on ephemeral state like the random server port (which changes
* every BooCoder restart). */
function sessionConfigHash(model: string): string {
return createHash('sha256').update(`opencode_server|${model}`).digest('hex').slice(0, 16);
}

View File

@@ -0,0 +1,108 @@
/**
* v2.5.11: discover Claude Code's real, enabled commands + plugin skills from
* disk so the coder slash menu shows them (claude is PTY — no ACP discovery).
*
* Scope (v1): user-global only — `~/.claude/commands/*.md` plus the enabled
* plugins listed in `~/.claude/settings.json:enabledPlugins` (user-scope install
* paths from `~/.claude/plugins/.../installed_plugins.json`). Project-local
* plugins and `<cwd>/.claude/commands` are deferred. Names are bare.
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { AgentCommand } from './provider-types.js';
/** Minimal frontmatter reader — single-line `key: value` between `---` fences. */
function frontmatterField(content: string, field: string): string | undefined {
const block = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!block?.[1]) return undefined;
const m = block[1].match(new RegExp(`^${field}:\\s*(.+)$`, 'm'));
return m?.[1]?.trim().replace(/^["']|["']$/g, '') || undefined;
}
function readCommandDir(dir: string): AgentCommand[] {
if (!existsSync(dir)) return [];
let files: string[];
try {
files = readdirSync(dir);
} catch {
return [];
}
const out: AgentCommand[] = [];
for (const f of files) {
if (!f.endsWith('.md')) continue;
let description: string | undefined;
try {
description = frontmatterField(readFileSync(join(dir, f), 'utf8'), 'description');
} catch {
/* unreadable — still list the command by name */
}
out.push({ name: f.slice(0, -3), kind: 'command', ...(description ? { description } : {}) });
}
return out;
}
function readSkillDir(dir: string): AgentCommand[] {
if (!existsSync(dir)) return [];
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return [];
}
const out: AgentCommand[] = [];
for (const sub of entries) {
const skillMd = join(dir, sub, 'SKILL.md');
if (!existsSync(skillMd)) continue;
let content: string;
try {
content = readFileSync(skillMd, 'utf8');
} catch {
continue;
}
out.push({
name: frontmatterField(content, 'name') ?? sub,
kind: 'skill',
...(() => {
const d = frontmatterField(content, 'description');
return d ? { description: d } : {};
})(),
});
}
return out;
}
export function discoverClaudeCommands(): AgentCommand[] {
const root = join(homedir(), '.claude');
const out: AgentCommand[] = [];
// User custom commands.
out.push(...readCommandDir(join(root, 'commands')));
// Enabled plugins (user-scope installs).
try {
const settings = JSON.parse(readFileSync(join(root, 'settings.json'), 'utf8')) as {
enabledPlugins?: Record<string, boolean>;
};
const installed = JSON.parse(
readFileSync(join(root, 'plugins', 'installed_plugins.json'), 'utf8'),
) as { plugins?: Record<string, Array<{ scope?: string; installPath?: string }>> };
const enabled = settings.enabledPlugins ?? {};
const plugins = installed.plugins ?? {};
for (const [key, on] of Object.entries(enabled)) {
if (!on) continue;
const installs = plugins[key] ?? [];
const installPath = (installs.find((i) => i.scope === 'user') ?? installs[0])?.installPath;
if (!installPath || !existsSync(installPath)) continue;
out.push(...readSkillDir(join(installPath, 'skills')));
out.push(...readCommandDir(join(installPath, 'commands')));
}
} catch {
/* missing/unreadable plugin config → user commands only */
}
// Dedupe by name (first wins).
const seen = new Set<string>();
return out.filter((c) => (seen.has(c.name) ? false : (seen.add(c.name), true)));
}

View File

@@ -3,13 +3,17 @@ import type { FastifyBaseLogger } from 'fastify';
import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames';
import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
import { dispatchViaAcp } from './acp-dispatch.js';
import { getResolvedRegistry } from './provider-config-registry.js';
import { dispatchViaPty } from './pty-dispatch.js';
import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
import { getManifestCommands } from './provider-commands.js';
import { persistExternalAgentTurn } from './agent-turn-persist.js';
import { snapshotToWireToolCall, type AcpToolSnapshot } from './acp-tool-snapshot.js';
import { agentPool } from './agent-pool.js';
import { OpenCodeServerBackend } from './backends/opencode-server.js';
import type { AgentBackend, AgentEvent } from './agent-backend.js';
interface InferenceRunner {
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
@@ -35,23 +39,35 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
const { sql, inference, broker, log, config } = deps;
let timer: ReturnType<typeof setInterval> | null = null;
let listener: { unlisten: () => Promise<void> } | null = null;
let running = false;
let polling = false;
let stopping = false;
let inflightPromise: Promise<void> | null = null;
// v2.6 (1.9): per-session in-flight registry replaces the global `running`
// boolean. Key = session_id (or `task:<id>` for sessionless tasks). Sessions
// without an in-flight turn run concurrently; within a session, strictly one
// turn at a time.
const inflight = new Map<string, Promise<void>>();
// Shared entry point for both the poll timer and the NOTIFY listener. poll()'s
// `running`/`stopping` guard makes this safe to call concurrently — a notify
// arriving mid-task returns immediately and never double-dispatches.
// `polling`/`stopping` guard makes this safe to call concurrently — a notify
// arriving mid-poll returns immediately and never double-dispatches.
function triggerPoll(reason: string): void {
poll().catch((err) => {
log.error({ err, reason }, 'dispatcher: poll error');
});
}
async function poll(): Promise<void> {
if (running || stopping) return;
function concurrencyKey(task: { id: string; session_id: string | null }): string {
return task.session_id ?? `task:${task.id}`;
}
// Grab one pending task
async function poll(): Promise<void> {
// `polling` serializes poll() execution itself (timer + NOTIFY can fire
// concurrently) so we never double-select a task. It does NOT serialize task
// execution — that's what `inflight` (keyed per session) governs.
if (polling || stopping) return;
polling = true;
try {
// Oldest-first; start every pending task whose session isn't already busy.
const rows = await sql<{
id: string;
project_id: string;
@@ -66,16 +82,22 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
FROM tasks
WHERE state = 'pending'
ORDER BY created_at
LIMIT 1
LIMIT 50
`;
if (rows.length === 0) return;
const task = rows[0]!;
running = true;
inflightPromise = runTask(task).finally(() => {
running = false;
inflightPromise = null;
for (const task of rows) {
if (stopping) break;
const key = concurrencyKey(task);
if (inflight.has(key)) continue; // this session already has an in-flight turn
// Register synchronously (before any await) so a later row in this pass
// with the same key is skipped and a concurrent poll can't re-pick it.
const p = runTask(task).finally(() => {
inflight.delete(key);
});
inflight.set(key, p);
}
} finally {
polling = false;
}
}
async function runTask(task: {
@@ -96,7 +118,13 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
SELECT name, supports_acp, install_path FROM available_agents WHERE name = ${task.agent}
`;
if (agentRow) {
// v2.6 (1.7): opencode routes to the warm pool backend; every other
// external agent keeps the existing one-shot ACP/PTY path untouched.
if (task.agent === 'opencode') {
await runOpenCodeServerTask(task, agentRow.install_path);
} else {
await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
}
return;
}
// Agent specified but not available — fall through to Path A with a warning
@@ -456,6 +484,274 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
}
}
// ─── Path B (opencode): warm OpenCode server backend (v2.6 1.7 + 1.10) ───────
// OpenCode runs ONE server per BooCoder process, shared across all sessions
// (the backend multiplexes sessions internally), so it's pooled under a fixed
// key rather than per-session. Warm ACP backends (Phase 2) will be per-session.
const OPENCODE_POOL_KEY = '__opencode_server__';
function getOpenCodeBackend(installPath: string | null): AgentBackend {
let backend = agentPool.get(OPENCODE_POOL_KEY, 'opencode');
if (!backend) {
backend = new OpenCodeServerBackend({ sql, log, opencodeBinary: installPath ?? 'opencode' });
agentPool.register(OPENCODE_POOL_KEY, 'opencode', backend);
}
return backend;
}
async function runOpenCodeServerTask(
task: {
id: string;
project_id: string;
input: string;
agent: string | null;
model: string | null;
mode_id: string | null;
thinking_option_id: string | null;
session_id: string | null;
},
installPath: string | null,
): Promise<void> {
const taskId = task.id;
const agent = 'opencode';
log.info({ taskId, agent }, 'dispatcher: starting task (path B — opencode server)');
const [project] = await sql<{ path: string | null }[]>`
SELECT path FROM projects WHERE id = ${task.project_id}
`;
const projectPath = project?.path;
if (!projectPath) {
await sql`
UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no path — cannot create worktree'
WHERE id = ${taskId}
`;
return;
}
const ac = new AbortController();
try {
// execution_path = 'acp' — the schema CHECK has no 'opencode_server' value
// (schema is frozen at Phase 0); the warm-vs-one-shot distinction lives in
// agent_sessions.backend. Reuse the closest existing value.
await sql`
UPDATE tasks
SET state = 'running', started_at = clock_timestamp(), execution_path = 'acp'
WHERE id = ${taskId}
`;
// Resolve session + chat (mirrors runExternalAgent).
let sessionId: string;
let chatId: string;
if (task.session_id) {
sessionId = task.session_id;
const chats = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
`;
if (chats.length === 0) {
const [chat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'External agent execution', 'open')
RETURNING id
`;
chatId = chat!.id;
} else {
chatId = chats[0]!.id;
}
} else {
const sessionName = `Task [${agent}]: ${task.input.slice(0, 30)}`;
const [session] = await sql<{ id: string }[]>`
INSERT INTO sessions (project_id, name, model, status)
VALUES (${task.project_id}, ${sessionName}, ${task.model ?? config.DEFAULT_MODEL}, 'open')
RETURNING id
`;
sessionId = session!.id;
const [chat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'External agent execution', 'open')
RETURNING id
`;
chatId = chat!.id;
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
}
if (!task.session_id) {
await sql`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
`;
}
// Persistent, session-keyed worktree (shared across turns; NOT torn down
// per turn — Phase 3 reaps it). Captures base_commit for a stable diff.
const { worktreePath, baseCommit } = await ensureSessionWorktree(sql, projectPath, sessionId, {
signal: ac.signal,
});
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready');
const [assistantMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
RETURNING id
`;
const assistantId = assistantMsg!.id;
broker.publishFrame(sessionId, {
type: 'message_started',
message_id: assistantId,
chat_id: chatId,
role: 'assistant',
} as WsFrame);
const manifestCommands = getManifestCommands(agent);
if (manifestCommands.length > 0) {
setTaskCommands(taskId, manifestCommands);
broker.publishFrame(sessionId, {
type: 'agent_commands',
task_id: taskId,
session_id: sessionId,
commands: manifestCommands,
} as WsFrame);
}
// Accumulate the turn's stream for persistence + the final message content.
const textChunks: string[] = [];
const reasoningChunks: string[] = [];
const toolSnaps = new Map<string, AcpToolSnapshot>();
// Map transport-agnostic AgentEvents → the SAME WS frames the ACP path emits.
// This boundary is where message_id/chat_id get attached (the backend never
// owns them).
const onEvent = (e: AgentEvent): void => {
switch (e.type) {
case 'text':
textChunks.push(e.text);
broker.publishFrame(sessionId, {
type: 'delta',
message_id: assistantId,
chat_id: chatId,
content: e.text,
} as WsFrame);
break;
case 'reasoning':
reasoningChunks.push(e.text);
broker.publishFrame(sessionId, {
type: 'reasoning_delta',
message_id: assistantId,
chat_id: chatId,
content: e.text,
} as WsFrame);
break;
case 'tool_call':
case 'tool_update':
toolSnaps.set(e.toolCall.toolCallId, e.toolCall);
broker.publishFrame(sessionId, {
type: 'tool_call',
message_id: assistantId,
chat_id: chatId,
tool_call: snapshotToWireToolCall(e.toolCall),
} as WsFrame);
break;
case 'commands':
// opencode-server doesn't emit these today; ignore if it ever does.
break;
}
};
// opencode expects provider-prefixed model ids (e.g. 'llama-swap/qwen3.6-35b…').
// DEFAULT_MODEL is bare (no prefix) because native inference uses it directly
// against llama-swap. Coalesce empty string (frontend sends '' when no models
// listed) and prefix bare ids so parseModel always succeeds.
const rawModel = (task.model && task.model.trim()) || config.DEFAULT_MODEL;
const model = rawModel.includes('/') ? rawModel : `llama-swap/${rawModel}`;
const backend = getOpenCodeBackend(installPath);
const handle = await backend.ensureSession(sessionId, {
agent,
model,
worktreePath,
projectId: task.project_id,
});
const result = await backend.prompt(handle, task.input, {
worktreePath,
model,
signal: ac.signal,
onEvent,
});
const assistantContent = textChunks.join('').slice(0, 50_000);
const reasoningText = reasoningChunks.join('').slice(0, 200_000);
const outputSummary = (result.ok ? textChunks.join('') : result.error ?? 'opencode turn failed').slice(0, 500);
await persistExternalAgentTurn(sql, assistantId, [...toolSnaps.values()], reasoningText);
await sql`
UPDATE messages
SET content = ${assistantContent}, status = 'complete', finished_at = clock_timestamp()
WHERE id = ${assistantId}
`;
broker.publishFrame(sessionId, {
type: 'message_complete',
message_id: assistantId,
chat_id: chatId,
} as WsFrame);
if (stopping) {
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
return; // worktree persists (no cleanup); backend stays warm
}
// 1.10: diff the persistent worktree against its captured baseline and
// SUPERSEDE the session's prior pending row (latest-wins, one accumulating
// diff) instead of stacking. Stamp agent for DiffPanel attribution.
const diff = await diffWorktree(worktreePath, projectPath, {
signal: ac.signal,
baseRef: baseCommit ?? 'HEAD',
});
if (diff) {
await sql`
DELETE FROM pending_changes WHERE session_id = ${sessionId} AND status = 'pending'
`;
await sql`
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
VALUES (${sessionId}, ${taskId}, ${projectPath}, 'edit', ${diff}, ${agent})
`;
log.info({ taskId, diffLength: diff.length }, 'dispatcher: diff superseded prior pending change');
} else {
log.info({ taskId }, 'dispatcher: no changes detected in session worktree');
}
// NO worktree cleanup — it's persistent (Phase 3 reaps it). Backend stays warm.
const [extCostRow] = await sql<{ total: number | null }[]>`
SELECT SUM(tokens_used)::int AS total
FROM messages
WHERE session_id = ${sessionId} AND tokens_used IS NOT NULL
`;
const extCostTokens = extCostRow?.total ?? null;
const finalState = result.ok ? 'completed' : 'failed';
await sql`
UPDATE tasks
SET state = ${finalState}, ended_at = clock_timestamp(), output_summary = ${outputSummary}, cost_tokens = ${extCostTokens}
WHERE id = ${taskId}
`;
log.info({ taskId, agent, finalState, costTokens: extCostTokens }, 'dispatcher: task finished (opencode server)');
clearTaskCommands(taskId);
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
log.error({ taskId, agent, err: errMsg }, 'dispatcher: opencode server error');
await sql`
UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
WHERE id = ${taskId}
`.catch(() => {});
clearTaskCommands(taskId);
// No worktree cleanup (persistent); backend stays warm for the next turn.
}
}
// ─── Helpers ────────────────────────────────────────────────────────────────
async function waitForCompletion(assistantId: string): Promise<string> {
@@ -514,9 +810,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
});
listener = null;
}
if (inflightPromise) {
log.info('dispatcher: waiting for in-flight task');
await inflightPromise;
if (inflight.size > 0) {
log.info({ count: inflight.size }, 'dispatcher: waiting for in-flight tasks');
await Promise.allSettled([...inflight.values()]);
}
log.info('dispatcher: stopped');
},

View File

@@ -29,6 +29,41 @@ export const CoderProvidersFileSchema = z.object({
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
export type CoderProvidersFile = z.infer<typeof CoderProvidersFileSchema>;
/**
* PATCH body schema (design.md §6.2). A partial providers map where each value
* is either a full override object (REPLACES that id's override) or `null`
* (DELETES the override → revert to the built-in default). Ids absent from the
* patch are left untouched. The route validates the body against this first
* (malformed → 422) so a bad shape can never reach the merge/save step.
*/
export const ProviderConfigPatchSchema = z.object({
providers: z.record(ProviderOverrideSchema.nullable()).default({}),
});
export type ProviderConfigPatch = z.infer<typeof ProviderConfigPatchSchema>;
/**
* Shallow per-id merge (design.md §6.2 / Paseo `patchConfig`). Each key in
* `patch.providers` REPLACES that id's override object wholesale (NOT a deep
* field merge); a `null` value DELETES the override. Returns a new object —
* never mutates `current`. The result is a plain CoderProvidersFile (no nulls),
* which the route re-validates against CoderProvidersFileSchema before save.
*/
export function mergeProviderConfigPatch(
current: CoderProvidersFile,
patch: ProviderConfigPatch,
): CoderProvidersFile {
const providers: Record<string, ProviderOverride> = { ...current.providers };
for (const [id, override] of Object.entries(patch.providers)) {
if (override === null) {
delete providers[id];
} else {
providers[id] = override;
}
}
return { providers };
}
/** Read + parse + validate. Falls back to built-ins-only on any failure; never throws. */
export function load(path: string): CoderProvidersFile {
let raw: string;

View File

@@ -0,0 +1,71 @@
/**
* v2.3 Phase 4 (design.md §8) — per-provider plaintext diagnostic report.
*
* Read-only by default: reports CACHED state (resolved registry def + the
* available_agents row + the warm snapshot-cache entry) plus a `which`-style
* PATH check for the launch binary. It does NOT spawn an ACP probe — §8 lists
* the live initialize probe as optional, and the route defaults to cached state.
*
* A template string is the whole formatter (no Paseo diagnostic-utils port).
*/
import type { ResolvedProviderDef } from './provider-config-registry.js';
import type { ProviderSnapshotEntry, ProviderModel } from './provider-types.js';
import { isCommandAvailable } from './command-availability.js';
/** The subset of an `available_agents` row the diagnostic reads. */
export interface DiagnosticAgentRow {
name: string;
install_path: string | null;
supports_acp?: boolean;
models?: ProviderModel[] | null;
last_probed_at?: string | Date | null;
}
interface DiagnosticOpts {
/** Warm snapshot-cache entry (read-only peek) — source of the last probe error. */
cachedEntry?: ProviderSnapshotEntry;
/** Injectable PATH check (defaults to the real `which`); stubbed in tests. */
checkAvailable?: (binary: string) => Promise<boolean>;
}
/** Resolve the binary the dispatcher would launch (for the PATH check + report). */
function resolveBinary(resolved: ResolvedProviderDef, agentRow: DiagnosticAgentRow | undefined): string {
return resolved.launchCommand?.[0] ?? agentRow?.install_path ?? resolved.id;
}
export async function getProviderDiagnostic(
resolved: ResolvedProviderDef,
agentRow: DiagnosticAgentRow | undefined,
opts: DiagnosticOpts = {},
): Promise<string> {
const checkAvailable = opts.checkAvailable ?? isCommandAvailable;
const installed = agentRow?.install_path != null;
const binary = resolveBinary(resolved, agentRow);
// boocode is native (no binary to launch) — short-circuit the PATH check.
const commandAvailable = resolved.transport === 'native' ? true : await checkAvailable(binary);
const lastProbedAt =
agentRow?.last_probed_at != null ? new Date(agentRow.last_probed_at).toISOString() : '(never)';
const modelCount = agentRow?.models?.length ?? 0;
const launchCommand = resolved.launchCommand
? resolved.launchCommand.join(' ')
: '(built-in default, resolved at dispatch)';
const lastError = opts.cachedEntry?.error ?? '(none recorded)';
return [
`provider: ${resolved.id}`,
`label: ${resolved.configLabel ?? resolved.label}`,
`transport: ${resolved.transport}`,
`enabled: ${resolved.enabled}`,
`builtin: ${resolved.isBuiltin}`,
`customAcp: ${resolved.isCustomAcp}`,
`installed: ${installed}`,
`install_path: ${agentRow?.install_path ?? '(none)'}`,
`binary: ${binary}`,
`command_available: ${commandAvailable}`,
`launch_command: ${launchCommand}`,
`supports_acp: ${agentRow?.supports_acp ?? '(unknown)'}`,
`last_probed_at: ${lastProbedAt}`,
`models_in_db: ${modelCount}`,
`last_probe_error: ${lastError}`,
].join('\n');
}

View File

@@ -16,6 +16,7 @@ import { getManifestCommands, mergeCommands } from './provider-commands.js';
import { readQwenSettingsModels } from './qwen-settings.js';
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
import { isCommandAvailable } from './command-availability.js';
import { discoverClaudeCommands } from './claude-command-discovery.js';
interface AgentRow {
name: string;
@@ -28,7 +29,7 @@ interface AgentRow {
last_probed_at: string | Date | null;
}
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
export async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
try {
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`);
if (!res.ok) return [];
@@ -149,10 +150,12 @@ async function buildProviderEntry(
// claude: static models + thinking options, no ACP probe (unchanged from v2.2).
if (name === 'claude') {
// claude is PTY (no ACP discovery) — read its enabled commands + plugin
// skills from disk live (the snapshot cache rate-limits the fs reads).
return {
name, label, transport, status: 'ready', enabled: true, installed: true,
models: attachClaudeThinking(withConfigModels(models)), modes: fallbackModes, defaultModeId,
commands: manifestCommands,
commands: mergeCommands(manifestCommands, discoverClaudeCommands()),
};
}
@@ -280,6 +283,16 @@ export function clearProviderSnapshotCache(): void {
snapshotInflight.clear();
}
/**
* Read-only peek into the warm snapshot cache for one provider (no build, no
* probe). Used by the diagnostic route to report the last computed probe error
* without spawning anything. Returns undefined on a cold cache / unknown name.
*/
export function peekSnapshotEntry(name: string, cwd?: string): ProviderSnapshotEntry | undefined {
const resolvedCwd = cwd?.trim() || homedir();
return snapshotCache.get(resolvedCwd)?.entries.find((e) => e.name === name);
}
/** Persist probed model lists back to available_agents for fast legacy reads. */
export async function persistProbedModels(
sql: Sql,

View File

@@ -30,6 +30,9 @@ export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'erro
export interface AgentCommand {
name: string;
description?: string;
// v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command).
// Drives the icon split in the coder slash menu. Undefined → command.
kind?: 'command' | 'skill';
}
// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity is

View File

@@ -6,6 +6,7 @@
* After the agent completes, we diff the worktree against HEAD and
* queue the diff into pending_changes.
*/
import type { Sql } from '../db.js';
import { hostExec } from './host-exec.js';
const WORKTREE_BASE = '/tmp/booworktrees';
@@ -45,7 +46,7 @@ export async function createWorktree(
export async function diffWorktree(
worktreePath: string,
projectPath: string,
opts?: { signal?: AbortSignal },
opts?: { signal?: AbortSignal; baseRef?: string },
): Promise<string> {
// First, commit any uncommitted changes in the worktree so we can diff branches
// Stage all changes
@@ -74,9 +75,13 @@ export async function diffWorktree(
{ signal: opts?.signal, timeoutMs: 15_000 },
);
// Diff the worktree branch against the parent commit (HEAD of main tree)
// Diff the worktree branch against the baseline. Per-task callers default to the
// main tree's current HEAD; the session-worktree (opencode) path passes the
// captured base_commit so the accumulated diff is stable across turns even if
// project HEAD advances.
const baseRef = opts?.baseRef ?? 'HEAD';
const diffResult = await hostExec(
`git -C ${shellEscape(projectPath)} diff HEAD...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`,
`git -C ${shellEscape(projectPath)} diff ${shellEscape(baseRef)}...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`,
{ signal: opts?.signal, timeoutMs: 60_000 },
);
@@ -111,6 +116,72 @@ export async function cleanupWorktree(
).catch(() => {});
}
// ─── v2.6: session-keyed persistent worktree ────────────────────────────────
export interface SessionWorktree {
worktreePath: string;
baseCommit: string | null;
}
/**
* v2.6: create-or-reuse ONE worktree per BooCode session (shared across all
* agents/turns in the session), recorded in `session_worktrees`. Unlike the
* per-task `createWorktree`, this persists — it is NOT torn down per turn
* (cleanup is Phase 3). Captures the project's current HEAD as `base_commit`
* so the accumulating diff has a stable baseline across turns.
*
* Distinct path namespace (`session-<id>` branch, `/sess-<id>` dir) so it never
* collides with the per-task worktrees that arena/new_task/MCP still use.
*/
export async function ensureSessionWorktree(
sql: Sql,
projectPath: string,
sessionId: string,
opts?: { signal?: AbortSignal },
): Promise<SessionWorktree> {
const [existing] = await sql<{ worktree_path: string; base_commit: string | null }[]>`
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId}
`;
if (existing) {
return { worktreePath: existing.worktree_path, baseCommit: existing.base_commit };
}
const worktreePath = `${WORKTREE_BASE}/sess-${sessionId}`;
const branchName = `session-${sessionId}`;
await hostExec(`mkdir -p ${WORKTREE_BASE}`, { signal: opts?.signal });
// Capture the baseline commit BEFORE branching, so the diff is stable even if
// project HEAD later advances.
const headResult = await hostExec(
`git -C ${shellEscape(projectPath)} rev-parse HEAD`,
{ signal: opts?.signal, timeoutMs: 10_000 },
);
const baseCommit = headResult.exitCode === 0 ? headResult.stdout.trim() || null : null;
const result = await hostExec(
`git -C ${shellEscape(projectPath)} worktree add ${shellEscape(worktreePath)} -b ${shellEscape(branchName)} HEAD`,
{ signal: opts?.signal, timeoutMs: 30_000 },
);
if (result.exitCode !== 0) {
throw new Error(`Failed to create session worktree: ${result.stderr.trim() || result.stdout.trim()}`);
}
// Persist. ON CONFLICT keeps the first writer's row if two turns race the create.
await sql`
INSERT INTO session_worktrees (session_id, worktree_path, base_commit)
VALUES (${sessionId}, ${worktreePath}, ${baseCommit})
ON CONFLICT (session_id) DO NOTHING
`;
const [row] = await sql<{ worktree_path: string; base_commit: string | null }[]>`
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId}
`;
return {
worktreePath: row?.worktree_path ?? worktreePath,
baseCommit: row?.base_commit ?? baseCommit,
};
}
/** Minimal shell escape for paths (single-quote wrapping). */
function shellEscape(s: string): string {
// Replace single quotes with escaped version, wrap in single quotes

View File

@@ -37,9 +37,11 @@ export async function maybeAutoNameChat(
if ((counts[0]?.n ?? 0) < 1) return;
const chatRows = await ctx.sql<
{ id: string; name: string | null; session_id: string }[]
{ id: string; name: string | null; session_id: string; model: string | null }[]
>`
SELECT id, name, session_id FROM chats WHERE id = ${chatId}
SELECT c.id, c.name, c.session_id, s.model
FROM chats c JOIN sessions s ON s.id = c.session_id
WHERE c.id = ${chatId}
`;
const chat = chatRows[0];
if (!chat) return;
@@ -67,6 +69,7 @@ export async function maybeAutoNameChat(
user: namingInput,
maxTokens: 30,
temperature: 0.3,
fallbackModel: chat.model ?? undefined,
});
const name = cleanTitle(raw);
if (!name) {

View File

@@ -14,6 +14,8 @@ import type {
AskUserAnswer,
ToolCostStat,
ProviderSnapshotEntry,
CoderProvidersFile,
ProviderConfigPatch,
CoderSendMessageBody,
CoderSendMessageResponse,
CoderMessageWire,
@@ -310,8 +312,23 @@ export const api = {
const qs = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '';
return request<ProviderSnapshotEntry[]>(`/api/coder/providers/snapshot${qs}`);
},
refreshProviders: () =>
request<{ refreshed: number }>('/api/coder/providers/refresh', { method: 'POST' }),
// v2.3 Phase 4: optional subset narrows the reported `refreshed` count.
refreshProviders: (providers?: string[]) =>
request<{ refreshed: number }>('/api/coder/providers/refresh', {
method: 'POST',
...(providers && providers.length > 0 ? { body: JSON.stringify({ providers }) } : {}),
}),
// v2.3 Phase 4: read/patch the provider config file. PATCH returns the new
// config; a `null` value in the patch deletes that id's override.
getProvidersConfig: () => request<CoderProvidersFile>('/api/coder/providers/config'),
patchProvidersConfig: (patch: ProviderConfigPatch) =>
request<{ ok: true } & CoderProvidersFile>('/api/coder/providers/config', {
method: 'PATCH',
body: JSON.stringify(patch),
}),
// v2.3 Phase 4: per-provider diagnostic — JSON { diagnostic: string } (§6.4).
getProviderDiagnostic: (id: string) =>
request<{ diagnostic: string }>(`/api/coder/providers/${encodeURIComponent(id)}/diagnostic`),
sendMessage: (sessionId: string, body: CoderSendMessageBody) =>
request<CoderSendMessageResponse>(`/api/coder/sessions/${sessionId}/messages`, {
method: 'POST',

View File

@@ -253,6 +253,31 @@ export interface ProviderSnapshotEntry {
fetchedAt?: string;
}
// v2.3 Phase 4: provider config file wire types. Mirror of the Zod-inferred
// ProviderOverride / CoderProvidersFile in apps/coder/src/services/provider-config.ts
// (web can't cross-import the coder package — TS6307 on the composite project).
export interface ProviderOverride {
extends?: 'acp';
label?: string;
description?: string;
command?: string[];
env?: Record<string, string>;
enabled?: boolean;
order?: number;
models?: Array<{ id: string; label: string }>;
additionalModels?: Array<{ id: string; label: string }>;
}
export interface CoderProvidersFile {
providers: Record<string, ProviderOverride>;
}
// PATCH body: a partial providers map. A `null` value deletes that id's
// override (revert to built-in default); an object replaces it wholesale.
export interface ProviderConfigPatch {
providers: Record<string, ProviderOverride | null>;
}
export interface AgentSessionConfig {
provider: string;
model: string;
@@ -273,6 +298,9 @@ export interface PermissionPrompt {
export interface AgentCommand {
name: string;
description?: string;
// v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command).
// Drives the icon split in the coder slash menu. Undefined → command.
kind?: 'command' | 'skill';
}
export interface CoderSendMessageBody {

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, RefreshCw, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
import { api } from '@/api/client';
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
@@ -176,8 +176,12 @@ interface Props {
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) {
const allEntries = useProviderSnapshot(projectPath);
// 5.5 — the composer picker only offers ENABLED providers that are ready (or
// still loading). Disabled (enabled:false) and unavailable/error providers are
// hidden here and managed in Settings → Providers. Native boocode is always
// enabled+ready, so it always appears.
const entries = useMemo(
() => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null,
() => allEntries?.filter((e) => e.enabled && (e.status === 'ready' || e.status === 'loading')) ?? null,
[allEntries],
);
const [refreshing, setRefreshing] = useState(false);
@@ -200,6 +204,35 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
onChange(resolveConfig(entry, prefs));
}, [entries, onChange, value.provider]);
// If the active provider is disabled in the settings drawer it drops out of
// `entries` (the 5.5 filter) — fall back to boocode so the composer never
// strands on an unselectable provider with empty model/mode pickers.
useEffect(() => {
if (!entries?.length) return;
if (entries.some((e) => e.name === value.provider)) return;
const fallback = entries.find((e) => e.name === 'boocode') ?? entries[0];
if (!fallback) return;
onChange(resolveConfig(fallback, loadPrefs()));
}, [entries, value.provider, onChange]);
// 5.6 — loading poll: while any entry is loading (Phase 2's sync cache-miss
// return), refetch until terminal. Capped; no provider_snapshot_updated WS
// frame (deferred Tier-2). Dormant today since the snapshot awaits the build.
const pollsRef = useRef(0);
useEffect(() => {
const anyLoading = allEntries?.some((e) => e.status === 'loading') ?? false;
if (!anyLoading) {
pollsRef.current = 0;
return;
}
if (pollsRef.current >= 10) return;
const t = setTimeout(() => {
pollsRef.current += 1;
void refreshProviderSnapshot(projectPath);
}, 2000);
return () => clearTimeout(t);
}, [allEntries, projectPath]);
const currentEntry = useMemo(
() => entries?.find((e) => e.name === value.provider),
[entries, value.provider],
@@ -283,7 +316,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
value={value.provider}
options={providerOptions}
onPick={pickProvider}
icon={providerIcon(value.provider)}
icon={
currentEntry?.status === 'loading'
? <Loader2 size={13} className="shrink-0 animate-spin" />
: providerIcon(value.provider)
}
/>
<CompactPicker
label="Mode"

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Code, History, MessageSquare, Plus, Terminal, X } from 'lucide-react';
import { Code, Columns2, History, MessageSquare, Plus, RotateCcw, Terminal, X } from 'lucide-react';
import type { Chat, WorkspacePane } from '@/api/types';
import { StatusDot } from '@/components/StatusDot';
import {
@@ -26,7 +26,9 @@ interface Props {
onCloseOthers: (chatId: string) => void;
onCloseToRight: (chatId: string) => void;
onCloseAll: () => void;
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
onNewTab: () => void;
onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void;
onReopenPane?: () => void;
onShowHistory: () => void;
onRename: (chatId: string, name: string) => Promise<void>;
onRemovePane?: () => void;
@@ -40,7 +42,9 @@ export function ChatTabBar({
onCloseOthers,
onCloseToRight,
onCloseAll,
onAddPane,
onNewTab,
onSplitPane,
onReopenPane,
onShowHistory,
onRename,
onRemovePane,
@@ -131,7 +135,7 @@ export function ChatTabBar({
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => onAddPane('chat')}>
<ContextMenuItem onSelect={onNewTab}>
New chat
</ContextMenuItem>
<ContextMenuSeparator />
@@ -170,29 +174,49 @@ export function ChatTabBar({
)}
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
<button
type="button"
onClick={onNewTab}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New tab"
title="New tab"
>
<Plus size={12} />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New pane"
title="New pane"
aria-label="Split pane"
title="Split pane"
>
<Plus size={12} />
<Columns2 size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<DropdownMenuItem onSelect={() => onSplitPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
<DropdownMenuItem onSelect={() => onSplitPane('terminal')}>
<Terminal size={14} /> New BooTerm
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
<DropdownMenuItem onSelect={() => onSplitPane('coder')}>
<Code size={14} /> New BooCode
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{onReopenPane && (
<button
type="button"
onClick={onReopenPane}
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Reopen closed pane"
title="Reopen closed pane"
>
<RotateCcw size={12} />
</button>
)}
<button
type="button"
onClick={onShowHistory}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, RefObject } from 'react';
import type { CSSProperties, ReactNode, RefObject } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
@@ -11,6 +11,7 @@ export interface SlashCommandItem {
export interface SlashCommandGroup {
label: string;
items: SlashCommandItem[];
icon?: ReactNode;
}
interface Props {
@@ -50,7 +51,7 @@ export function SlashCommandPicker({
() =>
groups
? groups
.map((g) => ({ label: g.label, items: filterByPrefix(g.items, query) }))
.map((g) => ({ label: g.label, icon: g.icon, items: filterByPrefix(g.items, query) }))
.filter((g) => g.items.length > 0)
: null,
[groups, query],
@@ -203,7 +204,8 @@ export function SlashCommandPicker({
{filteredGroups
? filteredGroups.map((g) => (
<div key={g.label}>
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70">
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70 flex items-center gap-1.5">
{g.icon}
{g.label}
</div>
{g.items.map((item) => renderItem(item, (runningIndex += 1)))}

View File

@@ -65,6 +65,8 @@ export function Workspace({
showLandingPage,
addSplitPane,
removePane,
reopenPane,
hasClosedPanes,
isPaneChatPending,
handlePaneDragStart,
handlePaneDragOver,
@@ -207,10 +209,9 @@ export function Workspace({
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
onCloseAll={() => closeAllTabs(idx)}
onAddPane={(kind) => {
if (kind === 'chat') void createChat(idx);
else onAddPane(kind);
}}
onNewTab={() => void createChat(idx)}
onSplitPane={(kind) => onAddPane(kind)}
onReopenPane={hasClosedPanes ? reopenPane : undefined}
onShowHistory={() => showLandingPage(idx)}
onRename={renameChat}
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}

View File

@@ -0,0 +1,139 @@
import { useMemo, useState } from 'react';
import { ExternalLink, Search } from 'lucide-react';
import { api } from '@/api/client';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import {
ACP_PROVIDER_CATALOG,
buildAcpProviderConfigPatch,
type AcpCatalogEntry,
} from '@/data/acp-provider-catalog';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Fired after a successful add so the parent can refetch the snapshot. */
onAdded: (id: string) => void;
}
/**
* v2.3 Phase 5 (design.md §7.3). Search the curated ACP catalog and register a
* provider: PATCH /api/providers/config with its custom-ACP override, then
* refresh that one provider. Adding only edits config — it does NOT install the
* binary, so the provider shows "Not installed" until the CLI is on PATH.
*/
export function AddProviderModal({ open, onOpenChange, onAdded }: Props) {
const [query, setQuery] = useState('');
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return ACP_PROVIDER_CATALOG;
return ACP_PROVIDER_CATALOG.filter(
(e) =>
e.id.toLowerCase().includes(q) ||
e.label.toLowerCase().includes(q) ||
e.description.toLowerCase().includes(q),
);
}, [query]);
async function add(entry: AcpCatalogEntry): Promise<void> {
setBusyId(entry.id);
setError(null);
try {
await api.coder.patchProvidersConfig(buildAcpProviderConfigPatch(entry));
await api.coder.refreshProviders([entry.id]);
onAdded(entry.id);
onOpenChange(false);
} catch (err) {
// 422 from PATCH (invalid override) surfaces here as ApiError.message.
setError(err instanceof Error ? err.message : 'failed to add provider');
} finally {
setBusyId(null);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg max-h-[85vh] grid-rows-[auto_minmax(0,1fr)_auto]">
<DialogHeader>
<DialogTitle>Add ACP provider</DialogTitle>
<DialogDescription>
Registers the provider in your coder config. It is not installed install the CLI
yourself; until it's on PATH it shows as “Not installed”.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col min-h-0 gap-3">
<div className="relative shrink-0">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search providers…"
className="pl-7"
/>
</div>
<div className="flex-1 min-h-0 rounded-md border overflow-y-auto overscroll-contain divide-y">
{filtered.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground">No matching providers.</div>
)}
{filtered.map((e) => (
<div key={e.id} className="px-3 py-2.5 space-y-1.5">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium">{e.label}</div>
<div className="text-xs text-muted-foreground">{e.description}</div>
</div>
<Button
size="sm"
disabled={busyId !== null}
onClick={() => void add(e)}
>
{busyId === e.id ? 'Adding' : 'Add'}
</Button>
</div>
<div className="font-mono text-[11px] text-muted-foreground truncate">
$ {e.command.join(' ')}
</div>
<div className="flex items-center gap-3">
<a
href={e.installUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
Install {e.label} <ExternalLink className="size-3" />
</a>
{e.installCmd && (
<span className="font-mono text-[11px] text-muted-foreground truncate">
{e.installCmd}
</span>
)}
</div>
</div>
))}
</div>
{error && <div className="text-sm text-destructive shrink-0">{error}</div>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busyId !== null}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,218 @@
import { useEffect, useRef, useState } from 'react';
import { Loader2, Plus, RefreshCw, Stethoscope } from 'lucide-react';
import { api } from '@/api/client';
import type { CoderProvidersFile, ProviderOverride, ProviderSnapshotEntry } from '@/api/types';
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
import { Button } from '@/components/ui/button';
import { AddProviderModal } from './AddProviderModal';
import { cn } from '@/lib/utils';
/** Map a snapshot entry to a status badge (design.md §7.1 labels). */
function statusBadge(e: ProviderSnapshotEntry): { label: string; cls: string } {
if (e.status === 'loading') return { label: 'Loading', cls: 'bg-muted text-muted-foreground' };
if (!e.enabled) return { label: 'Disabled', cls: 'bg-muted text-muted-foreground' };
if (e.status === 'ready')
return { label: 'Available', cls: 'bg-green-500/15 text-green-600 dark:text-green-400' };
if (e.status === 'error')
return { label: 'Error', cls: 'bg-red-500/15 text-red-600 dark:text-red-400' };
if (!e.installed)
return { label: 'Not installed', cls: 'bg-amber-500/15 text-amber-600 dark:text-amber-400' };
return { label: 'Unavailable', cls: 'bg-muted text-muted-foreground' };
}
/**
* v2.3 — provider management as a Settings tab section (design.md §7.1). Lists
* ALL registered providers (including the disabled/unavailable ones the composer
* picker hides). Per row: label + model count, status badge, per-id refresh,
* diagnostic, and an enable/disable toggle. Native boocode is always-on.
*
* Uses the home-cwd snapshot (no project arg) — provider management is global,
* not per-project (design.md §4.5).
*/
export function ProvidersSettings() {
const allEntries = useProviderSnapshot();
const [config, setConfig] = useState<CoderProvidersFile | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [diagId, setDiagId] = useState<string | null>(null);
const [diagText, setDiagText] = useState<string | null>(null);
// The raw config is needed to preserve a provider's FULL override when
// toggling: the PATCH replaces an id's override wholesale, so a bare
// { enabled } would wipe a custom ACP provider's command/label.
useEffect(() => {
api.coder
.getProvidersConfig()
.then(setConfig)
.catch(() => setConfig({ providers: {} }));
}, []);
// While any entry is loading, refetch until terminal (capped, no WS frame).
const pollsRef = useRef(0);
useEffect(() => {
const anyLoading = allEntries?.some((e) => e.status === 'loading') ?? false;
if (!anyLoading) {
pollsRef.current = 0;
return;
}
if (pollsRef.current >= 10) return;
const t = setTimeout(() => {
pollsRef.current += 1;
void refreshProviderSnapshot();
}, 2000);
return () => clearTimeout(t);
}, [allEntries]);
async function toggle(e: ProviderSnapshotEntry): Promise<void> {
setBusyId(e.name);
setError(null);
try {
const existing: ProviderOverride = config?.providers[e.name] ?? {};
const resp = await api.coder.patchProvidersConfig({
providers: { [e.name]: { ...existing, enabled: !e.enabled } },
});
setConfig({ providers: resp.providers });
await refreshProviderSnapshot();
} catch (err) {
setError(err instanceof Error ? err.message : 'failed to update provider');
} finally {
setBusyId(null);
}
}
async function refreshOne(id: string): Promise<void> {
setBusyId(id);
setError(null);
try {
await api.coder.refreshProviders([id]);
await refreshProviderSnapshot();
} catch (err) {
setError(err instanceof Error ? err.message : 'failed to refresh');
} finally {
setBusyId(null);
}
}
async function openDiagnostic(id: string): Promise<void> {
if (diagId === id) {
setDiagId(null);
setDiagText(null);
return;
}
setDiagId(id);
setDiagText('Loading…');
try {
const { diagnostic } = await api.coder.getProviderDiagnostic(id);
setDiagText(diagnostic);
} catch (err) {
setDiagText(err instanceof Error ? err.message : 'failed to load diagnostic');
}
}
const entries = allEntries ?? [];
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">
Enable, disable, refresh, or add coding agents. Disabled and unavailable providers are
hidden from the composer picker but managed here.
</p>
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)} className="shrink-0">
<Plus className="size-3.5" /> Add provider
</Button>
</div>
<div className="rounded-md border divide-y">
{allEntries === null && (
<div className="px-3 py-2 text-sm text-muted-foreground">Loading</div>
)}
{entries.map((e) => {
const badge = statusBadge(e);
const isNative = e.transport === 'native';
const busy = busyId === e.name;
return (
<div key={e.name} className="px-3 py-2.5">
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate">{e.label}</div>
<div className="text-xs text-muted-foreground">
{e.models.length} model{e.models.length === 1 ? '' : 's'}
</div>
</div>
<span
className={cn(
'inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium',
badge.cls,
)}
>
{e.status === 'loading' && <Loader2 className="size-3 mr-1 animate-spin" />}
{badge.label}
</span>
<button
type="button"
onClick={() => void refreshOne(e.name)}
disabled={busy}
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
aria-label={`Refresh ${e.label}`}
title="Refresh"
>
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} />
</button>
<button
type="button"
onClick={() => void openDiagnostic(e.name)}
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:text-foreground"
aria-label={`Diagnostic for ${e.label}`}
title="Diagnostic"
>
<Stethoscope className="size-3.5" />
</button>
{isNative ? (
<span className="text-[11px] text-muted-foreground w-14 text-center">
Always on
</span>
) : (
<button
type="button"
role="switch"
aria-checked={e.enabled}
disabled={busy}
onClick={() => void toggle(e)}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors disabled:opacity-40',
e.enabled ? 'bg-primary' : 'bg-muted-foreground/30',
)}
aria-label={`${e.enabled ? 'Disable' : 'Enable'} ${e.label}`}
title={e.enabled ? 'Enabled — click to disable' : 'Disabled — click to enable'}
>
<span
className={cn(
'inline-block size-4 rounded-full bg-background transition-transform',
e.enabled ? 'translate-x-4' : 'translate-x-0.5',
)}
/>
</button>
)}
</div>
{diagId === e.name && (
<pre className="mt-2 max-h-48 overflow-auto rounded bg-muted/50 p-2 text-[11px] font-mono whitespace-pre-wrap">
{diagText}
</pre>
)}
</div>
);
})}
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<AddProviderModal
open={addOpen}
onOpenChange={setAddOpen}
onAdded={() => void refreshProviderSnapshot()}
/>
</div>
);
}

View File

@@ -4,10 +4,11 @@
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Code, Check, X, RefreshCw } from 'lucide-react';
import { Code, Check, X, RefreshCw, Terminal, Puzzle, Sparkles } from 'lucide-react';
import { AgentComposerBar } from '@/components/AgentComposerBar';
import { PermissionCard } from '@/components/PermissionCard';
import { ChatInput } from '@/components/ChatInput';
import type { SlashCommandGroup } from '@/components/SlashCommandPicker';
import { api } from '@/api/client';
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
import { useSkills } from '@/hooks/useSkills';
@@ -525,12 +526,31 @@ export function CoderPane({
[skills],
);
const slashGroups = useMemo(() => {
const groups: Array<{ label: string; items: Array<{ name: string; description?: string }> }> = [];
if (agentCommands.length > 0) {
groups.push({ label: `${agentConfig.provider} commands`, items: agentCommands });
const groups: SlashCommandGroup[] = [];
// Split the active agent's set: native/CLI commands vs plugin skills, each
// with its own icon. BooCoder skills always come last.
const agentCmds = agentCommands.filter((c) => c.kind !== 'skill');
const agentSkills = agentCommands.filter((c) => c.kind === 'skill');
if (agentCmds.length > 0) {
groups.push({
label: `${agentConfig.provider} commands`,
items: agentCmds,
icon: <Terminal className="size-3 shrink-0" />,
});
}
if (agentSkills.length > 0) {
groups.push({
label: `${agentConfig.provider} skills`,
items: agentSkills,
icon: <Puzzle className="size-3 shrink-0" />,
});
}
if (skillItems.length > 0) {
groups.push({ label: 'Skills', items: skillItems });
groups.push({
label: 'BooCoder skills',
items: skillItems,
icon: <Sparkles className="size-3 shrink-0" />,
});
}
return groups;
}, [agentCommands, skillItems, agentConfig.provider]);

View File

@@ -15,9 +15,10 @@ import {
} from '@/components/ui/dialog';
import { ModelPicker } from '@/components/ModelPicker';
import { ThemePicker } from '@/components/ThemePicker';
import { ProvidersSettings } from '@/components/coder/ProvidersSettings';
import { cn } from '@/lib/utils';
type Section = 'session' | 'project' | 'theme';
type Section = 'session' | 'project' | 'theme' | 'providers';
interface Props {
session: Session;
@@ -73,7 +74,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
<div className="flex items-center gap-1 flex-1 min-w-0">
{(['session', 'project', 'theme'] as const).map((s) => (
{(['session', 'project', 'theme', 'providers'] as const).map((s) => (
<button
key={s}
type="button"
@@ -116,6 +117,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
{activeSection === 'session' && <SessionSection session={session} project={project} />}
{activeSection === 'project' && <ProjectSection project={project} />}
{activeSection === 'theme' && <ThemePicker />}
{activeSection === 'providers' && <ProvidersSettings />}
</div>
</div>
</div>

View File

@@ -0,0 +1,83 @@
import type { ProviderConfigPatch } from '@/api/types';
/**
* v2.3 Phase 5 (design.md §7.3) — a SMALL curated catalog of ACP coding agents
* the user might register. We deliberately do NOT port Paseo's 30+ entry list.
*
* Non-goal: we never install anything. Each entry is a manual-install hint
* (`installUrl` / `installCmd`) plus the config `command` that gets written into
* `/data/coder-providers.json`. The user installs the CLI themselves; until the
* binary is on PATH the provider shows as "Not installed". Commands are
* editable after adding — versions are aliased/untrimmed on purpose; pin on your
* own host once verified.
*/
export interface AcpCatalogEntry {
id: string;
label: string;
description: string;
/** Config command written verbatim into providers[id].command: [binary, ...args]. */
command: [string, ...string[]];
/** Where to install the CLI manually — we LINK, never install. */
installUrl: string;
/** Optional suggested install command, shown as a copyable hint. */
installCmd?: string;
}
export const ACP_PROVIDER_CATALOG: AcpCatalogEntry[] = [
{
id: 'amp-acp',
label: 'Amp',
description: 'Sourcegraph Amp — agentic coding CLI with an ACP bridge.',
command: ['amp-acp'],
installUrl: 'https://ampcode.com/',
installCmd: 'npm i -g @sourcegraph/amp',
},
{
id: 'gemini',
label: 'Gemini CLI',
description: 'Google Gemini CLI in ACP mode (--experimental-acp).',
command: ['gemini', '--experimental-acp'],
installUrl: 'https://github.com/google-gemini/gemini-cli',
installCmd: 'npm i -g @google/gemini-cli',
},
{
id: 'cline',
label: 'Cline',
description: 'Cline coding agent over ACP (run via npx).',
command: ['npx', '-y', 'cline', '--acp'],
installUrl: 'https://cline.bot/',
},
{
id: 'claude-code-acp',
label: 'Claude Code (ACP)',
description: "Zed's ACP adapter for Claude Code — distinct from the built-in PTY claude provider.",
command: ['npx', '-y', '@zed-industries/claude-code-acp'],
installUrl: 'https://github.com/zed-industries/claude-code-acp',
},
{
id: 'pi-acp',
label: 'Pi',
description: 'Example custom ACP entry — build the binary from source, then edit the command.',
command: ['pi-acp'],
installUrl: 'https://agentclientprotocol.com/',
},
];
/**
* Build the PATCH body that registers a catalog entry: a single-id partial
* providers map with the custom-ACP override (extends:'acp' + label + command),
* enabled. Sent to PATCH /api/providers/config (then refreshProviders([id])).
*/
export function buildAcpProviderConfigPatch(entry: AcpCatalogEntry): ProviderConfigPatch {
return {
providers: {
[entry.id]: {
extends: 'acp',
label: entry.label,
description: entry.description,
command: entry.command,
enabled: true,
},
},
};
}

View File

@@ -32,6 +32,21 @@ function chatPane(chatId: string): WorkspacePane {
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
}
interface ClosedPaneEntry {
kind: WorkspacePane['kind'];
chatIds: string[];
activeChatIdx: number;
}
const MAX_CLOSED = 10;
const closedPaneStack: ClosedPaneEntry[] = [];
function pushClosed(pane: WorkspacePane): void {
if (pane.kind === 'empty' || pane.kind === 'settings') return;
if (pane.chatIds.length === 0) return;
closedPaneStack.push({ kind: pane.kind, chatIds: [...pane.chatIds], activeChatIdx: pane.activeChatIdx });
if (closedPaneStack.length > MAX_CLOSED) closedPaneStack.shift();
}
function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
return kind === 'coder' ? 'BooCoder' : 'Terminal';
}
@@ -50,8 +65,8 @@ export function activePaneChatId(pane: WorkspacePane): string | undefined {
// v1.9: settings pane factory. No chats, no state beyond identity — the
// SettingsPane component renders Session/Project sections from the
// surrounding session/project.
function settingsPane(): WorkspacePane {
return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 };
function settingsPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'settings', chatIds: [], activeChatIdx: -1 };
}
// v1.14.x-html-artifact-panes: artifact pane factories. Payload travels with
@@ -135,8 +150,10 @@ export interface UseWorkspacePanesResult {
// Open-on-first-click, close-on-second-click. Singleton — settings panes
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
// falls back to an empty pane to preserve the "always one pane" invariant.
toggleSettingsPane: () => void;
toggleSettingsPane: () => string | null;
removePane: (idx: number) => void;
reopenPane: () => void;
hasClosedPanes: boolean;
removeChatFromPanes: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void;
validatePanes: (validChatIds: Set<string>) => void;
@@ -391,6 +408,14 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
const pane = next[paneIdx]!;
const nextIds = pane.chatIds.filter((id) => id !== chatId);
if (nextIds.length === 0) {
if (next.length > 1) {
// Last tab closed and other panes exist — remove the whole pane
// instead of leaving an orphaned empty panel.
pushClosed(pane); setHasClosedPanes(true);
const spliced = next.filter((_, i) => i !== paneIdx);
setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1));
return spliced;
}
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
} else {
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
@@ -492,14 +517,21 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
return success ? newPaneId : null;
}, [seedPaneChat]);
const toggleSettingsPane = useCallback(() => {
// Returns the new settings pane id when one is OPENED (so mobile callers can
// push ?pane= atomically — see addPaneAndSwitch), or null when it was closed.
// Id generated outside the updater so a strict-mode double-invoke agrees.
const toggleSettingsPane = useCallback((): string | null => {
const newPaneId = generateId();
let openedId: string | null = null;
setPanes((prev) => {
const existingIdx = prev.findIndex((p) => p.kind === 'settings');
if (existingIdx < 0) {
const next = [...prev, settingsPane()];
const next = [...prev, settingsPane(newPaneId)];
setActivePaneIdx(next.length - 1);
openedId = newPaneId;
return next;
}
openedId = null;
if (prev.length <= 1) {
setActivePaneIdx(0);
return [emptyPane()];
@@ -508,6 +540,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next;
});
return openedId;
}, []);
const removePane = useCallback((idx: number) => {
@@ -526,6 +559,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
// The endpoint is idempotent (404 on missing session) so a strict-mode
// double-invoke of the updater is safe.
const removed = prev[idx];
if (removed) { pushClosed(removed); setHasClosedPanes(true); }
if (removed?.kind === 'terminal') {
api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ });
}
@@ -535,6 +569,26 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
});
}, [sessionId]);
const [hasClosedPanes, setHasClosedPanes] = useState(closedPaneStack.length > 0);
const reopenPane = useCallback(() => {
const entry = closedPaneStack.pop();
setHasClosedPanes(closedPaneStack.length > 0);
if (!entry) return;
setPanes((prev) => {
const restored: WorkspacePane = {
id: generateId(),
kind: entry.kind,
chatId: entry.chatIds[entry.activeChatIdx] ?? entry.chatIds[0],
chatIds: entry.chatIds,
activeChatIdx: Math.min(entry.activeChatIdx, entry.chatIds.length - 1),
};
const next = [...prev, restored];
setActivePaneIdx(next.length - 1);
return next;
});
}, []);
// Replaces a single empty default pane with a chat pane. Used by the initial
// chat fetch to land on the most-recent open chat if no saved pane state.
const initializeFirstChatIfEmpty = useCallback((chatId: string) => {
@@ -664,6 +718,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
addSplitPane,
toggleSettingsPane,
removePane,
reopenPane,
hasClosedPanes,
removeChatFromPanes,
initializeFirstChatIfEmpty,
validatePanes,

View File

@@ -18,8 +18,8 @@ export function parseSlashInput(text: string): { cmdName: string; args: string }
return { cmdName: match[1]!, args: (match[2] ?? '').trim() };
}
export function mergeCommandsByName(...lists: SlashCommandItem[][]): SlashCommandItem[] {
const byName = new Map<string, SlashCommandItem>();
export function mergeCommandsByName<T extends SlashCommandItem>(...lists: T[][]): T[] {
const byName = new Map<string, T>();
for (const list of lists) {
for (const cmd of list) {
byName.set(cmd.name, cmd);

View File

@@ -123,6 +123,20 @@ function SessionInner({ sessionId }: { sessionId: string }) {
};
}, [sessionId]);
// v2.3: opening the settings pane on mobile must push ?pane= atomically, or
// the URL-sync effect below snaps activePaneIdx back to the chat pane and the
// settings pane never shows (same fix as addPaneAndSwitch). toggleSettingsPane
// returns the new pane id when it opens (null when it closes → drop ?pane= so
// the effect falls back to pane 0). Desktop has no URL pane state — no-op.
const toggleSettingsAndSync = useCallback(() => {
const openedId = panesHook.toggleSettingsPane();
if (!isMobile) return;
const params = new URLSearchParams(location.search);
if (openedId) params.set('pane', openedId);
else params.delete('pane');
navigate(`${location.pathname}?${params.toString()}`);
}, [panesHook, isMobile, navigate, location.pathname, location.search]);
useEffect(() => {
return sessionEvents.subscribe((event) => {
if (event.type === 'session_renamed' && event.session_id === sessionId) {
@@ -156,10 +170,10 @@ function SessionInner({ sessionId }: { sessionId: string }) {
// Sidebar Settings button broadcasts this when a session is mounted;
// toggleSettingsPane opens on first click, closes on second.
if (event.type === 'open_settings_pane') {
panesHook.toggleSettingsPane();
toggleSettingsAndSync();
}
});
}, [sessionId, editingName, navigate, project, panesHook]);
}, [sessionId, editingName, navigate, project, toggleSettingsAndSync]);
// v1.8: URL ?pane= sync (mobile only). Lifted from Workspace.tsx so
// MobileTabSwitcher's onSwitchPane can push the same URL state and the

View File

@@ -0,0 +1,12 @@
{
"providers": {
"goose": { "enabled": false },
"amp-acp": {
"extends": "acp",
"label": "Amp",
"description": "ACP wrapper for Amp",
"command": ["amp-acp"],
"enabled": true
}
}
}

View File

@@ -1,3 +0,0 @@
{
"providers": {}
}

View File

@@ -0,0 +1,61 @@
---
name: systematic-debugging
description: Guided root-cause debugging. Use when encountering any bug, test failure, unexpected behavior, or performance problem. Enforces investigation before fixes.
---
# Systematic Debugging
No fixes without root cause. Symptom fixes mask real bugs and waste time.
## The Rule
Complete Phase 1 before proposing ANY fix. If you haven't investigated, you cannot fix.
## Phase 1: Root Cause Investigation
1. **Read error messages carefully.** Stack traces, line numbers, error codes. Don't skip past them.
2. **Reproduce consistently.** Exact steps, every time. If not reproducible, gather more data instead of guessing.
3. **Check recent changes.** Git diff, recent commits, new deps, config changes, env differences.
4. **Trace data flow.** Where does the bad value originate? Trace backward through the call stack to the source. Fix at the source, not the symptom.
5. **Multi-component systems:** Before fixing, add diagnostic logging at each component boundary (what enters, what exits). Run once to locate the failing layer, THEN investigate that layer.
## Phase 2: Pattern Analysis
1. Find working examples of similar code in the same codebase.
2. Compare working vs broken — list every difference.
3. If implementing a pattern, read the reference implementation completely, not skimmed.
4. Understand all dependencies, config, and assumptions.
## Phase 3: Hypothesis and Testing
1. State one hypothesis clearly: "X is the root cause because Y."
2. Make the smallest possible change to test it. One variable at a time.
3. If it didn't work, form a NEW hypothesis. Don't stack more fixes on top.
4. After 3 failed fixes: STOP. Question the architecture, not the symptoms.
## Phase 4: Implementation
1. Create a failing test case first (simplest reproduction).
2. Implement a single fix addressing the root cause.
3. Verify: test passes, no regressions, issue actually resolved.
4. If the fix doesn't work and you've tried 3+: the problem is architectural. Discuss before attempting more.
## Red Flags — STOP and return to Phase 1
- "Quick fix for now, investigate later"
- "Just try changing X and see"
- "I don't fully understand but this might work"
- "One more fix attempt" after 2+ failures
- Proposing solutions before tracing data flow
- Each fix reveals a new problem in a different place
## Apply This Skill
Use these tools to investigate before proposing changes:
- `view_file` to read error sites and suspect code paths
- `grep` to find all callers / references to the failing function
- `find_files` to locate related config, test fixtures, schema
- `list_dir` to understand the module layout around the bug
Report your Phase 1 findings (what you observed, what you traced, what you ruled out) before moving to Phase 3.

View File

@@ -2,7 +2,7 @@
This document describes work intentionally **not** shipped in the 2026-05-26 stale/simplify batch. Each item needs a product or architecture decision before implementation. See also [`STALE-DEPRECATED.md`](./STALE-DEPRECATED.md) for what was resolved in that batch.
Last updated: 2026-05-26
Last updated: 2026-05-29
---
@@ -11,7 +11,7 @@ Last updated: 2026-05-26
| Item | Category | User impact | Effort | Risk if left alone |
|------|----------|-------------|--------|-------------------|
| Task cancel → abort ACP/PTY child | Correctness / UX | High — Stop does not kill external agents | Medium | Zombie processes, stuck `running` tasks, orphaned worktrees |
| Skip ACP cold probe when DB fresh | Performance | Medium — composer open can stall 530s on cache miss | Medium (v2.3 batch) | Slow provider picker; repeated ACP spawns on every snapshot rebuild |
| Skip ACP cold probe when DB fresh | Performance | Medium — composer open can stall 530s on cache miss | ✅ Shipped (v2.3, Phase 2) | Resolved — `PROVIDER_PROBE_TTL_MS` TTL gate live |
| Unified `packages/types` | Maintainability | Low (dev-only) | MediumHigh | Type drift between server, coder, web |
| Large file splits | Maintainability | None directly | Medium per file | Harder reviews, merge conflicts |
| Retire `apps/coder/web/` fallback SPA | Scope / ops | Low — Sam uses CoderPane | Medium | Dual UI maintenance, divergent API client |
@@ -111,7 +111,7 @@ There is also **no frontend** calling task cancel today (`grep` across `apps/web
## 2. Skip ACP cold probe when DB models are fresh
**Status:** Planned — [`openspec/changes/v2-3-provider-lifecycle/`](../openspec/changes/v2-3-provider-lifecycle/proposal.md). **Not shipped** (no `v2.3` tag; all tasks unchecked).
**Status:** **ADDRESSED** in v2.3 (phases 15: `v2.5.4-provider-lifecycle-phase1``v2.5.12-provider-lifecycle-phase4`, plus the phase-5 settings UI + picker filter). The `PROVIDER_PROBE_TTL_MS` (default 24h) gate on `available_agents.last_probed_at` is live — the tier-2 cold ACP probe runs only on `force` (`POST /api/providers/refresh`), TTL staleness, or empty DB models; otherwise the snapshot serves cached models. See [`openspec/changes/v2-3-provider-lifecycle/`](../openspec/changes/v2-3-provider-lifecycle/proposal.md). The original (v2.2) behavior below is kept for history.
### Current behavior (v2.2)
@@ -140,12 +140,21 @@ See [`design.md`](../openspec/changes/v2-3-provider-lifecycle/design.md):
v2.2 shipped the snapshot wire shape and ACP dispatch stack. Lifecycle semantics (config registry, enable/disable, probe TTL, settings UI) were scoped as the follow-on **v2.3** batch to avoid mixing two large behavior changes in one tag.
### Acceptance criteria (when v2.3 ships)
### Acceptance criteria — met
- Second `GET /api/providers/snapshot` within TTL does not invoke `probeAcpProvider` (mock assert in tests)
- Disabled provider visible in settings, absent from composer
- Second `GET /api/providers/snapshot` within TTL does not invoke `probeAcpProvider` (mock assert in `provider-snapshot.test.ts`)
- Disabled provider visible in settings (Providers tab), absent from composer
- Explicit refresh repopulates models; warm open is sub-second
### Still deferred (Tier-2 follow-ups, not shipped in v2.3)
These were explicitly scoped out of v2.3 (see `design.md` §11) and remain open:
- **`provider_snapshot_updated` WS frame** — the loading state uses a capped client poll / one-shot refetch instead of a server-pushed frame (design §4.4, §11; tasks O.1).
- **`available_agents.enabled` DB column** — `enabled` is read from the in-memory resolved registry only; no DB mirror, so settings state after a coder restart re-derives from the JSON config rather than the DB (design §3.3; tasks O.2).
- **Single-source-of-truth shared types package** — the provider snapshot types are duplicated across `apps/coder/.../provider-types.ts` and `apps/web/src/api/types.ts`, guarded by the text-identity `provider-types-parity.test.ts` rather than a shared package (see §3 below).
- **MCP `list_providers` / `inspect_provider` tools** — provider introspection over MCP is not wired (design §11).
---
## 3. Unified `packages/types` for provider snapshot JSON

10
pnpm-lock.yaml generated
View File

@@ -63,6 +63,9 @@ importers:
'@modelcontextprotocol/sdk':
specifier: ^1.29.0
version: 1.29.0(zod@3.25.76)
'@opencode-ai/sdk':
specifier: ~1.15.0
version: 1.15.12
fastify:
specifier: ^4.28.1
version: 4.29.1
@@ -920,6 +923,9 @@ packages:
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@opencode-ai/sdk@1.15.12':
resolution: {integrity: sha512-lOaBNX93dkakZe6C42ttX1bkSx3K2c6+Yv+w8Qv02v5rPlu1vCXbmdfYDh9/bw+oq+NKPSaBm9d6kPA19hA5Lg==}
'@opentelemetry/api@1.9.1':
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
@@ -4702,6 +4708,10 @@ snapshots:
'@open-draft/until@2.1.0': {}
'@opencode-ai/sdk@1.15.12':
dependencies:
cross-spawn: 7.0.6
'@opentelemetry/api@1.9.1': {}
'@pinojs/redact@0.4.0': {}