Compare commits

...

24 Commits

Author SHA1 Message Date
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
dc3859975d coder(providers): capture + persist opencode's live ACP commands (no dispatch needed)
The cold ACP probe captured available_commands but read probedCommands synchronously right after newSession, racing opencode's async available_commands_update notification -> captured nothing, only the static manifest showed. The probe now waits (poll <=3s + 300ms settle) for the notification. Captured commands persist to a new available_agents.commands column and are served (merged with the manifest) on the tier-2-skip path, so the agent's discovered commands survive once models are warm and show without a dispatch. Boot warms via the force:true startup snapshot. Caveat: relies on opencode emitting available_commands_update on session creation, not only post-prompt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:56:18 +00:00
23a33e893a web+coder: segmented per-agent slash menu (agent commands + skills) + cross-agent skill execution
Coder / menu now shows two groups: the active agent's commands first (manifest + live ACP available_commands), BooCoder skills second. SlashCommandPicker gains an opt-in groups prop (flat items path unchanged -> BooChat byte-identical, parity verified); ChatInput takes slashGroups; CoderPane builds the groups. Skills run under the selected agent: coder skill_invoke accepts a provider and, when external, injects the server-side skill body into a dispatched task instead of native inference. Also folds in the initial-chat skill fix (handleLandingSkill: create chat -> assign to pane -> invoke, same transition as a text send) that resolves the landing-page blank screen. BooChat slash menu + skill invocation unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:38:39 +00:00
8bf86ecb92 web(coder): keep composer refresh on the top line + icon-only Mode picker on mobile
The AgentComposerBar refresh button wrapped to a second line on mobile: the status dot had ml-auto (pinned to the far-right edge) and the refresh button followed it in DOM order, overflowing past the edge. Group the dot + refresh into one right-aligned (ml-auto) unit so the refresh stays on the top line. Also add an iconOnly option to CompactPicker and render the Mode (permission) picker icon-only on mobile (shield + chevron, no label; aria-label/title + tap-to-open list still convey the selection) to free row width. Desktop unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:46:40 +00:00
fe52250d78 coder(providers): fix empty picker (loading-state) + config model overrides + current Claude models
Fix: getProviderSnapshot returned synchronous installed:false 'loading' entries on a cache miss (v2.5.5/Phase 2), which AgentComposerBar filters out — with the Phase 5 client poll not yet built, a single fetch stranded on 'loading' and the picker showed no providers. It now awaits the build and returns terminal entries; the sync loading-return is deferred until Phase 5. Builds stay fast via the tier-2 cold-probe skip.

Feature: wire the v2.3 config schema's models/additionalModels — buildResolvedRegistry carries them onto ResolvedProviderDef (models replace, additionalModels merge) and provider-snapshot applies them to every ready model list, so /data/coder-providers.json can edit any provider's models with no code change. Claude staticModels bumped from the stale 2-entry list to opus/sonnet/haiku latest-aliases + pinned claude-opus-4-8 / claude-sonnet-4-6 / claude-haiku-4-5-20251001 (passed verbatim to claude --model). +2 tests (109 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:37:01 +00:00
4035aa2b98 coder(providers): v2.3 provider-lifecycle phase 3 — generic ACP dispatch
ACP dispatch now spawns from the resolved registry's launch spec instead of a hardcoded per-name switch. acp-spawn.ts gains resolveLaunchSpec(resolved, installPath): launchCommand (config override / custom-ACP command) wins, else the kept resolveAcpSpawnArgs switch is the built-in fallback. acp-dispatch.ts spawns spec.binary/spec.args with env { ...process.env, ...spec.env }; dispatcher.ts loads the resolved def by task.agent and passes it through. Config-defined custom ACP providers dispatch with no new switch case. Built-in dispatch (opencode/goose/qwen) is byte-identical to pre-v2.3 — proven by a regression test (opencode->['acp'], goose->['acp'], qwen->['--acp'], binary=installPath ?? id, empty env -> plain process.env). Deliberate deviation from design's !installPath->null: the installPath ?? id fallback is preserved. setSessionMode/permission/streaming and the dispatcher poll/NOTIFY/running-guard untouched. 7 new acp-spawn.test.ts cases. No routes/UI (Phase 4+).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:06:32 +00:00
35a0aba211 coder(providers): v2.3 provider-lifecycle phase 2 — snapshot lifecycle
provider-snapshot no longer returns null for uninstalled/disabled providers: it emits one entry per registered provider with a lifecycle status (loading|ready|unavailable|error), an enabled flag, and a two-tier probe. Tier-1 is a fast which-style check (command-availability.ts, execFile/no-shell); tier-2 (cold ACP probe) is skipped unless forced, last_probed_at is older than PROVIDER_PROBE_TTL_MS (24h), or DB models are empty — the snapshot-latency win. Cache miss returns status:'loading' synchronously while the build settles via the existing inflight promise. ProviderSnapshotStatus/Entry regain loading/unavailable + gain enabled/description?/fetchedAt? in both coder and web copies, guarded by a runtime parity test (provider-types-parity.test.ts; compile-time cross-project check was blocked by TS6307). Also tracks the data/coder-providers.json seed via a .gitignore exception, completing the Phase 1 config file. No dispatch/route/UI changes (Phase 3+); AgentComposerBar filtering unchanged. 13 snapshot tests (+6) + 6 parity tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:47:48 +00:00
3730dc9341 coder(providers): v2.3 provider-lifecycle phase 1 — config-backed registry
Adds a config layer merged over the hardcoded built-ins (tasks 1.1-1.6): CODER_PROVIDERS_PATH env (default /data/coder-providers.json); provider-config.ts (Zod schema + never-throw loader — missing/invalid file falls back to built-ins only — + save); provider-config-registry.ts (ResolvedProviderDef + buildResolvedRegistry merge: override built-ins, add custom extends:'acp' entries, boocode always enabled + singleton); agent-probe now iterates the resolved registry, probes custom-ACP command[0] via execFile (no shell), skips disabled providers (keeps the row), reads enabled from memory only (no DB column). No snapshot/dispatch/route/UI changes (Phase 2+). 6 new unit tests; empty config provably yields exactly the built-ins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 04:09:34 +00:00
a359a4ab8b coder(providers): remove retired cursor and copilot providers
Drop both retired providers from BooCoder's provider layer: acp-spawn argv cases, provider-manifest mode blocks + manifest keys, provider-commands maps, the provider-snapshot cursor model-CLI branch (+ orphaned exec/promisify imports), the agent-probe copilot ACP-detect branch, and the now-dead cursor-models module + its test. The PROVIDERS registry array already lacked both. Built-ins unchanged: claude, opencode, goose, qwen, native boocode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 04:07:21 +00:00
a8c84ecfe4 chore+docs: config, agent registry, codecontext, v2.6 spec, changelog
Working-tree config/doc changes (.gitignore, CLAUDE.md, AGENTS.md removal + data/AGENTS.md, codecontext Dockerfile/shim — pre-existing) plus this session's v2-6 persistent-agent-sessions openspec proposal/design/tasks (planning only; feature unimplemented, reserves the v2.6.0 tag) and the v2.5.2 CHANGELOG entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:12:31 +00:00
547fd70650 server/coder: working-tree backend changes (pre-existing)
Checkpoint of in-progress backend work present in the tree, not authored this session: auto_name, inference tool-phase/turn, secret_guard, provider-registry, plus a new agent-allowlist test (7 tests, passing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:12:16 +00:00
990a615b87 web(coder UI): ChatInput migration + Thinking render + DiffPanel route fix
Bundles in-progress working-tree UI work not authored this session (CoderPane ChatInput migration, AgentComposerBar/CoderMessageList/tab-bar/sidebar/pane refinements, provider icons) with this session's changes to the same files: MessageBubble renders a collapsible 'Thinking' block from reasoning_text/reasoning_parts (surfacing ACP agent_thought_chunk + native reasoning), and the DiffPanel approve/reject calls are repointed to the real /api/coder/pending/:id/apply and /reject routes (the old /sessions/:id/pending/:id/approve|reject paths did not exist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:12:06 +00:00
5352fd9942 coder(pending): new-file-from-RightRail create endpoint + modal
POST /api/sessions/:sessionId/pending/create queues a pending_changes create via queueCreate (WriteGuardError -> 422 with the guard message). RightRail gains a 'New file from pasted text' modal (path + content) wired through api.coder.createPendingFile; sessionId is threaded down from App.tsx. The staged change shows in the CoderPane DiffPanel for explicit apply.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:11:50 +00:00
66df410826 web: fix mobile nav stuck-open on rejoin + paste-chip code fence
useViewport re-syncs the snapshot on pageshow/visibilitychange/resize/orientationchange — iOS reported a stale width on backgrounded-tab restore, leaving isMobile=false so the sidebar rendered as a permanent column with no close affordance. flattenToMessage now inserts pasted-text chips verbatim instead of wrapping them in a triple-backtick fence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:11:42 +00:00
f89c8f3f15 coder(dispatcher): react to new tasks via LISTEN/NOTIFY, poll as fallback
AFTER INSERT trigger on tasks fires pg_notify('tasks_new'); the dispatcher listens via porsager sql.listen and triggers an immediate poll, with the setInterval poll kept at 2s as a missed-notification safety net. Per-session guard unchanged (no double-dispatch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 03:11:34 +00:00
cbef7618b3 v2.5.1-budget-100: raise all tool call budgets to 100 + codecontextignore fix
Budget defaults raised from 50/10/50 to 100/100/100 (read-only,
non-read-only, no-agent). Per-agent max_tool_calls from AGENTS.md
still overrides.

Added .claude/worktrees/ to .codecontextignore to prevent
get_codebase_overview from parsing empty stub files in stale
worktree node_modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-28 02:40:26 +00:00
82 changed files with 4125 additions and 1089 deletions

View File

@@ -21,6 +21,7 @@ out/
.opencode/
.vscode/
.idea/
.claude/worktrees/
# Test artifacts / coverage
coverage/

2
.gitignore vendored
View File

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

109
AGENTS.md
View File

@@ -1,109 +0,0 @@
# Agent navigation
Cursor/agent entry point for the BooCode monorepo. **Deep engineering reference:** `CLAUDE.md` (Claude Code). This file is navigation + task routing only.
Last updated: 2026-05-25
## Doc map
| Need | Read |
|------|------|
| Commands, gotchas, inference, DB, env | `CLAUDE.md` |
| Read-only chat behavior | `BOOCHAT.md` |
| Write tools, dispatch, pending changes | `BOOCODER.md` |
| Shipped vs planned, version order | `boocode_roadmap.md` |
| Latest release truth | `CHANGELOG.md` (top entry = current) |
| System diagram + data flow | `docs/ARCHITECTURE.md` |
| Current focus / blockers | `CURRENT.md` |
| Batch convention | `openspec/README.md` |
| Shipped batch snapshots | `openspec/changes/archived/` |
| Chat agent personas + tool lists | `data/AGENTS.md` |
| External repo lift inventory | `boocode_code_review.md` |
## Monorepo layout (actual)
Three **surfaces**, four **packages**. There is no `apps/chat/` directory.
| Surface | Packages | Port | Deploy |
|---------|----------|------|--------|
| **BooChat** | `apps/server` (API + inference) + `apps/web` (SPA) | `100.114.205.53:9500` | Docker `boocode` container |
| **BooTerm** | `apps/booterm` | `100.114.205.53:9501` | Docker `booterm` container |
| **BooCoder** | `apps/coder` | host `:9502` | systemd `boocoder.service` (not Docker since v2.1.0) |
Shared: Postgres 16 — Docker service `boocode_db`, **database name `boochat`**, host port `127.0.0.1:5500`.
## Task routing
| Task type | Start here |
|-----------|------------|
| Chat inference / tools / compaction | `apps/server/src/services/inference/` |
| WS frames | `apps/server/src/types/ws-frames.ts` + `apps/web/src/api/ws-frames.ts` (keep in sync) |
| Frontend chat UI | `apps/web/src/components/`, hooks in `apps/web/src/hooks/` |
| BooCoder write tools / dispatch | `apps/coder/src/` — build server first (`pnpm -C apps/server build`) |
| Provider picker / external agents | `apps/coder/src/services/provider-registry.ts`, `dispatcher.ts`, `agent-probe.ts` |
| Terminal panes | `apps/booterm/src/`, frontend `TerminalPane.tsx` |
| Schema changes | `apps/server/src/schema.sql` + sync `*_STATUSES` in `apps/server/src/types/api.ts` |
| New batch / feature | `openspec/changes/<slug>/proposal.md` + `tasks.md` (see below) |
## Verification (before claiming done)
```bash
pnpm -C apps/server test && pnpm -C apps/server build
npx tsc -p apps/web/tsconfig.app.json --noEmit # root tsc can miss web errors
curl http://100.114.205.53:9500/api/health # Tailscale IP, not localhost:9500
curl http://100.114.205.53:9502/api/health # BooCoder on host
```
Deploy truth beats source-only reads — check running health + `git log --oneline -3`.
## Hard rules (from CLAUDE.md)
- **Do not commit or push** unless Sam explicitly asks.
- **No app-layer auth** — Authelia at the reverse proxy.
- **Parts table is source of truth** — read message tool fields from `messages_with_parts` view, write via `insertParts`.
- **New WS frame type** — update server + web schemas; publish via `publishFrame` / `publishUserFrame` only.
- **New tool** — own file in `services/`, register in `tools.ts` `ALL_TOOLS`; whitelists derive from there, never hardcoded.
- **Typecheck web with per-app tsconfig** — root `tsc --noEmit` uses project references and can miss errors.
- **`includeUsage: true`** on `createOpenAICompatible` in `provider.ts` — do not remove.
- **Agent dispatch** — direct `spawn`/`exec` on host via `install_path` (v2.1.0+); SSH helpers deprecated.
- **Event dedup** — server publishes via broker; frontend must not duplicate `sessionEvents.emit` after API calls that already WS-broadcast.
## Using openspec with Cursor
Openspec is a **folder convention**, not a CLI. Use it to give agents a scoped brief before coding.
### When starting a batch
1. Create `openspec/changes/<slug>/` (lowercase-hyphenated, e.g. `v2-2-arena-ui`).
2. Write `proposal.md` — why, scope, non-goals, dependencies.
3. Write `tasks.md` — numbered checkbox steps (build + smoke).
4. Optional `design.md` — schema/API decisions that outlive the batch.
See `openspec/README.md` for the full shape. Shipped pre-v1.13.15 batches live in `openspec/changes/archived/` as snapshots only.
### Prompting an agent
```
@openspec/changes/<slug>/proposal.md @openspec/changes/<slug>/tasks.md
Implement tasks 13. Server tests must pass. Do not commit.
```
Attach the spec files with `@` so they load into context. Point at specific code paths when known:
```
@openspec/changes/v2-x/proposal.md
Extend apps/coder/src/routes/providers.ts — follow provider-registry.ts patterns.
```
### After shipping
- Tag: `vMAJOR.MINOR.PATCH-slug`
- Add entry to top of `CHANGELOG.md`
- Move or snapshot the openspec folder to `archived/` if you want history preserved
- Update `CURRENT.md` and `boocode_roadmap.md` shipped table if the batch was roadmap-tracked
### What not to use openspec for
- One-line bug fixes — just describe the bug + file.
- Exploratory questions — Ask mode + `@CLAUDE.md` is enough.
- Duplicating `CLAUDE.md` — openspec is per-batch scope, not permanent conventions.

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 **tracked in git** via a `.gitignore` exception (the rest of `data/*` is ignored). 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,58 @@
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.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.
## v2.5.9-agent-slash-commands — 2026-05-29
Segmented per-agent slash menu in the coder pane, plus cross-agent skills. The `/` menu now shows two labeled groups — **the active agent's commands first** (opencode/claude/qwen manifest + live ACP `available_commands`), **BooCoder skills second** — instead of always showing BooCoder's skills regardless of provider. `SlashCommandPicker` gains an opt-in `groups` prop (the flat `items` path is unchanged, so **BooChat's menu is byte-identical** — parity verified: no BooChat caller passes the grouped prop, and the skills lookup / invocation routing are untouched); `ChatInput` takes `slashGroups`; `CoderPane` builds the groups from the selected provider's commands + skills. Skills now **run under the selected agent**: the coder `skill_invoke` route accepts a `provider` and, when external, injects the server-side skill body into a dispatched task (instead of native inference) — so a skill like brainstorming executes through opencode/claude with the body kept server-side, mirroring the messages-route external dispatch. Also folds in the earlier initial-chat fix: invoking a skill on the landing chat now runs the same create-chat → assign-to-pane → invoke transition as a text send (`handleLandingSkill`) rather than invoking invisibly without a pane transition (the blank-screen repro). Web tsc + coder build clean.
## v2.5.8-mobile-composer-row — 2026-05-29
Mobile fix for the `AgentComposerBar`: the refresh button was wrapping to a second line. Root cause was layout order, not width — the status dot carried `ml-auto` (pinned to the far-right edge) and the refresh button followed it in DOM order, so it overflowed and wrapped. The dot + refresh are now one right-aligned (`ml-auto`) unit, keeping the refresh on the top line. Additionally, `CompactPicker` gained an `iconOnly` option and the Mode (permission) picker now renders icon-only on mobile (shield + chevron, no "Bypass"/"Plan" text label; `aria-label`/`title` and the tap-to-open list still convey the value) to free row width. Desktop is unchanged (full labels). Web-only change.
## v2.5.7-claude-models-and-picker-fix — 2026-05-29
Two provider-layer changes. **(1) Fix the empty provider picker** — a regression from `v2.5.5` (Phase 2): on a cache miss `getProviderSnapshot` returned synchronous `installed:false` `loading` entries, which `AgentComposerBar` filters out (`e.installed && e.status !== 'error'`); with the client-side poll deferred to Phase 5, a single fetch landed on `loading` forever and no providers appeared. `getProviderSnapshot` now awaits the build and returns terminal entries (the sync `loading` return is deferred until Phase 5 ships the poll); builds stay fast via the tier-2 cold-probe skip. **(2) Claude models** — the list was a hardcoded 2-entry static list (Opus 4 / Sonnet 4, May 2025), and the v2.3 config schema's `models`/`additionalModels` were parsed but never wired. `buildResolvedRegistry` now carries config `models` (replace) + `additionalModels` (merge) onto `ResolvedProviderDef`, and `provider-snapshot` applies them to every ready model list — so `/data/coder-providers.json` can add or replace any provider's models with no code change. Claude `staticModels` bumped to `opus`/`sonnet`/`haiku` latest-aliases plus pinned `claude-opus-4-8` / `claude-sonnet-4-6` / `claude-haiku-4-5-20251001` (passed verbatim to `claude --model`; the CLI accepts both aliases and pinned full names). +2 unit tests (109 total). Builds on `v2.5.6-provider-lifecycle-phase3`.
## v2.5.6-provider-lifecycle-phase3 — 2026-05-29
Phase 3 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §5): generic ACP dispatch. `acp-spawn.ts` gains `resolveLaunchSpec(resolved, installPath)` — it consults the resolved registry's `launchCommand` (a config override or a custom-ACP entry's command) first, falling back to the kept `resolveAcpSpawnArgs` switch for built-ins. `acp-dispatch.ts` now spawns `spec.binary`/`spec.args` with `env: { ...process.env, ...spec.env }` instead of the hardcoded per-name argv, and `dispatcher.ts` loads the resolved def by `task.agent` and passes it through. This lets config-defined custom ACP providers dispatch with no new switch case. Built-in dispatch (claude/opencode/goose/qwen) is **byte-identical** to pre-v2.3 — proven by a regression test asserting opencode→`['acp']`, goose→`['acp']`, qwen→`['--acp']`, binary=`installPath ?? id`, and empty config env → plain `process.env`. One deliberate deviation from the spec's literal `!installPath → null`: the `installPath ?? id` fallback is preserved so a missing install path still spawns the bare agent name as before. `setSessionMode`/permission/streaming and the dispatcher poll/NOTIFY/running-guard are untouched. 7 new `acp-spawn.test.ts` cases. No routes/UI (Phase 4+). Builds on `v2.5.5-provider-lifecycle-phase2`.
## v2.5.5-provider-lifecycle-phase2 — 2026-05-29
Phase 2 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §4). `provider-snapshot.ts` stops returning `null` for uninstalled/disabled providers — it now emits one entry per registered provider with a lifecycle status (`loading | ready | unavailable | error`), an `enabled` flag, and a two-tier probe. Tier-1 is a fast `which`-style availability check (`command-availability.ts`, `execFile`/no-shell); tier-2 — the 530s cold ACP probe — is now SKIPPED unless forced (`POST /refresh`), the `available_agents.last_probed_at` row is older than `PROVIDER_PROBE_TTL_MS` (24h default), or the DB model list is empty, which kills snapshot latency on warm reads. A cache miss returns `status:'loading'` synchronously while the build settles in the background (client polling is deferred to Phase 5). `ProviderSnapshotStatus`/`ProviderSnapshotEntry` regained `loading`/`unavailable` and gained `enabled`, `description?`, `fetchedAt?` in both the coder and web copies, guarded by a runtime parity test (`provider-types-parity.test.ts`, mirroring the `ws-frames.test.ts` convention) that fails on any field drift — a compile-time cross-project assignability check was attempted first but blocked by TS6307 (web is a composite tsconfig project). Also tracks the previously-gitignored `data/coder-providers.json` seed via a `.gitignore` exception, completing the Phase 1 config file. No dispatch/route/UI changes (Phase 3+); AgentComposerBar filtering unchanged. Builds on `v2.5.4-provider-lifecycle-phase1`.
## v2.5.4-provider-lifecycle-phase1 — 2026-05-29
Phase 1 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §23): a config-backed provider layer merged over the hardcoded built-ins, with no runtime change when no config file exists. Adds `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`); `provider-config.ts` (Zod `ProviderOverride`/`CoderProvidersFile` schemas + a loader that never throws at startup — a missing file, invalid JSON, or schema mismatch all fall back to built-ins-only — plus `save` for the Phase 4 PATCH route); and `provider-config-registry.ts` (`ResolvedProviderDef` + `buildResolvedRegistry` merge: built-in overrides, custom `extends:'acp'` entries requiring label+command, `boocode` always enabled, plus a module singleton). `agent-probe.ts` now iterates the resolved registry instead of the hardcoded list — custom ACP entries resolve their binary from `command[0]` via `execFile` (no shell), disabled providers skip probing without losing their row, and `enabled` is read from memory only (no DB column this phase). Six unit tests, including a regression proving an empty config yields exactly the built-ins. No snapshot/dispatch/route/UI changes (Phase 2+). The `data/coder-providers.json` seed exists on disk but is gitignored (`data/*`). Lands on top of `v2.5.3-remove-cursor-copilot`.
## v2.5.3-remove-cursor-copilot — 2026-05-29
Retire the cursor and copilot providers from BooCoder entirely. Removes their `acp-spawn` argv cases, `provider-manifest` mode blocks + manifest keys, `provider-commands` command maps, the `provider-snapshot` cursor model-CLI branch (and the now-orphaned `exec`/`promisify` imports), and the `agent-probe` copilot ACP-detect branch; deletes the dead `cursor-models.ts` module and its test. The `PROVIDERS` registry array already lacked both entries, so only the doc comment needed correcting. Built-ins unchanged: claude, opencode, goose, qwen, native boocode. Standalone cleanup; pairs with `v2.5.4-provider-lifecycle-phase1` which builds on it.
## v2.5.2-coder-ux-fixes — 2026-05-29
Working-tree checkpoint bundling this session's fixes with in-progress coder UI work. This session: the BooCoder dispatcher now reacts to new tasks immediately via a Postgres `LISTEN/NOTIFY` (`tasks_new`) AFTER INSERT trigger, with the poll loop kept at 2s as a missed-notification fallback (`dispatcher.ts`, `apps/coder/src/schema.sql`); the mobile nav drawer no longer sticks open after returning to a backgrounded tab — `useViewport` re-syncs on `pageshow`/`visibilitychange`/`resize`/`orientationchange` (iOS reported a stale width on bfcache restore, leaving `isMobile=false`); assistant reasoning renders as a collapsible "Thinking" block in `MessageBubble`, surfacing ACP `agent_thought_chunk` from opencode/goose/qwen and native `reasoning_parts`; paste-to-chip inserts pasted text verbatim instead of wrapping it in a code fence; and a "New file from pasted text" affordance in the RightRail browser queues a `pending_changes` create through the new `POST /api/sessions/:id/pending/create` endpoint, paired with a fix repointing the DiffPanel's dead approve/reject calls to the real `/api/pending/:id/apply` and `/reject` routes. Also carried in the tree but not authored this session: the CoderPane `ChatInput` migration and `AgentComposerBar` refinements, plus backend tweaks to `auto_name`, inference `tool-phase`/`turn`, `secret_guard`, and `provider-registry`. Ships the `v2-6-persistent-agent-sessions` openspec proposal/design/tasks (free agent-switching with per-agent memory, opencode-as-server) as planning docs only — the feature is unimplemented and reserves the `v2.6.0` tag for it. Build green across server/coder/web; server suite 531 passing. (CHANGELOG note: the v2.3v2.5.1 entries were never backfilled and remain absent above.)
## v2.2.2-xml-placeholder-reject — 2026-05-26
Reject placeholder XML tool args at parse time in `extractToolCallBlocks` (`xml-parser.ts`). Drops calls when any string arg is `...`, empty/whitespace, `<path>`, `<file>`, `placeholder`, or angle-bracket sentinels; appends the raw XML block to flushed prose instead of silently deleting it. Fixes qwen3.6 answer-then-spurious-tools tail that caused duplicate assistant rows (full answer + failed `xml_call_*` tools + regenerated answer). Four new tests in `xml-parser.test.ts`. Known nit: rejection logs via `console.debug` instead of pino — filed in `docs/DEFERRED-WORK.md` §6 for a later cleanup.

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,16 @@ 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 the live config (tracked via a `.gitignore` exception, bind-mounted); UI toggles mutate it on disk → working-tree drift, don't commit it as a code change.
- External agents dispatch **one-shot** (`opencode acp` / `goose acp` / `qwen --acp`) and report no context-window/token usage; only native `boocode` (llama-swap engine) tracks ctx. OpenCode-as-HTTP-server (warm process + `@opencode-ai/sdk`, the source of a real context bar) is the **planned, unshipped** `openspec/changes/v2-6-persistent-agent-sessions` batch; Paseo's per-provider native clients (design §12) were deliberately not ported.
### Frontend (`apps/web/src/`)
@@ -145,10 +149,12 @@ 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. 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.
- 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.
- Host-side smoke endpoint: `curl http://100.114.205.53:9500/api/...`. The boocode container's port mapping binds to the Tailscale IP, not `0.0.0.0`, so `localhost:9500` doesn't work from the host shell. Same for booterm at `:9501`.
- Frontend blank-screen / runtime crash: get the stack-trace column offset from the browser console, then `cut -c <start>-<end> apps/web/dist/assets/index-*.js | sed -n '<line>p'` to read the exact minified expression that threw. Faster than bisecting source. Watch for `=== null`/`!== null` on optional fields fed an `as unknown as` cast — those bypass tsc.
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
- `node:20-*` base images ship a `node` user at uid/gid 1000 — delete it (`userdel`/`groupdel` on debian, `deluser`/`delgroup` on alpine) before adding samkintop at 1000.
@@ -157,8 +163,8 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
- A local PreToolUse hook (`security_reminder_hook.py`) regex-flags Node's older `child_process` spawn helpers as unsafe (false positive even on the File-suffixed variant). Use `spawn` — it's accepted.
- `/opt/boolab` hosts a working sibling BooCode terminal at `boocode.indifferentketchup.com`. Useful for visual side-by-side comparison on the same iPhone when debugging booterm rendering. Boolab uses Tailwind v3 (`@tailwind base`); boocode uses v4 — many subtle build differences. Don't assume parity.
- booterm SSHs to the host as `samkintop@100.114.205.53` (the Tailscale IP). The hostname `ubuntu-homelab` (shown in the bash prompt after login) does NOT resolve from inside the container — only the host's `/etc/hosts` knows it. Override via `BOOTERM_SSH_HOST` / `BOOTERM_SSH_USER` env vars in docker-compose if you ever move the shell to a different machine.
- codecontext sidecar lives at `/opt/boocode/codecontext/`. Sidecar HTTP API at `http://codecontext:8080/v1/<tool_name>` over the `boocode_net` bridge (no host port). BooCode wrappers in `apps/server/src/services/tools/codecontext/`. The `.codecontextignore.template` documents recommended ignore patterns; users copy and adapt to project root manually.
- codecontext fork at `/opt/forks/codecontext/` — separate git repo (branch `boocode-ts`), pushed via the same boocode_gitea SSH key to `indifferentketchup/codecontext`. Build: `go build ./...`. Test: `go test ./...`. Docker rebuild: `docker compose build --no-cache codecontext`.
- codecontext sidecar lives at `/opt/boocode/codecontext/`. Sidecar HTTP API at `http://codecontext:8080/v1/<tool_name>` over the `boocode_net` bridge (no host port). BooCode wrappers in `apps/server/src/services/tools/codecontext/`. The `.codecontextignore` at project root is honored when `--respect-gitignore` is passed (enabled in the shim).
- codecontext fork at `/opt/forks/codecontext/` — separate git repo (branch `boocode-ts`), pushed via the same boocode_gitea SSH key to `indifferentketchup/codecontext`. Build: `go build ./...`. Test: `go test ./...`. Docker rebuild requires staging the fork source first: `tar -czf codecontext/fork.tar.gz -C /opt/forks/codecontext --exclude=.git --exclude=bin .` then `docker compose build --no-cache codecontext`. The Dockerfile COPYs `fork.tar.gz` into the builder stage (Gitea is behind Authelia, no HTTP clone). `fork.tar.gz` is gitignored.
- Go binary: `/snap/go/current/bin/go` (not on PATH by default). Use `export PATH=$PATH:/snap/go/current/bin` or full path for Go commands.
- `os/exec` child supervisors must explicitly call `child.Wait()` in a goroutine and `os.Exit` on child death. `Signal(0)` returns nil on zombies and is NOT a liveness check. Without `Wait()`, docker's `restart: unless-stopped` policy never fires because the parent stays alive. The `codecontext/shim.go` implementation is the reference pattern.
@@ -171,10 +177,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`.
@@ -185,3 +193,14 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
- **Workspace dependency pattern** (`apps/coder``@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
- **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of a JSON object/array). Pattern established in `parts.ts`, `settings.ts`.
- **`payload.ts:loadContext` SELECT**: must include every `Session` field that downstream code reads. The tool phase reads `session.allowed_read_paths`; if the SELECT omits it, cross-repo read grants silently fail. The `Session` TypeScript type doesn't catch this because `sql<Session[]>` doesn't enforce column coverage.
- **Sidecar routing** (`services/inference/provider.ts`): `upstreamModel(config, modelId, agent)` routes to `LLAMA_SIDECAR_URL` when agent has `llama_extra_args`, otherwise `LLAMA_SWAP_URL`. `resolveRoute(agent)` returns `{route: 'swap'|'sidecar', flags}`. Sidecar provider created fresh per call (not cached) because `X-Agent-Flags` header varies per agent. Boot-time guard in `index.ts` refuses to start if any agent has `llama_extra_args` but `LLAMA_SIDECAR_URL` is unset.
- **Secret guard safe patterns** (`services/secret_guard.ts`): `.env.example`, `.env.sample`, `.env.template`, `.env.defaults` are allowlisted via `SAFE_PATTERNS` set. Do NOT add `.env.production`/`.env.development`/`.env.test` — those can hold real secrets.
- **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

@@ -13,3 +13,4 @@ GITEA_USER=indifferentketchup
GITEA_SSH_HOST=100.114.205.53:2222
MCP_CONFIG_PATH=/data/mcp.json
SKILLS_ROOT=/opt/boocode/data/skills
CODER_PROVIDERS_PATH=/opt/boocode/data/coder-providers.json

View File

@@ -23,6 +23,13 @@ const ConfigSchema = z.object({
GITEA_TOKEN: z.string().optional(),
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
MCP_CONFIG_PATH: z.string().optional(),
// v2.3: config-backed provider overrides/custom-ACP entries merged over the
// hardcoded built-ins. Missing file = built-ins only (see provider-config.ts).
CODER_PROVIDERS_PATH: z.string().default('/data/coder-providers.json'),
// v2.3 phase 2: tier-2 (cold ACP probe) is skipped when available_agents was
// probed more recently than this. 24h default — stale model lists self-heal
// on the next snapshot; an explicit /refresh always re-probes.
PROVIDER_PROBE_TTL_MS: z.coerce.number().int().positive().default(86_400_000),
// v2.0.5: cheaper model for titles, summaries, labeling.
FAST_MODEL: z.string().optional(),
// SSH access to the host for external agent dispatch (Phase 5)

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,4 +1,5 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import type { Sql } from '../db.js';
import {
listPending,
@@ -6,7 +7,14 @@ import {
applyAll,
rejectOne,
rewindOne,
queueCreate,
} from '../services/pending_changes.js';
import { WriteGuardError } from '../services/write_guard.js';
const CreateBody = z.object({
file_path: z.string().min(1),
content: z.string(),
});
/**
* Resolve project root from a session's project path.
@@ -51,6 +59,49 @@ export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
},
);
// POST /api/sessions/:sessionId/pending/create — queue a new-file create
// (manual create from the RightRail file browser; no inference involved).
// queueCreate runs resolveWritePath internally, so a path that escapes the
// project root or hits a secret file throws WriteGuardError → 422 with the
// guard message. Mirrors the { error } 404 shape used by the other routes
// and the 422 status used by apply/rewind on failure.
app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/pending/create',
async (req, reply) => {
const sessionId = req.params.sessionId;
const parsed = CreateBody.safeParse(req.body);
if (!parsed.success) {
reply.code(400);
return { error: 'invalid body', details: parsed.error.flatten() };
}
const projectRoot = await resolveProjectRoot(sql, sessionId);
if (!projectRoot) {
reply.code(404);
return { error: 'session or project not found' };
}
try {
const change = await queueCreate(
sql,
sessionId,
null,
parsed.data.file_path,
parsed.data.content,
projectRoot,
);
return change;
} catch (err) {
if (err instanceof WriteGuardError) {
reply.code(422);
return { error: err.message };
}
throw err;
}
},
);
// POST /api/sessions/:sessionId/pending/apply — apply all pending changes
app.post<{ Params: { sessionId: string } }>(
'/api/sessions/:sessionId/pending/apply',

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

@@ -16,6 +16,12 @@ const SkillInvokeBody = z.object({
pane_id: z.string().min(1).max(200),
skill_name: z.string().min(1),
user_message: z.string().max(64_000).nullable().optional(),
// v2.5.9: when set to an external provider, the skill runs UNDER that agent —
// its body is injected into a dispatched task instead of native inference.
provider: z.string().max(100).optional(),
model: z.string().max(200).optional(),
mode_id: z.string().max(200).optional(),
thinking_option_id: z.string().max(200).optional(),
});
interface InferenceApi {
@@ -39,9 +45,9 @@ export function registerSkillRoutes(
}
const sessionId = req.params.sessionId;
const { pane_id, skill_name } = parsed.data;
const sessionRows = await sql<{ id: string }[]>`
SELECT id FROM sessions WHERE id = ${sessionId}
const { pane_id, skill_name, provider, model, mode_id, thinking_option_id } = parsed.data;
const sessionRows = await sql<{ id: string; project_id: string }[]>`
SELECT id, project_id FROM sessions WHERE id = ${sessionId}
`;
if (sessionRows.length === 0) {
reply.code(404);
@@ -69,6 +75,31 @@ export function registerSkillRoutes(
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
}
// v2.5.9: external agent → run the skill UNDER that agent. The skill body
// stays server-side (like the native path's tool message) and is injected
// into a dispatched task; the agent receives the skill instructions + the
// user's text. Mirrors the messages-route external-provider dispatch.
if (provider && provider !== 'boocode') {
const [userMsg] = await sql<{ id: string }[]>`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${userText}, 'complete', clock_timestamp())
RETURNING id
`;
broker.publishFrame(sessionId, { type: 'message_started', message_id: userMsg!.id, chat_id: chatId, role: 'user' } as WsFrame);
broker.publishFrame(sessionId, { type: 'delta', message_id: userMsg!.id, chat_id: chatId, content: userText } as WsFrame);
broker.publishFrame(sessionId, { type: 'message_complete', message_id: userMsg!.id, chat_id: chatId } as WsFrame);
const taskInput = `${body}\n\n---\n\n${userText}`;
const [task] = await sql<{ id: string; state: string }[]>`
INSERT INTO tasks (project_id, input, agent, model, mode_id, thinking_option_id, session_id)
VALUES (${sessionRows[0]!.project_id}, ${taskInput}, ${provider}, ${model ?? null}, ${mode_id ?? null}, ${thinking_option_id ?? null}, ${sessionId})
RETURNING id, state
`;
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
reply.code(202);
return { user_message_id: userMsg!.id, task_id: task!.id, dispatched: true };
}
const { result, toolCall } = await runSkillInvokeTransaction(sql, {
sessionId,
chatId,

View File

@@ -66,8 +66,31 @@ CREATE OR REPLACE VIEW human_inbox AS
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
-- v2.5.10: persisted ACP available_commands (captured during the cold probe), so
-- an agent's live command set survives the tier-2 probe skip and shows without a
-- dispatch.
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS commands JSONB DEFAULT '[]'::jsonb;
-- v2.2.0: Paseo-style session config on tasks.
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;
-- 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
-- fallback poll. Postgres holds the notification until COMMIT, so the listener
-- always sees the committed row. A trigger covers all insert paths with no
-- app-code drift. Idempotent: re-applied on every startup.
CREATE OR REPLACE FUNCTION notify_tasks_new() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('tasks_new', '');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS tasks_notify_new ON tasks;
CREATE TRIGGER tasks_notify_new
AFTER INSERT ON tasks
FOR EACH ROW
EXECUTE FUNCTION notify_tasks_new();

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { resolveLaunchSpec, resolveAcpSpawnArgs } from '../acp-spawn.js';
import { buildResolvedRegistry } from '../provider-config-registry.js';
import type { CoderProvidersFile } from '../provider-config.js';
import { PROVIDERS } from '../provider-registry.js';
/** Resolved def for a provider id under the given config (default: no override). */
function builtin(name: string, providers: CoderProvidersFile['providers'] = {}) {
const def = buildResolvedRegistry(PROVIDERS, { providers }).get(name);
if (!def) throw new Error(`no resolved def for ${name}`);
return def;
}
describe('resolveLaunchSpec', () => {
// --- byte-identical built-in regression (the HARD CONSTRAINT) ---------------
// These argv values are the pre-v2.3 resolveAcpSpawnArgs switch outputs and
// MUST NOT change. spawn() is `spawn(spec.binary, spec.args, ...)`, so argv
// parity here is dispatch parity.
it('opencode (no override) → byte-identical argv ["acp"], binary = installPath', () => {
const spec = resolveLaunchSpec(builtin('opencode'), '/usr/bin/opencode');
expect(spec).not.toBeNull();
expect(spec!.args).toEqual(['acp']); // pre-v2.3 value
expect(spec!.binary).toBe('/usr/bin/opencode');
expect(spec!.env).toBeUndefined();
// cross-check against the switch source-of-truth
expect(spec!.args).toEqual(resolveAcpSpawnArgs('opencode'));
});
it('goose → ["acp"], qwen → ["--acp"] (byte-identical)', () => {
expect(resolveLaunchSpec(builtin('goose'), '/usr/bin/goose')!.args).toEqual(['acp']);
expect(resolveLaunchSpec(builtin('qwen'), '/usr/bin/qwen')!.args).toEqual(['--acp']);
});
it('built-in with null installPath falls back to the bare id (pre-v2.3 `installPath ?? agent`)', () => {
const spec = resolveLaunchSpec(builtin('opencode'), null);
expect(spec!.binary).toBe('opencode');
expect(spec!.args).toEqual(['acp']);
});
it('non-ACP / unknown provider → null (claude has no ACP argv)', () => {
expect(resolveLaunchSpec(builtin('claude'), '/usr/bin/claude')).toBeNull();
expect(resolveLaunchSpec(builtin('boocode'), null)).toBeNull();
});
// --- config-driven launch (the new capability) ------------------------------
it('custom ACP entry → configured command + env reach the spec', () => {
const def = builtin('amp-acp', {
'amp-acp': { extends: 'acp', label: 'Amp', command: ['amp-acp', '--acp'], env: { AMP_KEY: 'x' } },
});
const spec = resolveLaunchSpec(def, '/usr/local/bin/amp-acp');
expect(spec).not.toBeNull();
expect(spec!.binary).toBe('amp-acp'); // command[0], not the resolved install path
expect(spec!.args).toEqual(['--acp']); // command.slice(1)
expect(spec!.env).toEqual({ AMP_KEY: 'x' });
});
it('built-in WITH a config command override uses the override, not the switch default', () => {
const def = builtin('opencode', { opencode: { command: ['opencode', 'acp', '--verbose'], env: { DEBUG: '1' } } });
const spec = resolveLaunchSpec(def, '/usr/bin/opencode');
expect(spec!.binary).toBe('opencode');
expect(spec!.args).toEqual(['acp', '--verbose']);
expect(spec!.env).toEqual({ DEBUG: '1' });
});
});
describe('acp-dispatch spawn wiring (documented pass-through)', () => {
// dispatchViaAcp spawns `spawn(spec.binary, spec.args, { env: { ...process.env, ...spec.env } })`.
// The env merge layers config env over process.env; for a built-in with no
// config env, spec.env is undefined → { ...process.env } (byte-identical).
it('built-in with no config env yields an undefined spec.env (→ plain process.env at spawn)', () => {
expect(resolveLaunchSpec(builtin('opencode'), '/usr/bin/opencode')!.env).toBeUndefined();
});
});

View File

@@ -1,47 +0,0 @@
import { describe, it, expect } from 'vitest';
import { parseCursorAgentModelsOutput } from '../cursor-models.js';
describe('parseCursorAgentModelsOutput', () => {
it('parses cursor-agent models output with default marker', () => {
const output = `
Available models
claude-4-sonnet - Claude 4 Sonnet (default)
gpt-4.1 - GPT-4.1
Tip: use cursor-agent models for full list
`.trim();
const models = parseCursorAgentModelsOutput(output);
expect(models).toEqual([
{ id: 'claude-4-sonnet', label: 'Claude 4 Sonnet', isDefault: true },
{ id: 'gpt-4.1', label: 'GPT-4.1', isDefault: false },
]);
});
it('uses current marker when no default', () => {
const output = `
model-a - Model A (current)
model-b - Model B
`.trim();
const models = parseCursorAgentModelsOutput(output);
expect(models.find((m) => m.id === 'model-a')?.isDefault).toBe(true);
expect(models.find((m) => m.id === 'model-b')?.isDefault).toBe(false);
});
it('defaults to first model when no markers', () => {
const output = 'alpha - Alpha\nbeta - Beta';
const models = parseCursorAgentModelsOutput(output);
expect(models[0]?.isDefault).toBe(true);
expect(models[1]?.isDefault).toBe(false);
});
it('skips malformed lines', () => {
const output = 'no-separator\nvalid - Valid';
const models = parseCursorAgentModelsOutput(output);
expect(models).toEqual([{ id: 'valid', label: 'Valid', isDefault: true }]);
});
});

View File

@@ -3,7 +3,7 @@ import { getManifestCommands, mergeCommands, PROVIDER_COMMANDS } from '../provid
describe('provider-commands', () => {
it('defines commands for every external harness', () => {
for (const name of ['claude', 'opencode', 'cursor', 'goose', 'qwen', 'copilot']) {
for (const name of ['claude', 'opencode', 'goose', 'qwen']) {
expect(getManifestCommands(name).length, name).toBeGreaterThan(0);
}
});

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi } from 'vitest';
import { buildResolvedRegistry } from '../provider-config-registry.js';
import { PROVIDERS } from '../provider-registry.js';
import type { CoderProvidersFile } from '../provider-config.js';
describe('buildResolvedRegistry', () => {
it('applies a built-in override (goose label)', () => {
const config: CoderProvidersFile = { providers: { goose: { label: 'Goosey' } } };
const reg = buildResolvedRegistry(PROVIDERS, config);
const goose = reg.get('goose');
expect(goose).toBeDefined();
expect(goose!.label).toBe('Goosey');
expect(goose!.configLabel).toBe('Goosey');
expect(goose!.enabled).toBe(true);
expect(goose!.isBuiltin).toBe(true);
expect(goose!.isCustomAcp).toBe(false);
});
it('adds a custom ACP entry (extends:acp + label + command)', () => {
const config: CoderProvidersFile = {
providers: {
'amp-acp': { extends: 'acp', label: 'Amp', description: 'ACP wrapper', command: ['amp-acp', '--acp'], env: { AMP: '1' } },
},
};
const reg = buildResolvedRegistry(PROVIDERS, config);
const amp = reg.get('amp-acp');
expect(amp).toBeDefined();
expect(amp!.isCustomAcp).toBe(true);
expect(amp!.isBuiltin).toBe(false);
expect(amp!.transport).toBe('acp');
expect(amp!.modelSource).toBe('probe');
expect(amp!.launchCommand).toEqual(['amp-acp', '--acp']);
expect(amp!.env).toEqual({ AMP: '1' });
expect(amp!.enabled).toBe(true);
});
it('keeps a disabled built-in in the registry flagged disabled (goose)', () => {
const config: CoderProvidersFile = { providers: { goose: { enabled: false } } };
const reg = buildResolvedRegistry(PROVIDERS, config);
expect(reg.has('goose')).toBe(true);
expect(reg.get('goose')!.enabled).toBe(false);
});
it('skips a custom id without extends (no throw)', () => {
const config: CoderProvidersFile = { providers: { weird: { label: 'Weird', command: ['weird'] } } };
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const reg = buildResolvedRegistry(PROVIDERS, config);
expect(reg.has('weird')).toBe(false);
// built-ins untouched
expect(reg.size).toBe(PROVIDERS.length);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it('ignores enabled:false on boocode and warns', () => {
const config: CoderProvidersFile = { providers: { boocode: { enabled: false } } };
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const reg = buildResolvedRegistry(PROVIDERS, config);
expect(reg.get('boocode')!.enabled).toBe(true);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it('carries config models + additionalModels onto built-in and custom defs', () => {
const reg = buildResolvedRegistry(PROVIDERS, {
providers: {
claude: { models: [{ id: 'claude-opus-4-8', label: 'Opus 4.8' }] },
'amp-acp': {
extends: 'acp',
label: 'Amp',
command: ['amp-acp'],
additionalModels: [{ id: 'amp-1', label: 'Amp 1' }],
},
},
});
expect(reg.get('claude')!.configModels).toEqual([{ id: 'claude-opus-4-8', label: 'Opus 4.8' }]);
expect(reg.get('amp-acp')!.configAdditionalModels).toEqual([{ id: 'amp-1', label: 'Amp 1' }]);
});
it('REGRESSION: empty config returns exactly the built-ins, all enabled', () => {
const reg = buildResolvedRegistry(PROVIDERS, { providers: {} });
expect(reg.size).toBe(PROVIDERS.length);
expect([...reg.keys()]).toEqual(PROVIDERS.map((p) => p.name));
for (const def of PROVIDERS) {
const r = reg.get(def.name)!;
expect(r.enabled).toBe(true);
expect(r.isBuiltin).toBe(true);
expect(r.isCustomAcp).toBe(false);
expect(r.launchCommand).toBeNull();
expect(r.label).toBe(def.label);
}
});
});

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

@@ -1,10 +1,15 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
mergeModels,
prefixLlamaSwapModels,
clearProviderSnapshotCache,
getProviderSnapshot,
peekSnapshotEntry,
} from '../provider-snapshot.js';
import { loadProviderConfig } from '../provider-config-registry.js';
vi.mock('../acp-probe.js', () => ({
probeAcpProvider: vi.fn(),
@@ -14,6 +19,13 @@ import { probeAcpProvider } from '../acp-probe.js';
const mockProbe = vi.mocked(probeAcpProvider);
/** Write a temp coder-providers.json and point the resolved registry at it. */
function loadConfigFixture(providers: Record<string, unknown>): void {
const path = join(tmpdir(), `coder-providers-test-${providers ? Object.keys(providers).join('-') || 'empty' : 'empty'}.json`);
writeFileSync(path, JSON.stringify({ providers }), 'utf8');
loadProviderConfig(path);
}
function mockSql(agents: Array<{
name: string;
install_path: string | null;
@@ -21,6 +33,7 @@ function mockSql(agents: Array<{
models: Array<{ id: string; label: string }> | null;
label: string | null;
transport: string | null;
last_probed_at?: string | null;
}>) {
return vi.fn((strings: TemplateStringsArray) => {
const query = strings.join('');
@@ -36,6 +49,7 @@ function mockSql(agents: Array<{
const config = {
LLAMA_SWAP_URL: 'http://llama-swap.test',
PROVIDER_PROBE_TTL_MS: 86_400_000,
} as import('../config.js').Config;
describe('prefixLlamaSwapModels', () => {
@@ -68,6 +82,8 @@ describe('mergeModels', () => {
describe('getProviderSnapshot', () => {
beforeEach(() => {
clearProviderSnapshotCache();
// Reset the resolved registry to built-ins-only (missing path → {} config).
loadProviderConfig('/nonexistent-coder-providers.json');
vi.restoreAllMocks();
vi.stubGlobal(
'fetch',
@@ -165,4 +181,190 @@ describe('getProviderSnapshot', () => {
expect(claude?.modes.length).toBeGreaterThan(0);
expect(claude?.commands.some((c) => c.name === 'help')).toBe(true);
});
it('disabled provider → unavailable + enabled:false, WITHOUT spawning a probe', async () => {
loadConfigFixture({ goose: { enabled: false } });
mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] });
const sql = mockSql([
{
name: 'goose',
install_path: '/usr/bin/goose',
supports_acp: true,
models: [{ id: 'g1', label: 'G1' }],
label: 'Goose',
transport: 'acp',
last_probed_at: new Date().toISOString(),
},
]);
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
const goose = entries.find((e) => e.name === 'goose');
expect(goose?.status).toBe('unavailable');
expect(goose?.enabled).toBe(false);
expect(goose?.installed).toBe(false);
expect(mockProbe).not.toHaveBeenCalled();
});
it('uninstalled provider → unavailable + enabled:true + installed:false', async () => {
loadConfigFixture({});
mockProbe.mockResolvedValue({ ok: true, models: [], modes: [], defaultModeId: null, commands: [] });
const sql = mockSql([]); // nothing probed/installed
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
const opencode = entries.find((e) => e.name === 'opencode');
expect(opencode?.status).toBe('unavailable');
expect(opencode?.enabled).toBe(true);
expect(opencode?.installed).toBe(false);
expect(mockProbe).not.toHaveBeenCalled();
});
it('fresh DB within TTL → tier-2 cold probe SKIPPED (serves DB models)', async () => {
loadConfigFixture({});
// If this were wrongly called, cached-goose would be replaced and the
// not.toHaveBeenCalled assertion would fail.
mockProbe.mockResolvedValue({
ok: true,
models: [{ id: 'SHOULD-NOT-APPEAR', label: 'nope' }],
modes: [],
defaultModeId: null,
commands: [],
});
const sql = mockSql([
{
name: 'goose',
install_path: '/usr/bin/goose',
supports_acp: true,
models: [{ id: 'cached-goose', label: 'Cached Goose' }],
label: 'Goose',
transport: 'acp',
last_probed_at: new Date().toISOString(), // fresh
},
]);
// force=false → cache-miss returns loading; second call joins the build / cache.
await getProviderSnapshot(sql, config, '/tmp/cwd', false);
const entries = await getProviderSnapshot(sql, config, '/tmp/cwd', false);
const goose = entries.find((e) => e.name === 'goose');
expect(goose?.status).toBe('ready');
expect(goose?.installed).toBe(true);
expect(goose?.models.map((m) => m.id)).toContain('cached-goose');
expect(goose?.models.map((m) => m.id)).not.toContain('SHOULD-NOT-APPEAR');
expect(mockProbe).not.toHaveBeenCalled();
});
it('force refresh → tier-2 cold probe RUNS even when DB is fresh', async () => {
loadConfigFixture({});
mockProbe.mockResolvedValue({
ok: true,
models: [{ id: 'fresh-probe', label: 'Fresh' }],
modes: [],
defaultModeId: null,
commands: [],
});
const sql = mockSql([
{
name: 'goose',
install_path: '/usr/bin/goose',
supports_acp: true,
models: [{ id: 'cached-goose', label: 'Cached' }],
label: 'Goose',
transport: 'acp',
last_probed_at: new Date().toISOString(), // fresh, but force overrides
},
]);
await getProviderSnapshot(sql, config, '/tmp/cwd', true);
expect(mockProbe).toHaveBeenCalled();
});
it('native boocode → ready, enabled, installed', async () => {
loadConfigFixture({});
const sql = mockSql([]);
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
const boocode = entries.find((e) => e.name === 'boocode');
expect(boocode?.status).toBe('ready');
expect(boocode?.enabled).toBe(true);
expect(boocode?.installed).toBe(true);
});
it('config models REPLACE the claude static list; additionalModels merge (+ thinking)', async () => {
loadConfigFixture({
claude: {
models: [{ id: 'claude-opus-4-8', label: 'Opus 4.8' }],
additionalModels: [{ id: 'sonnet', label: 'Sonnet (latest)' }],
},
});
const sql = mockSql([
{
name: 'claude',
install_path: '/usr/bin/claude',
supports_acp: false,
models: [{ id: 'old-static', label: 'Old' }],
label: 'Claude Code',
transport: 'pty',
last_probed_at: new Date().toISOString(),
},
]);
const entries = await getProviderSnapshot(sql, config, '/tmp/project', true);
const claude = entries.find((e) => e.name === 'claude');
const ids = claude!.models.map((m) => m.id);
expect(ids).toContain('claude-opus-4-8'); // config models replaced the DB/static list
expect(ids).toContain('sonnet'); // additionalModels merged on top
expect(ids).not.toContain('old-static'); // replaced, not appended
// thinking options still attach to the config-provided models
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({
ok: true,
models: [{ id: 'm1', label: 'M1' }],
modes: [],
defaultModeId: null,
commands: [],
});
const sql = mockSql([
{
name: 'goose',
install_path: '/usr/bin/goose',
supports_acp: true,
models: null,
label: 'Goose',
transport: 'acp',
last_probed_at: null,
},
]);
await getProviderSnapshot(sql, config, '/tmp/cwd', true); // cold populate
const probeCallsAfterFirst = mockProbe.mock.calls.length;
await getProviderSnapshot(sql, config, '/tmp/cwd', false); // warm read
const probeCallsAfterSecond = mockProbe.mock.calls.length;
// Success criterion: second snapshot is served from cache with no ACP spawns.
expect(probeCallsAfterSecond - probeCallsAfterFirst).toBe(0);
});
});

View File

@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Parity guard between the two copies of the provider snapshot types:
* apps/coder/src/services/provider-types.ts (backend source of truth)
* apps/web/src/api/types.ts (web wire copy)
*
* APPROACH: text-identity of each shared type block (mirrors the repo's existing
* ws-frames.test.ts byte-parity convention). A compile-time bidirectional-
* assignability check was attempted first (a web-side file importing coder's
* import-free provider-types.ts), but apps/web/tsconfig.app.json is a composite
* project and rejects out-of-include files with TS6307 — so cross-project type
* import is structurally blocked. This runtime guard FAILS on any field
* add/remove/rename/loosen in either copy, including the nested model/mode/
* command types that ProviderSnapshotEntry references. Single-source-of-truth
* (shared workspace package) is deferred as a Tier-2 follow-up.
*/
const here = dirname(fileURLToPath(import.meta.url));
const coderSrc = readFileSync(resolve(here, '../provider-types.ts'), 'utf8');
const webSrc = readFileSync(resolve(here, '../../../../web/src/api/types.ts'), 'utf8');
function extractBlock(src: string, name: string): string {
const iface = src.match(new RegExp(`export interface ${name} \\{[\\s\\S]*?\\n\\}`));
const alias = src.match(new RegExp(`export type ${name} =[^;]*;`));
const block = iface?.[0] ?? alias?.[0];
if (!block) throw new Error(`type block '${name}' not found`);
// Normalize to type structure: drop blank + comment lines (//, /* */, *),
// trim each line. Field add/remove/rename/loosen still changes a field line.
return block
.split('\n')
.map((l) => l.trim())
.filter(
(l) =>
l.length > 0 &&
!l.startsWith('//') &&
!l.startsWith('/*') &&
!l.startsWith('*'),
)
.join('\n');
}
describe('provider snapshot type parity (coder ↔ web)', () => {
// Includes the nested types ProviderSnapshotEntry references, so structural
// drift anywhere in the snapshot surface is caught.
const names = [
'ProviderSnapshotStatus',
'ProviderSnapshotEntry',
'ProviderModel',
'ProviderMode',
'ThinkingOption',
'AgentCommand',
];
for (const name of names) {
it(`${name} is identical in both copies`, () => {
expect(
extractBlock(webSrc, name),
`${name} drifted between apps/coder/src/services/provider-types.ts and apps/web/src/api/types.ts`,
).toBe(extractBlock(coderSrc, name));
});
}
});

View File

@@ -26,7 +26,8 @@ import type { Broker } from '@boocode/server/broker';
import type { WsFrame } from '@boocode/server/ws-frames';
import { spawn } from 'node:child_process';
import { findThoughtLevelConfigId } from './acp-derive.js';
import { resolveAcpSpawnArgs } from './acp-spawn.js';
import { resolveLaunchSpec } from './acp-spawn.js';
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
import { createAcpNdJsonStream } from './acp-stream.js';
import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js';
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
@@ -59,6 +60,9 @@ export interface AcpDispatchOpts {
messageId?: string;
broker?: Broker;
installPath?: string;
/** v2.3 phase 3: resolved registry def for launch-spec resolution. The
* dispatcher loads this by task.agent; falls back to a registry lookup here. */
resolved?: ResolvedProviderDef;
signal?: AbortSignal;
log: FastifyBaseLogger;
}
@@ -282,8 +286,12 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatch
broker,
} = opts;
const args = resolveAcpSpawnArgs(agent);
if (!args) {
// v2.3 phase 3: launch from the resolved registry def (config override /
// custom-ACP command) with the built-in switch as the fallback. The dispatcher
// passes `resolved`; fall back to a registry lookup if it didn't.
const resolved = opts.resolved ?? getResolvedRegistry().get(agent);
const spec = resolved ? resolveLaunchSpec(resolved, installPath ?? null) : null;
if (!spec) {
return {
exitCode: 1,
output: `Agent '${agent}' does not support ACP.`,
@@ -293,12 +301,11 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatch
};
}
const binary = installPath ?? agent;
log.info({ agent, binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning');
const child = spawn(binary, args, {
log.info({ agent, binary: spec.binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning');
const child = spawn(spec.binary, spec.args, {
cwd: worktreePath,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
env: { ...process.env, ...spec.env },
});
const streamCtx = new AcpStreamContext(

View File

@@ -130,6 +130,17 @@ export async function probeAcpProvider(
});
const session = await connection.newSession({ cwd, mcpServers: [] });
// available_commands_update is an async session notification opencode sends
// shortly AFTER newSession resolves — reading probedCommands synchronously
// here races it and captures nothing. Wait briefly for the first batch, then
// a short settle for any stragglers (capped well under PROBE_TIMEOUT_MS).
const deadline = Date.now() + 3_000;
while (probedCommands.length === 0 && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 150));
}
if (probedCommands.length > 0) {
await new Promise((r) => setTimeout(r, 300));
}
const result = parseSessionResponse(session, agent);
result.commands = probedCommands;
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});

View File

@@ -1,15 +1,15 @@
import type { ResolvedProviderDef } from './provider-config-registry.js';
/**
* Resolve ACP spawn argv per provider (host-probe verified 2026-05-25).
* Resolve ACP spawn argv per built-in provider (host-probe verified 2026-05-25).
* Source of truth for built-in default argv — resolveLaunchSpec wraps these; it
* does NOT replace them.
*/
export function resolveAcpSpawnArgs(agent: string): string[] | null {
switch (agent) {
case 'opencode':
case 'goose':
return ['acp'];
case 'cursor':
return ['acp'];
case 'copilot':
return ['--acp'];
case 'qwen':
return ['--acp'];
default:
@@ -17,13 +17,34 @@ export function resolveAcpSpawnArgs(agent: string): string[] | null {
}
}
export function resolveAcpProbeBinaries(agent: string): string[] {
switch (agent) {
case 'cursor':
return ['cursor-agent', 'agent'];
case 'copilot':
return ['copilot'];
default:
return [agent];
/**
* v2.3 phase 3: resolve the launch spec for an ACP dispatch (design.md §5.1).
* Consults the resolved registry's launchCommand (config override or custom-ACP
* entry) first; otherwise falls back to the built-in default argv above.
*
* Byte-identical to pre-v2.3 for built-ins with no override: binary is
* `installPath ?? id` and args come from resolveAcpSpawnArgs — exactly the
* `binary = installPath ?? agent` + `resolveAcpSpawnArgs(agent)` the dispatcher
* used before. (Deliberate deviation from design §5.1's `!installPath → null`:
* the old path spawned the bare agent name when install_path was missing, so we
* preserve the `?? id` fallback rather than fail.)
*/
export function resolveLaunchSpec(
resolved: ResolvedProviderDef,
installPath: string | null,
): { binary: string; args: string[]; env?: Record<string, string> } | null {
if (resolved.launchCommand) {
return {
binary: resolved.launchCommand[0],
args: resolved.launchCommand.slice(1),
env: resolved.env,
};
}
const args = resolveAcpSpawnArgs(resolved.id);
if (!args) return null;
return { binary: installPath ?? resolved.id, args, env: resolved.env };
}
export function resolveAcpProbeBinaries(agent: string): string[] {
return [agent];
}

View File

@@ -1,24 +1,34 @@
import type { Sql } from '../db.js';
import type { FastifyBaseLogger } from 'fastify';
import { exec as execCb } from 'node:child_process';
import { exec as execCb, execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
import { PROVIDERS_BY_NAME, PROBED_AGENT_NAMES } from './provider-registry.js';
import { PROVIDERS_BY_NAME } from './provider-registry.js';
import { resolveAcpProbeBinaries } from './acp-spawn.js';
import { clearProviderSnapshotCache } from './provider-snapshot.js';
import { readQwenSettingsModels } from './qwen-settings.js';
import { loadConfig } from '../config.js';
import { loadProviderConfig } from './provider-config-registry.js';
const exec = promisify(execCb);
const execFile = promisify(execFileCb);
// `which` via execFile (no shell) — the binary name can come from the config
// file (custom ACP entries), so avoid interpolating it into a shell string.
async function whichBinary(bin: string): Promise<string | null> {
try {
const { stdout } = await execFile('which', [bin], { timeout: 10_000 });
const path = stdout.trim();
return path || null;
} catch {
return null;
}
}
async function resolveInstallPath(agentName: string): Promise<string | null> {
const candidates = resolveAcpProbeBinaries(agentName);
for (const bin of candidates) {
try {
const { stdout } = await exec(`which ${bin}`, { timeout: 10_000 });
const path = stdout.trim();
if (path) return path;
} catch {
/* try next */
}
const path = await whichBinary(bin);
if (path) return path;
}
return null;
}
@@ -27,15 +37,6 @@ async function detectAcpSupport(agentName: string, installPath: string): Promise
const transport = PROVIDERS_BY_NAME.get(agentName)?.transport;
if (transport !== 'acp') return false;
if (agentName === 'copilot') {
try {
const { stdout } = await exec(`"${installPath}" --help`, { timeout: 10_000 });
return stdout.includes('--acp');
} catch {
return false;
}
}
if (agentName === 'qwen') {
try {
const { stdout } = await exec(`"${installPath}" --help`, { timeout: 10_000 });
@@ -55,14 +56,37 @@ async function detectAcpSupport(agentName: string, installPath: string): Promise
/**
* Probe for available agents on the HOST.
*
* v2.3: iterates the resolved provider registry (built-ins + config-backed
* custom ACP entries) rather than the hardcoded `PROBED_AGENT_NAMES`. Native
* boocode is not probed; disabled providers are skipped (their `available_agents`
* row is kept, not deleted). `enabled` is read from the in-memory registry only —
* no DB column in Phase 1 (design.md §3.3).
*/
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
clearProviderSnapshotCache();
log.info('agent-probe: scanning for known agents');
for (const agentName of PROBED_AGENT_NAMES) {
const registry = loadProviderConfig(loadConfig().CODER_PROVIDERS_PATH);
for (const resolved of registry.values()) {
const agentName = resolved.id;
// Native boocode is not a probed host agent.
if (resolved.transport === 'native') continue;
// Disabled providers: skip the probe, keep any existing row.
if (!resolved.enabled) {
log.info({ agent: agentName }, 'agent-probe: skipping disabled provider');
continue;
}
try {
const installPath = await resolveInstallPath(agentName);
// Custom ACP entries resolve their binary from command[0]; built-ins use
// the per-agent probe binaries.
const installPath = resolved.isCustomAcp && resolved.launchCommand
? await whichBinary(resolved.launchCommand[0])
: await resolveInstallPath(agentName);
if (!installPath) continue;
let version: string | null = null;
@@ -73,24 +97,34 @@ export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<voi
/* optional */
}
const providerDef = PROVIDERS_BY_NAME.get(agentName);
let supportsAcp = providerDef?.transport === 'acp';
if (supportsAcp) {
supportsAcp = await detectAcpSupport(agentName, installPath);
// Custom ACP entries are ACP by declaration; built-ins detect support.
let supportsAcp: boolean;
if (resolved.isCustomAcp) {
supportsAcp = true;
} else {
supportsAcp = resolved.transport === 'acp';
if (supportsAcp) {
supportsAcp = await detectAcpSupport(agentName, installPath);
}
}
let models: Array<{ id: string; label: string }> = [];
if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
models = providerDef.staticModels;
if (!resolved.isCustomAcp) {
const providerDef = PROVIDERS_BY_NAME.get(agentName);
if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
models = providerDef.staticModels;
}
if (agentName === 'qwen') {
models = await readQwenSettingsModels();
}
}
if (agentName === 'qwen') {
models = await readQwenSettingsModels();
}
const label = providerDef?.label ?? agentName;
const transport =
providerDef?.transport === 'acp' && !supportsAcp ? 'pty' : (providerDef?.transport ?? 'pty');
const label = resolved.configLabel ?? resolved.label;
const transport = resolved.isCustomAcp
? 'acp'
: resolved.transport === 'acp' && !supportsAcp
? 'pty'
: (resolved.transport ?? 'pty');
await sql`
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport)

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

@@ -0,0 +1,22 @@
/**
* v2.3 phase 2: tier-1 fast availability check — is a binary on PATH?
*
* Uses execFile (NO shell) because the binary name can come from the provider
* config file (custom ACP entries) — mirrors the Phase 1 agent-probe hardening.
* Note: agent-probe's `whichBinary` returns the resolved path (it needs it for
* `install_path`); this returns a boolean. Kept separate rather than over-
* refactored into one helper — different return contracts, two short call sites.
*/
import { execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
const execFile = promisify(execFileCb);
export async function isCommandAvailable(binary: string): Promise<boolean> {
try {
const { stdout } = await execFile('which', [binary], { timeout: 10_000 });
return stdout.trim().length > 0;
} catch {
return false;
}
}

View File

@@ -1,39 +0,0 @@
/**
* Cursor model list parser — lifted from Paseo cursor-acp-agent.ts
*/
import type { ProviderModel } from './provider-types.js';
const CURSOR_MODEL_MARKER_PATTERN = /\s+\((?:default|current)\)$/;
export function parseCursorAgentModelsOutput(output: string): ProviderModel[] {
const parsed = output
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && line !== 'Available models' && !line.startsWith('Tip:'))
.map((line) => {
const separatorIndex = line.indexOf(' - ');
if (separatorIndex <= 0) return null;
const id = line.slice(0, separatorIndex).trim();
const rawLabel = line.slice(separatorIndex + 3).trim();
if (!id || !rawLabel) return null;
let marker: 'default' | 'current' | null = null;
if (rawLabel.endsWith(' (default)')) marker = 'default';
else if (rawLabel.endsWith(' (current)')) marker = 'current';
return { id, label: rawLabel.replace(CURSOR_MODEL_MARKER_PATTERN, ''), marker };
})
.filter((m): m is { id: string; label: string; marker: 'default' | 'current' | null } => m !== null);
const defaultModelId =
parsed.find((m) => m.marker === 'default')?.id ??
parsed.find((m) => m.marker === 'current')?.id ??
parsed[0]?.id;
return parsed.map((model) => ({
id: model.id,
label: model.label,
isDefault: model.id === defaultModelId,
}));
}

View File

@@ -5,6 +5,7 @@ import type { WsFrame } from '@boocode/server/ws-frames';
import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree } 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';
@@ -24,16 +25,29 @@ interface Deps {
config: Config;
}
const POLL_INTERVAL_MS = 5_000;
// LISTEN/NOTIFY ('tasks_new') is the fast path — the dispatcher reacts to new
// tasks immediately. The poll is only a safety net for notifications missed
// during a listen-connection drop (porsager auto-reconnects), so it can stay slow.
const POLL_INTERVAL_MS = 2_000;
const COMPLETION_POLL_MS = 2_000;
export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } {
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 stopping = false;
let inflightPromise: Promise<void> | null = null;
// 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.
function triggerPoll(reason: string): void {
poll().catch((err) => {
log.error({ err, reason }, 'dispatcher: poll error');
});
}
async function poll(): Promise<void> {
if (running || stopping) return;
@@ -327,6 +341,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
if (supportsAcp) {
const result = await dispatchViaAcp({
agent,
resolved: getResolvedRegistry().get(agent),
task: task.input,
worktreePath,
installPath: installPath ?? undefined,
@@ -463,12 +478,28 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
return {
start() {
log.info('dispatcher: starting poll loop');
timer = setInterval(() => {
poll().catch((err) => {
log.error({ err }, 'dispatcher: poll error');
log.info('dispatcher: starting poll loop + tasks_new listener');
// Fallback poll — catches notifications missed while the listen connection
// was down. The fast path is the NOTIFY listener below.
timer = setInterval(() => triggerPoll('interval'), POLL_INTERVAL_MS);
// Fast path: react immediately to new tasks. porsager reserves a dedicated
// connection and auto-resubscribes on reconnect; the onlisten callback
// fires on each (re)subscribe, so we kick a catch-up poll there too to
// sweep up anything inserted during a disconnect.
sql
.listen(
'tasks_new',
() => triggerPoll('notify'),
() => triggerPoll('listen-subscribed'),
)
.then((meta) => {
listener = meta;
})
.catch((err) => {
log.error({ err }, 'dispatcher: failed to LISTEN tasks_new — relying on poll fallback');
});
}, POLL_INTERVAL_MS);
},
async stop() {
@@ -477,6 +508,12 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
clearInterval(timer);
timer = null;
}
if (listener) {
await listener.unlisten().catch((err) => {
log.error({ err }, 'dispatcher: unlisten error');
});
listener = null;
}
if (inflightPromise) {
log.info('dispatcher: waiting for in-flight task');
await inflightPromise;

View File

@@ -27,13 +27,6 @@ const OPENCODE_COMMANDS: AgentCommand[] = [
{ name: 'export', description: 'Export session' },
];
const CURSOR_COMMANDS: AgentCommand[] = [
{ name: 'help', description: 'Show available slash commands' },
{ name: 'clear', description: 'Clear conversation' },
{ name: 'compact', description: 'Compact context' },
{ name: 'resume', description: 'Resume a prior session' },
];
const GOOSE_COMMANDS: AgentCommand[] = [
{ name: 'help', description: 'Show available commands' },
{ name: 'clear', description: 'Clear conversation' },
@@ -49,23 +42,12 @@ const QWEN_COMMANDS: AgentCommand[] = [
{ name: 'review', description: 'Review changes' },
];
const COPILOT_COMMANDS: AgentCommand[] = [
{ name: 'help', description: 'Show available commands' },
{ name: 'explain', description: 'Explain selected code' },
{ name: 'fix', description: 'Fix issues in context' },
{ name: 'tests', description: 'Generate or run tests' },
{ name: 'doc', description: 'Generate documentation' },
{ name: 'clear', description: 'Clear conversation' },
];
/** boocode harness uses /api/skills — merged on the frontend. */
export const PROVIDER_COMMANDS: Record<string, AgentCommand[]> = {
claude: CLAUDE_COMMANDS,
opencode: OPENCODE_COMMANDS,
cursor: CURSOR_COMMANDS,
goose: GOOSE_COMMANDS,
qwen: QWEN_COMMANDS,
copilot: COPILOT_COMMANDS,
boocode: [],
};

View File

@@ -0,0 +1,133 @@
/**
* v2.3 resolved provider registry — single in-memory source of truth after
* merging the hardcoded built-ins (provider-registry.ts) with the config file
* (provider-config.ts). Mirrors Paseo's buildProviderRegistry/addDerivedProviders.
*
* Phase 1 scope: build + expose the resolved registry. `launchCommand` is null
* for built-ins (the default argv is resolved at dispatch time in Phase 3) and
* is the config `command` for custom ACP entries. No DB columns (design.md §3.3);
* `enabled` lives in memory only.
*/
import type { ProviderDef } from './provider-registry.js';
import { PROVIDERS } from './provider-registry.js';
import { load, type CoderProvidersFile } from './provider-config.js';
export interface ResolvedProviderDef extends ProviderDef {
id: string;
enabled: boolean;
isBuiltin: boolean;
isCustomAcp: boolean;
/** Full argv for spawn: [binary, ...args]. Null for built-ins (resolved at dispatch). */
launchCommand: [string, ...string[]] | null;
env: Record<string, string> | undefined;
configLabel?: string;
configDescription?: string;
/** Config `models` — REPLACES the discovered/static model list when present. */
configModels?: Array<{ id: string; label: string }>;
/** Config `additionalModels` — MERGED on top of the resolved model list. */
configAdditionalModels?: Array<{ id: string; label: string }>;
}
/**
* Merge built-ins with config overrides into the resolved registry.
* Algorithm verbatim from design.md §3.1.
*/
export function buildResolvedRegistry(
builtins: ProviderDef[],
config: CoderProvidersFile,
): Map<string, ResolvedProviderDef> {
const out = new Map<string, ResolvedProviderDef>();
const overrides = config.providers ?? {};
const builtinNames = new Set(builtins.map((b) => b.name));
// 1. Built-ins, applying a config override if one is present.
for (const def of builtins) {
const ov = overrides[def.name];
let enabled = ov?.enabled !== false;
// 3. boocode is always enabled; an enabled:false override is ignored + warned.
if (def.name === 'boocode' && ov?.enabled === false) {
console.warn("provider-config: ignoring enabled:false for built-in 'boocode' (always enabled)");
enabled = true;
}
const launchCommand =
ov?.command && ov.command.length > 0 ? (ov.command as [string, ...string[]]) : null;
out.set(def.name, {
...def,
label: ov?.label ?? def.label,
id: def.name,
enabled,
isBuiltin: true,
isCustomAcp: false,
launchCommand,
env: ov?.env,
configLabel: ov?.label,
configDescription: ov?.description,
configModels: ov?.models,
configAdditionalModels: ov?.additionalModels,
});
}
// 2. Config ids that are not built-ins → custom ACP entries.
for (const [id, ov] of Object.entries(overrides)) {
if (builtinNames.has(id)) continue;
// §2.2 rules: "New id without extends → Reject at load with log."
if (ov.extends !== 'acp' || !ov.label || !ov.command || ov.command.length === 0) {
console.warn(
`provider-config: skipping custom provider '${id}' — requires extends:'acp', label, and command`,
);
continue;
}
out.set(id, {
name: id,
label: ov.label,
transport: 'acp',
modelSource: 'probe',
id,
enabled: ov.enabled !== false,
isBuiltin: false,
isCustomAcp: true,
launchCommand: ov.command as [string, ...string[]],
env: ov.env,
configLabel: ov.label,
configDescription: ov.description,
configModels: ov.models,
configAdditionalModels: ov.additionalModels,
});
}
return out;
}
// --- Module singleton ---------------------------------------------------------
let cachedRegistry: Map<string, ResolvedProviderDef> | null = null;
let cachedPath: string | null = null;
/** Load the config file at `path`, rebuild, and cache the resolved registry. */
export function loadProviderConfig(path: string): Map<string, ResolvedProviderDef> {
cachedPath = path;
cachedRegistry = buildResolvedRegistry(PROVIDERS, load(path));
return cachedRegistry;
}
/** Re-read the last-loaded config file and rebuild (Phase 4 calls this after PATCH). */
export function reloadProviderConfig(): Map<string, ResolvedProviderDef> {
if (cachedPath == null) {
cachedRegistry = buildResolvedRegistry(PROVIDERS, { providers: {} });
return cachedRegistry;
}
return loadProviderConfig(cachedPath);
}
/** The cached resolved registry (built-ins only if nothing has been loaded yet). */
export function getResolvedRegistry(): Map<string, ResolvedProviderDef> {
return cachedRegistry ?? buildResolvedRegistry(PROVIDERS, { providers: {} });
}
/** Resolved provider ids in registry order. */
export function getResolvedProviderIds(): string[] {
return [...getResolvedRegistry().keys()];
}

View File

@@ -0,0 +1,100 @@
/**
* v2.3 provider config file (`/data/coder-providers.json`) — schema + loader.
*
* Layers config-backed overrides/custom-ACP entries over the hardcoded built-ins
* (see provider-config-registry.ts). Loading NEVER throws at startup (design.md
* §2.1): a missing file, invalid JSON, or schema mismatch all fall back to
* `{ providers: {} }` (built-ins only, all enabled).
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { z } from 'zod';
// Schemas verbatim from design.md §2.2.
export const ProviderOverrideSchema = z.object({
extends: z.enum(['acp']).optional(), // v2.3: only 'acp' for custom; built-ins omit extends
label: z.string().min(1).optional(),
description: z.string().optional(),
command: z.array(z.string().min(1)).min(1).optional(), // [binary, ...args]
env: z.record(z.string()).optional(),
enabled: z.boolean().optional(), // default true
order: z.number().int().optional(), // UI sort key
models: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
additionalModels: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
});
export const CoderProvidersFileSchema = z.object({
providers: z.record(ProviderOverrideSchema).default({}),
});
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;
try {
raw = readFileSync(path, 'utf8');
} catch {
// Missing file → built-ins only. Expected, not an error.
return { providers: {} };
}
let json: unknown;
try {
json = JSON.parse(raw);
} catch (err) {
console.error(`provider-config: invalid JSON in ${path} — using built-ins only`, err);
return { providers: {} };
}
const parsed = CoderProvidersFileSchema.safeParse(json);
if (!parsed.success) {
console.error(
`provider-config: schema validation failed for ${path} — using built-ins only`,
parsed.error.flatten(),
);
return { providers: {} };
}
return parsed.data;
}
/** Write the config back to disk (used by the Phase 4 PATCH route). */
export function save(path: string, config: CoderProvidersFile): void {
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
}

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

@@ -24,31 +24,6 @@ const OPENCODE_MODES: ProviderMode[] = [
{ id: 'full-access', label: 'Full Access', description: 'Auto-approves all tool prompts', isUnattended: true },
];
const COPILOT_MODES: ProviderMode[] = [
{
id: 'https://agentclientprotocol.com/protocol/session-modes#agent',
label: 'Agent',
description: 'Default agent mode',
},
{
id: 'https://agentclientprotocol.com/protocol/session-modes#plan',
label: 'Plan',
description: 'Plan mode for multi-step work',
},
{
id: 'allow-all',
label: 'Allow All',
description: 'Automatically approves all tool, path, and URL requests',
isUnattended: true,
},
];
const CURSOR_CLI_MODES: ProviderMode[] = [
{ id: 'agent', label: 'Agent', description: 'Full agent capabilities with tool access' },
{ id: 'plan', label: 'Plan', description: 'Read-only planning mode' },
{ id: 'ask', label: 'Ask', description: 'Q&A read-only mode' },
];
const QWEN_PTY_MODES: ProviderMode[] = [
{ id: 'default', label: 'Default', description: 'Prompt for approval' },
{ id: 'plan', label: 'Plan', description: 'Plan only — no edits' },
@@ -75,14 +50,6 @@ export const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry> = {
defaultModeId: 'build',
modes: OPENCODE_MODES,
},
copilot: {
defaultModeId: 'https://agentclientprotocol.com/protocol/session-modes#agent',
modes: COPILOT_MODES,
},
cursor: {
defaultModeId: 'agent',
modes: CURSOR_CLI_MODES,
},
goose: {
defaultModeId: null,
modes: [],

View File

@@ -13,8 +13,7 @@ export interface ProviderDef {
* - boocode: llama-swap only
* - opencode: ACP probe + mergeLlamaSwap (prefixed llama-swap/* ids)
* - qwen: ACP probe + merge ~/.qwen/settings.json; PTY fallback reads settings only
* - cursor: ACP probe + cursor-agent models CLI fallback
* - goose / copilot: ACP probe only
* - goose: ACP probe only
* - claude: static manifest models + thinking options
*/
export const PROVIDERS: ProviderDef[] = [
@@ -24,12 +23,6 @@ export const PROVIDERS: ProviderDef[] = [
transport: 'native',
modelSource: 'llama-swap',
},
{
name: 'cursor',
label: 'Cursor Agent',
transport: 'acp',
modelSource: 'probe',
},
{
name: 'opencode',
label: 'OpenCode',
@@ -48,9 +41,18 @@ export const PROVIDERS: ProviderDef[] = [
label: 'Claude Code',
transport: 'pty',
modelSource: 'static',
// Passed verbatim to `claude --model <id>` (PTY dispatch). The CLI accepts a
// latest-alias ('opus'/'sonnet'/'haiku') or a pinned full name
// ('claude-opus-4-8'). Aliases never go stale; pinned IDs let you select an
// exact version. Extend/replace per-install via data/coder-providers.json
// (models / additionalModels) without a code change.
staticModels: [
{ id: 'claude-opus-4-20250514', label: 'Opus 4' },
{ id: 'claude-sonnet-4-20250514', label: 'Sonnet 4' },
{ id: 'opus', label: 'Opus (latest)' },
{ id: 'claude-opus-4-8', label: 'Opus 4.8' },
{ id: 'sonnet', label: 'Sonnet (latest)' },
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },
{ id: 'haiku', label: 'Haiku (latest)' },
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' },
],
},
{
@@ -59,12 +61,6 @@ export const PROVIDERS: ProviderDef[] = [
transport: 'acp',
modelSource: 'probe',
},
{
name: 'copilot',
label: 'GitHub Copilot',
transport: 'acp',
modelSource: 'probe',
},
];
export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p]));

View File

@@ -2,32 +2,31 @@
* Provider snapshot cache — cold ACP probe per provider + static manifest merge.
*/
import { homedir } from 'node:os';
import { exec as execCb } from 'node:child_process';
import { promisify } from 'node:util';
import type { FastifyBaseLogger } from 'fastify';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import { PROVIDERS, type ProviderDef } from './provider-registry.js';
import {
getManifestDefaultModeId,
getManifestModes,
PROVIDER_MANIFEST,
} from './provider-manifest.js';
import { probeAcpProvider } from './acp-probe.js';
import { parseCursorAgentModelsOutput } from './cursor-models.js';
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js';
import type { ProviderModel, ProviderSnapshotEntry, AgentCommand } from './provider-types.js';
import { getManifestCommands, mergeCommands } from './provider-commands.js';
import { readQwenSettingsModels } from './qwen-settings.js';
const exec = promisify(execCb);
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;
install_path: string | null;
supports_acp: boolean;
models: ProviderModel[] | null;
commands: AgentCommand[] | null;
label: string | null;
transport: string | null;
last_probed_at: string | Date | null;
}
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
@@ -41,15 +40,6 @@ async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
}
}
async function fetchCursorModelsCli(installPath: string): Promise<ProviderModel[]> {
try {
const { stdout } = await exec(`"${installPath}" models`, { timeout: 15_000, maxBuffer: 1024 * 1024 });
return parseCursorAgentModelsOutput(stdout);
} catch {
return [];
}
}
/** Prefix llama-swap model ids so they don't collide with provider-native models. */
export function prefixLlamaSwapModels(models: ProviderModel[]): ProviderModel[] {
return models.map((m) => ({
@@ -82,112 +72,155 @@ export function mergeModels(...lists: ProviderModel[][]): ProviderModel[] {
}
async function buildProviderEntry(
provider: ProviderDef,
resolved: ResolvedProviderDef,
agentRow: AgentRow | undefined,
llamaModels: ProviderModel[],
cwd: string,
): Promise<ProviderSnapshotEntry | null> {
const isNative = provider.name === 'boocode';
const installed = isNative || !!agentRow;
if (!installed) return null;
ttlMs: number,
force: boolean,
): Promise<ProviderSnapshotEntry> {
const name = resolved.id;
const isNative = resolved.transport === 'native';
const fallbackModes = getManifestModes(name);
const defaultModeId = getManifestDefaultModeId(name);
const manifestCommands = getManifestCommands(name);
// Manifest + persisted live ACP commands (captured on a prior cold probe), so
// the agent's discovered commands show even when the tier-2 probe is skipped.
const dbCommands = mergeCommands(manifestCommands, agentRow?.commands ?? []);
const label = agentRow?.label ?? resolved.configLabel ?? resolved.label;
const descr = resolved.configDescription ? { description: resolved.configDescription } : {};
let transport = provider.transport;
if (agentRow && provider.transport === 'acp' && !agentRow.supports_acp) {
// v2.3: config `models` REPLACES the discovered/static list; `additionalModels`
// MERGES on top. Applied to every ready/installed model list below.
const withConfigModels = (m: ProviderModel[]): ProviderModel[] => {
let out = resolved.configModels && resolved.configModels.length > 0 ? resolved.configModels : m;
if (resolved.configAdditionalModels && resolved.configAdditionalModels.length > 0) {
out = mergeModels(out, resolved.configAdditionalModels);
}
return out;
};
// ACP built-ins fall back to PTY transport when the installed binary lacks ACP.
let transport = resolved.transport;
if (agentRow && resolved.transport === 'acp' && !agentRow.supports_acp) {
transport = 'pty';
}
const fallbackModes = getManifestModes(provider.name);
const defaultModeId = getManifestDefaultModeId(provider.name);
if (isNative) {
// 1. Disabled → unavailable, no probe.
if (!resolved.enabled) {
return {
name: provider.name,
label: provider.label,
transport,
status: 'ready',
installed: true,
models: llamaModels,
modes: [],
defaultModeId: null,
commands: getManifestCommands(provider.name),
name, label, ...descr, transport, status: 'unavailable',
enabled: false, installed: false, models: [], modes: fallbackModes,
defaultModeId, commands: manifestCommands,
};
}
// 2. Native boocode → always ready (llama-swap models).
if (isNative) {
return {
name, label: resolved.label, transport, status: 'ready',
enabled: true, installed: true, models: withConfigModels(llamaModels), modes: [],
defaultModeId: null, commands: manifestCommands,
};
}
// 3. Tier-1 fast availability: installed iff a probed install_path exists or
// the launch binary is on PATH. No spawn beyond a `which` for custom entries.
const fast =
agentRow?.install_path != null ||
(resolved.launchCommand ? await isCommandAvailable(resolved.launchCommand[0]) : false);
if (!fast) {
return {
name, label, ...descr, transport, status: 'unavailable',
enabled: true, installed: false, models: [], modes: fallbackModes,
defaultModeId, commands: manifestCommands,
};
}
// Baseline model precedence (used by claude + non-probe fallbacks).
let models: ProviderModel[] = [];
if (provider.modelSource === 'llama-swap' && provider.mergeLlamaSwap) {
if (resolved.modelSource === 'llama-swap' && resolved.mergeLlamaSwap) {
models = llamaModels;
} else if (agentRow?.models?.length) {
models = agentRow.models;
} else if (provider.staticModels) {
models = provider.staticModels.map((m) => ({ id: m.id, label: m.label }));
} else if (resolved.staticModels) {
models = resolved.staticModels.map((m) => ({ id: m.id, label: m.label }));
}
if (provider.name === 'claude') {
models = attachClaudeThinking(models);
// 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: provider.name,
label: agentRow?.label ?? provider.label,
transport,
status: 'ready',
installed: true,
models,
modes: fallbackModes,
defaultModeId,
commands: getManifestCommands(provider.name),
name, label, transport, status: 'ready', enabled: true, installed: true,
models: attachClaudeThinking(withConfigModels(models)), modes: fallbackModes, defaultModeId,
commands: mergeCommands(manifestCommands, discoverClaudeCommands()),
};
}
if (transport === 'acp' && agentRow?.install_path && agentRow.supports_acp) {
const probe = await probeAcpProvider(provider.name, agentRow.install_path, cwd);
if (probe.models.length > 0) {
models = probe.models;
} else if (provider.name === 'cursor' && agentRow.install_path) {
models = await fetchCursorModelsCli(agentRow.install_path);
} else if (provider.modelSource === 'llama-swap') {
models = llamaModels;
const canProbeAcp =
transport === 'acp' &&
((agentRow?.install_path != null && agentRow.supports_acp) ||
(resolved.isCustomAcp && resolved.launchCommand != null));
if (canProbeAcp) {
// Tier-2 gate (§4.3): cold ACP probe only on force, staleness, or empty DB
// models. Otherwise serve DB models + manifest modes/commands — no spawn.
const lastProbedMs =
agentRow?.last_probed_at != null ? new Date(agentRow.last_probed_at).getTime() : NaN;
const stale = Number.isNaN(lastProbedMs) || Date.now() - lastProbedMs > ttlMs;
const dbEmpty = !(agentRow?.models && agentRow.models.length > 0);
const runTier2 = force || stale || dbEmpty;
if (!runTier2) {
let skipModels = agentRow?.models ?? [];
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
skipModels = mergeModels(skipModels, prefixLlamaSwapModels(llamaModels));
} else if (resolved.modelSource === 'llama-swap' && skipModels.length === 0) {
skipModels = llamaModels;
}
return {
name, label, transport, status: 'ready', enabled: true, installed: true,
models: withConfigModels(skipModels), modes: fallbackModes, defaultModeId, commands: dbCommands,
};
}
if (provider.name === 'qwen') {
const settingsModels = await readQwenSettingsModels();
models = mergeModels(models, settingsModels);
}
const probeTarget =
resolved.isCustomAcp && resolved.launchCommand
? resolved.launchCommand[0]
: agentRow!.install_path!;
const probe = await probeAcpProvider(name, probeTarget, cwd);
if (provider.mergeLlamaSwap && provider.modelSource !== 'llama-swap') {
const nativeModels = probe.models.length > 0 ? probe.models : models;
models = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
let probeModels = probe.models.length > 0 ? probe.models : models;
if (name === 'qwen') {
probeModels = mergeModels(probeModels, await readQwenSettingsModels());
}
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
const nativeModels = probe.models.length > 0 ? probe.models : probeModels;
probeModels = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
}
return {
name: provider.name,
label: agentRow.label ?? provider.label,
transport,
name, label, transport,
status: probe.ok ? 'ready' : 'error',
installed: true,
models,
enabled: true, installed: true,
models: withConfigModels(probeModels),
modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
defaultModeId: probe.defaultModeId ?? defaultModeId,
commands: mergeCommands(getManifestCommands(provider.name), probe.commands),
error: probe.error,
commands: mergeCommands(manifestCommands, probe.commands),
...(probe.error ? { error: probe.error } : {}),
fetchedAt: new Date().toISOString(),
};
}
// PTY-only providers (qwen fallback when ACP unavailable)
if (provider.name === 'qwen') {
if (models.length === 0) {
models = await readQwenSettingsModels();
}
// PTY-only fallback (e.g. qwen without ACP) — installed + ready.
if (name === 'qwen' && models.length === 0) {
models = await readQwenSettingsModels();
}
return {
name: provider.name,
label: agentRow?.label ?? provider.label,
transport,
status: 'ready',
installed: true,
models,
modes: fallbackModes,
defaultModeId,
commands: getManifestCommands(provider.name),
name, label, transport, status: 'ready', enabled: true, installed: true,
models: withConfigModels(models), modes: fallbackModes, defaultModeId, commands: dbCommands,
};
}
@@ -216,16 +249,16 @@ export async function getProviderSnapshot(
const build = async (): Promise<ProviderSnapshotEntry[]> => {
const llamaModels = await fetchLlamaSwapModels(config);
const agents = await sql<AgentRow[]>`
SELECT name, install_path, supports_acp, models, label, transport FROM available_agents
SELECT name, install_path, supports_acp, models, commands, label, transport, last_probed_at FROM available_agents
`;
const agentMap = new Map(agents.map((a) => [a.name, a]));
const ttlMs = config.PROVIDER_PROBE_TTL_MS;
const built = await Promise.all(
PROVIDERS.map((provider) =>
buildProviderEntry(provider, agentMap.get(provider.name), llamaModels, resolvedCwd),
const entries = await Promise.all(
[...getResolvedRegistry().values()].map((resolved) =>
buildProviderEntry(resolved, agentMap.get(resolved.id), llamaModels, resolvedCwd, ttlMs, force),
),
);
const entries = built.filter((entry): entry is ProviderSnapshotEntry => entry !== null);
snapshotCache.set(cacheKey, { at: Date.now(), entries });
return entries;
@@ -235,6 +268,13 @@ export async function getProviderSnapshot(
snapshotInflight.delete(cacheKey);
});
snapshotInflight.set(cacheKey, promise);
// Await the build (force or cache-miss) and return terminal entries. The sync
// `loading` return (design §4.4) is DEFERRED until Phase 5 ships the client
// poll that resolves it: without that poll, a single fetch lands on
// installed:false `loading` entries, which AgentComposerBar filters out
// (`e.installed && ...`) → empty picker. Builds stay fast via the tier-2 skip
// once available_agents.models is warm.
return promise;
}
@@ -243,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,
@@ -251,16 +301,34 @@ export async function persistProbedModels(
): Promise<void> {
let count = 0;
for (const entry of entries) {
if (entry.name === 'boocode' || entry.models.length === 0) continue;
const flatModels = entry.models.map(({ id, label }) => ({ id, label }));
await sql`
UPDATE available_agents
SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp()
WHERE name = ${entry.name}
`;
count++;
if (entry.name === 'boocode') continue;
let persisted = false;
if (entry.models.length > 0) {
const flatModels = entry.models.map(({ id, label }) => ({ id, label }));
await sql`
UPDATE available_agents
SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp()
WHERE name = ${entry.name}
`;
persisted = true;
}
// Persist captured ACP commands so they survive the tier-2 probe skip and
// show without a dispatch. Only when non-empty — never clobber a prior set.
if (entry.commands.length > 0) {
const flatCommands = entry.commands.map((c) => ({
name: c.name,
...(c.description ? { description: c.description } : {}),
}));
await sql`
UPDATE available_agents
SET commands = ${sql.json(flatCommands as never)}, last_probed_at = clock_timestamp()
WHERE name = ${entry.name}
`;
persisted = true;
}
if (persisted) count++;
}
if (count > 0) {
log.info({ count }, 'provider-snapshot: persisted models to available_agents');
log.info({ count }, 'provider-snapshot: persisted models/commands to available_agents');
}
}

View File

@@ -23,24 +23,34 @@ export interface ProviderModel {
defaultThinkingOptionId?: string;
}
export type ProviderSnapshotStatus = 'ready' | 'error';
// v2.3 phase 2: 'loading' (cache-miss, probe in flight) + 'unavailable'
// (disabled or not installed) restored alongside the terminal 'ready' | 'error'.
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
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
// enforced by __tests__/provider-types-parity.test.ts (fails on any field drift).
export interface ProviderSnapshotEntry {
name: string;
label: string;
description?: string;
transport: string;
status: ProviderSnapshotStatus;
enabled: boolean;
installed: boolean;
models: ProviderModel[];
modes: ProviderMode[];
defaultModeId: string | null;
commands: AgentCommand[];
error?: string;
fetchedAt?: string;
}
export interface AgentSessionConfig {

View File

@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { parseAgentsMd, matchToolGlob } from '../agents.js';
import { toolJsonSchemas } from '../tools.js';
describe('agent tool allowlist', () => {
const plannerMd = `# Agents
## Planner
---
temperature: 0.6
tools: [view_file, grep, list_dir, find_files]
description: Read-only planner
---
You plan.
`;
it('parses an agent with a restricted tool allowlist', () => {
const { agents, errors } = parseAgentsMd(plannerMd);
expect(errors).toHaveLength(0);
expect(agents).toHaveLength(1);
const planner = agents[0]!;
expect(planner.name).toBe('Planner');
expect(planner.tools).toEqual(['view_file', 'grep', 'list_dir', 'find_files']);
});
it('stream-phase filter: agent allowlist excludes tools not in the list', () => {
const { agents } = parseAgentsMd(plannerMd);
const planner = agents[0]!;
const allSchemas = toolJsonSchemas();
const filtered = allSchemas.filter((t) =>
matchToolGlob(t.function.name, planner.tools),
);
const filteredNames = filtered.map((t) => t.function.name);
expect(filteredNames).toContain('view_file');
expect(filteredNames).toContain('grep');
expect(filteredNames).not.toContain('edit_file');
expect(filteredNames).not.toContain('web_search');
expect(filteredNames).not.toContain('get_codebase_overview');
expect(filtered).toHaveLength(4);
});
it('tool-phase guard: rejects tool call not in agent allowlist', () => {
const { agents } = parseAgentsMd(plannerMd);
const planner = agents[0]!;
expect(matchToolGlob('edit_file', planner.tools)).toBe(false);
expect(matchToolGlob('create_file', planner.tools)).toBe(false);
expect(matchToolGlob('delete_file', planner.tools)).toBe(false);
expect(matchToolGlob('web_search', planner.tools)).toBe(false);
});
it('tool-phase guard: allows tool call in agent allowlist', () => {
const { agents } = parseAgentsMd(plannerMd);
const planner = agents[0]!;
expect(matchToolGlob('view_file', planner.tools)).toBe(true);
expect(matchToolGlob('grep', planner.tools)).toBe(true);
expect(matchToolGlob('list_dir', planner.tools)).toBe(true);
expect(matchToolGlob('find_files', planner.tools)).toBe(true);
});
it('null/absent tools field defaults to all tools (no regression)', () => {
const noToolsMd = `# Agents
## Default
---
temperature: 0.7
description: Uses all tools
---
Default agent.
`;
const { agents } = parseAgentsMd(noToolsMd);
const agent = agents[0]!;
const allSchemas = toolJsonSchemas();
const filtered = allSchemas.filter((t) =>
matchToolGlob(t.function.name, agent.tools),
);
expect(filtered.length).toBe(allSchemas.length);
});
it('builder agent: write tools filtered out when not in ALL_TOOLS (BooChat context)', () => {
const builderMd = `# Agents
## Builder
---
temperature: 0.6
tools: [view_file, grep, list_dir, find_files, edit_file, create_file, delete_file, apply_pending, rewind]
description: Read and write tools
---
You build.
`;
const { agents } = parseAgentsMd(builderMd);
const builder = agents[0]!;
expect(matchToolGlob('view_file', builder.tools)).toBe(true);
expect(matchToolGlob('grep', builder.tools)).toBe(true);
// Write tools not in server's ALL_TOOLS are silently filtered during parsing.
// In BooCoder context (where ALL_TOOLS includes write tools), they'd be retained.
expect(builder.tools).not.toContain('edit_file');
expect(builder.tools).not.toContain('create_file');
expect(matchToolGlob('web_search', builder.tools)).toBe(false);
});
it('matchToolGlob rejects hallucinated tool against exact allowlist', () => {
const allowlist = ['view_file', 'grep', 'list_dir'];
expect(matchToolGlob('edit_file', allowlist)).toBe(false);
expect(matchToolGlob('rm_rf', allowlist)).toBe(false);
expect(matchToolGlob('view_file_extended', allowlist)).toBe(false);
});
});

View File

@@ -45,22 +45,26 @@ export async function maybeAutoNameChat(
if (!chat) return;
if (chat.name !== null && chat.name !== '') return;
const assistantMsg = await ctx.sql<{ content: string }[]>`
SELECT content FROM messages
const firstMsgs = await ctx.sql<{ role: string; content: string }[]>`
SELECT role, content FROM messages
WHERE chat_id = ${chatId}
AND role = 'assistant'
AND status = 'complete'
AND role IN ('user', 'assistant')
AND status IN ('complete', 'ok')
AND content <> ''
ORDER BY created_at ASC
LIMIT 1
LIMIT 2
`;
if (!assistantMsg[0]) return;
const userMsg = firstMsgs.find(m => m.role === 'user');
const assistantMsg = firstMsgs.find(m => m.role === 'assistant');
if (!assistantMsg) return;
const assistantText = assistantMsg[0].content.slice(0, 2000);
let namingInput = '';
if (userMsg) namingInput += `User: ${userMsg.content.slice(0, 1000)}\n\n`;
namingInput += `Assistant: ${assistantMsg.content.slice(0, 1000)}`;
const raw = await taskModelCompletion({
system: NAMING_SYSTEM_PROMPT,
user: assistantText,
user: namingInput,
maxTokens: 30,
temperature: 0.3,
});

View File

@@ -18,9 +18,9 @@ import { READ_ONLY_TOOL_NAMES } from '../tools.js';
// turns + deeper exploration without changing the safety floor materially —
// the doom-loop guard (3 identical calls → abort) catches the actual failure
// mode this cap was guarding against.
export const BUDGET_READ_ONLY = 50;
export const BUDGET_NON_READ_ONLY = 10;
export const BUDGET_NO_AGENT = 50;
export const BUDGET_READ_ONLY = 100;
export const BUDGET_NON_READ_ONLY = 100;
export const BUDGET_NO_AGENT = 100;
const READ_ONLY_SET: ReadonlySet<string> = new Set(READ_ONLY_TOOL_NAMES);

View File

@@ -1,7 +1,8 @@
import type { Session, ToolCall } from '../../types/api.js';
import type { Agent, Session, ToolCall } from '../../types/api.js';
import * as modelContext from '../model-context.js';
import { PathScopeError } from '../path_guard.js';
import { TOOLS_BY_NAME } from '../tools.js';
import { matchToolGlob } from '../agents.js';
import { maybeFlagForCompaction } from './payload.js';
import { insertParts, partsFromAssistantMessage, partsFromToolMessage } from './parts.js';
// v1.13.16: richer unknown-tool error so the model can self-correct when it
@@ -98,7 +99,8 @@ export async function executeToolPhase(
result: StreamResult,
startedAt: string | null,
session: Session,
projectRoot: string
projectRoot: string,
agent?: Agent | null,
): Promise<ToolPhaseResult> {
const { sessionId, chatId, assistantMessageId } = args;
const content = stripToolMarkup(result.content, { final: true });
@@ -262,6 +264,31 @@ export async function executeToolPhase(
);
return;
}
if (agent && !matchToolGlob(tc.name, agent.tools)) {
const stored = {
tool_call_id: tc.id,
output: null,
truncated: false,
error: `tool '${tc.name}' is not allowed for agent '${agent.name}'`,
};
await insertParts(
ctx.sql,
partsFromToolMessage({ tool_results: stored }).map((p) => ({
...p,
message_id: toolMessageId,
})),
);
ctx.publish(sessionId, {
type: 'tool_result',
tool_message_id: toolMessageId,
chat_id: chatId,
tool_call_id: tc.id,
output: stored.output,
truncated: false,
error: stored.error,
});
return;
}
const tres = await executeToolCall(projectRoot, tc, session.allowed_read_paths);
if (SYNTHESIS_TOOLS.has(tc.name)) {
synthEntries.push({ tc, output: tres.output, ...(tres.error ? { error: tres.error } : {}) });

View File

@@ -292,7 +292,7 @@ export async function runAssistantTurn(
// ---- tool phase ----
let toolPhaseResult: ToolPhaseResult;
try {
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot);
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot, agent);
} catch (err) {
// Tool phase errors are unexpected (individual tool failures are
// caught inside executeToolPhase). Log and break.

View File

@@ -163,6 +163,13 @@ const COMPILED: ReadonlyArray<CompiledPattern> = DEFAULT_SECURITY_IGNORE_FILETYP
// Returns true when `relPath` matches a known-secret pattern. Case-insensitive
// (regex 'i' flag). Always normalize path separators to `/` so Windows-origin
// paths match the same patterns. Empty or root-only paths return false.
const SAFE_PATTERNS: ReadonlySet<string> = new Set([
'.env.example',
'.env.sample',
'.env.template',
'.env.defaults',
]);
export function isSecretPath(relPath: string): boolean {
if (!relPath) return false;
const normalized = relPath.replace(/\\/g, '/');
@@ -170,6 +177,8 @@ export function isSecretPath(relPath: string): boolean {
if (segments.length === 0) return false;
const base = segments[segments.length - 1]!;
if (SAFE_PATTERNS.has(base.toLowerCase())) return false;
for (const compiled of COMPILED) {
if (compiled.mode === 'basename') {
if (compiled.regex.test(base)) return true;

View File

@@ -33,7 +33,7 @@ function RightRailForSession({ sessionId }: { sessionId: string }) {
// a right-side drawer toggled by the header's FolderTree button (via
// useRightRailDrawer). On desktop, it renders inline as before with its
// own internal open/close state.
return <RightRail projectId={projectId} />;
return <RightRail projectId={projectId} sessionId={sessionId} />;
}
function MobileBackdrop() {

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',
@@ -332,20 +349,51 @@ export const api = {
request<CoderMessageWire[]>(
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
),
skillInvoke: (sessionId: string, paneId: string, skillName: string, userMessage: string | null) =>
skillInvoke: (
sessionId: string,
paneId: string,
skillName: string,
userMessage: string | null,
// v2.5.9: when the active provider is external, the skill runs under that
// agent (body injected into a dispatched task) → response carries task_id.
config?: { provider?: string; model?: string; mode_id?: string; thinking_option_id?: string },
) =>
request<{
user_message_id: string;
assistant_message_id: string;
synth_assistant_id: string;
tool_message_id: string;
assistant_message_id?: string;
synth_assistant_id?: string;
tool_message_id?: string;
task_id?: string;
dispatched?: boolean;
}>(`/api/coder/sessions/${sessionId}/skill_invoke`, {
method: 'POST',
body: JSON.stringify({
pane_id: paneId,
skill_name: skillName,
user_message: userMessage,
...(config?.provider ? { provider: config.provider } : {}),
...(config?.model ? { model: config.model } : {}),
...(config?.mode_id ? { mode_id: config.mode_id } : {}),
...(config?.thinking_option_id ? { thinking_option_id: config.thinking_option_id } : {}),
}),
}),
// Queue a new-file create from the RightRail browser → BooCoder
// pending_changes (operation='create'). Surfaces in the CoderPane DiffPanel
// for explicit apply. A WriteGuardError comes back as a 422 whose { error }
// body ApiError exposes as .message for inline display.
createPendingFile: (sessionId: string, file_path: string, content: string) =>
request<{
id: string;
session_id: string;
task_id: string | null;
file_path: string;
operation: string;
status: string;
created_at: string;
}>(`/api/coder/sessions/${sessionId}/pending/create`, {
method: 'POST',
body: JSON.stringify({ file_path, content }),
}),
},
agents: {

View File

@@ -182,10 +182,14 @@ export interface Message {
// majority of messages.
metadata: MessageMetadata | null;
// v1.13.1-C: reasoning content captured from models that stream reasoning
// tokens separately (qwen3.6 etc.). Backend populates from message_parts;
// optional on the wire — frontend doesn't render this yet (reserved for
// a v1.14 UI surface).
// tokens separately (qwen3.6 etc.) and from external agents over ACP
// (agent_thought_chunk). Backend populates from message_parts; rendered by
// MessageBubble as a collapsible "Thinking" block.
reasoning_parts?: Array<{ text: string }> | null;
// Coder wire shape pre-joins reasoning_parts into a single string
// (CoderPane/CoderMessageList) and streams it live via reasoning_delta
// frames. MessageBubble reads whichever of the two is present.
reasoning_text?: string | null;
// v1.11: anchored rolling compaction fields. Optional on the wire so that
// older API responses (or test fixtures) parse without explicit nulls.
// summary — true on the assistant row that holds the active
@@ -228,19 +232,50 @@ export interface ThinkingOption {
isDefault?: boolean;
}
export type ProviderSnapshotStatus = 'ready' | 'error';
// v2.3 phase 2: 'loading' + 'unavailable' restored alongside 'ready' | 'error'.
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
// KEEP IN SYNC with apps/coder/src/services/provider-types.ts ProviderSnapshotEntry
// — parity is enforced by coder __tests__/provider-types-parity.test.ts (field drift fails it).
export interface ProviderSnapshotEntry {
name: string;
label: string;
description?: string;
transport: string;
status: ProviderSnapshotStatus;
enabled: boolean;
installed: boolean;
models: ProviderModel[];
modes: ProviderMode[];
defaultModeId: string | null;
commands: AgentCommand[];
error?: string;
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 {
@@ -263,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

@@ -9,6 +9,7 @@ interface Props {
export function AgentCommandsHint({ commands }: Props) {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
if (commands.length === 0) return null;
@@ -25,10 +26,19 @@ export function AgentCommandsHint({ commands }: Props) {
{open && (
<ul className="px-2 pb-2 space-y-1 border-t border-border/40 max-h-48 overflow-y-auto overscroll-contain touch-pan-y">
{commands.map((cmd) => (
<li key={cmd.name} className="font-mono">
<span className="text-primary/80">/{cmd.name}</span>
<li
key={cmd.name}
className="cursor-pointer"
onClick={() => setExpanded((v) => v === cmd.name ? null : cmd.name)}
>
<span className="font-mono text-primary/80">/{cmd.name}</span>
{cmd.description && (
<span className="ml-1.5 text-muted-foreground font-sans line-clamp-1">{cmd.description}</span>
<span className={cn(
'ml-1.5 text-muted-foreground font-sans',
expanded === cmd.name ? '' : 'line-clamp-2',
)}>
{cmd.description}
</span>
)}
</li>
))}

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, RefreshCw, Shield, Cpu, Brain } 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';
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
@@ -91,9 +92,11 @@ interface PickerProps {
options: Array<{ id: string; label: string }>;
onPick: (id: string) => void;
icon?: React.ReactNode;
/** Mobile: render icon + chevron only (no value label) to save row width. */
iconOnly?: boolean;
}
function CompactPicker({ label, value, disabled, options, onPick, icon }: PickerProps) {
function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly }: PickerProps) {
const { isMobile } = useViewport();
const [open, setOpen] = useState(false);
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
@@ -125,9 +128,11 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker
disabled={disabled}
onClick={() => setOpen(true)}
aria-label={`${label}: ${currentLabel}`}
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
className="inline-flex items-center gap-1 min-h-[44px] px-1.5 rounded text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
>
{icon ?? <Cpu className="size-4" />}
{icon}
{!iconOnly && <span className="truncate max-w-[120px]">{currentLabel}</span>}
<ChevronDown className="size-3 opacity-70 shrink-0" />
</button>
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
<div className="px-2">{list}</div>
@@ -142,16 +147,16 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker
<button
type="button"
disabled={disabled}
className="text-xs font-mono text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60 disabled:opacity-40 max-w-[140px]"
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60 disabled:opacity-40"
>
{icon}
<span className="truncate">{currentLabel}</span>
<span className="truncate max-w-[180px]">{currentLabel}</span>
<ChevronDown className="size-3 opacity-70 shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-64 overflow-y-auto min-w-[160px]">
{options.map((o) => (
<DropdownMenuItem key={o.id} onSelect={() => onPick(o.id)} className="font-mono text-xs">
<DropdownMenuItem key={o.id} onSelect={() => onPick(o.id)} className="text-xs">
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
{o.label}
</DropdownMenuItem>
@@ -166,12 +171,17 @@ interface Props {
value: AgentSessionConfig;
onChange: (next: AgentSessionConfig) => void;
onProviderCommandsChange?: (commands: AgentCommand[]) => void;
connected?: boolean;
}
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange }: 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);
@@ -194,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],
@@ -255,6 +294,16 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
);
}
const providerIcon = (name: string) => {
switch (name) {
case 'claude': return <ClaudeIcon size={13} className="shrink-0" />;
case 'opencode': return <OpenCodeIcon size={13} className="shrink-0" />;
case 'goose': return <Bird size={13} className="shrink-0" />;
case 'qwen': return <TermIcon size={13} className="shrink-0" />;
default: return <Dog size={13} className="shrink-0" />;
}
};
const providerOptions = entries.map((e) => ({ id: e.name, label: e.label }));
const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label }));
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label }));
@@ -267,7 +316,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
value={value.provider}
options={providerOptions}
onPick={pickProvider}
icon={<Cpu className="size-3 shrink-0" />}
icon={
currentEntry?.status === 'loading'
? <Loader2 size={13} className="shrink-0 animate-spin" />
: providerIcon(value.provider)
}
/>
<CompactPicker
label="Mode"
@@ -276,6 +329,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
options={modeOptions}
onPick={(modeId) => persist({ ...value, modeId })}
icon={<Shield className="size-3 shrink-0" />}
iconOnly
/>
<CompactPicker
label="Model"
@@ -283,6 +337,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
disabled={modelOptions.length === 0}
options={modelOptions}
onPick={pickModel}
icon={<Bot size={13} className="shrink-0" />}
/>
{thinkingOpts.length > 0 && (
<CompactPicker
@@ -293,16 +348,26 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
icon={<Brain className="size-3 shrink-0" />}
/>
)}
<button
type="button"
onClick={() => void handleRefresh()}
disabled={refreshing}
className="ml-auto inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
aria-label="Refresh provider list"
title="Refresh providers"
>
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
</button>
{/* Status dot + refresh as one right-aligned unit so the refresh button
stays on the top line instead of wrapping past the edge-pinned dot. */}
<div className="ml-auto flex items-center gap-1 shrink-0">
{connected !== undefined && (
<span
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}
title={connected ? 'Connected' : 'Disconnected'}
/>
)}
<button
type="button"
onClick={() => void handleRefresh()}
disabled={refreshing}
className="inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
aria-label="Refresh provider list"
title="Refresh providers"
>
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
</button>
</div>
</div>
);
}

View File

@@ -22,8 +22,9 @@ import { AttachmentPreviewModal } from '@/components/AttachmentPreviewModal';
import { FileMentionPopover } from '@/components/FileMentionPopover';
import { DropOverlay } from '@/components/DropOverlay';
import { AgentPicker } from '@/components/AgentPicker';
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
import { ContextBar } from '@/components/ContextBar';
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
import { SlashCommandPicker, type SlashCommandGroup } from '@/components/SlashCommandPicker';
import { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
import { api } from '@/api/client';
import type { Message } from '@/api/types';
@@ -55,6 +56,13 @@ interface Props {
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
// disables slash-command dispatch (input is sent as literal text).
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
// v2.5.9: segmented slash-command DISPLAY source for the picker + hint. When
// provided (e.g. CoderPane passing [agent commands, skills]), these labeled
// groups are shown instead of the BooChat skills. Invocation routing still
// uses the skills lookup — names not in skills (opencode's /help etc.) fall
// through and are sent to the agent as literal text. Omitted → BooChat skills
// (flat, unchanged — parity).
slashGroups?: SlashCommandGroup[];
// v1.10.4: send-to-chat reverse path. When chatId is provided, this input
// registers in chatInputsRegistry so the terminal floating menu can list
// it, and subscribes to sendToChat events scoped to this chatId. Receiving
@@ -70,7 +78,7 @@ interface Props {
modelContextLimit?: number | null;
}
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, chatId, chatLabel, messages, modelContextLimit }: Props) {
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
const { isMobile } = useViewport();
const [value, setValue] = useState('');
const [busy, setBusy] = useState(false);
@@ -99,6 +107,15 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
for (const s of skills) m.set(s.name, true);
return m;
}, [skills]);
// Flat display source for the hint (and the picker's no-groups fallback):
// caller-provided groups flattened, else the BooChat skills.
const slashItems = useMemo(
() =>
slashGroups
? slashGroups.flatMap((g) => g.items)
: skills.map((s) => ({ name: s.name, description: s.description })),
[slashGroups, skills],
);
const [fileIndex, setFileIndex] = useState<string[] | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
@@ -560,6 +577,9 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
))}
</div>
)}
{slashItems.length > 0 && (
<AgentCommandsHint commands={slashItems} />
)}
{/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1
inlines ContextBar in the same row so the bar lives next to the
picker rather than as a separate header above it. The row renders
@@ -657,11 +677,12 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
{slashState && (
<SlashCommandPicker
query={slashState.query}
items={skills}
items={slashItems}
groups={slashGroups}
inputRef={textareaRef}
onSelect={handleSlashSelect}
onClose={() => setSlashState(null)}
emptyLabel="No skills available"
emptyLabel={slashGroups ? 'No commands available' : 'No skills available'}
/>
)}
</div>

View File

@@ -181,7 +181,7 @@ export function ChatTabBar({
<Plus size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, PanelRightOpen } from 'lucide-react';
import { ChevronDown, ChevronRight, Copy, RefreshCw, Check, Share2, RotateCw, GitFork, Trash2, PanelRightOpen, Brain } from 'lucide-react';
import { toast } from 'sonner';
import type { Chat, ErrorReason, Message } from '@/api/types';
import { api, ApiError } from '@/api/client';
@@ -117,12 +117,20 @@ function deriveMarkdownTitle(content: string): string {
return 'Markdown artifact';
}
export interface MessageActions {
onRegenerate?: (chatId: string, messageId: string) => Promise<void>;
onResend?: (chatId: string, content: string) => Promise<void>;
onFork?: (chatId: string, messageId: string) => Promise<void>;
onDelete?: (chatId: string, messageId: string) => Promise<void>;
}
interface Props {
message: Message;
sessionChats?: Chat[];
// v1.8.2: passed by MessageList's render-item pass for cap-hit sentinels.
// Only the most recent sentinel shows the Continue button.
capHitInfo?: { position: number; isLatest: boolean };
actions?: MessageActions;
/** Hide actions that don't apply (fork, delete, open-in-pane). */
hideActions?: ('fork' | 'delete' | 'openInPane')[];
}
function StatsLine({ message }: { message: Message }) {
@@ -157,8 +165,12 @@ function StatsLine({ message }: { message: Message }) {
function ActionRow({
message,
actions,
hiddenSet,
}: {
message: Message;
actions?: MessageActions;
hiddenSet: Set<string>;
}) {
const [justCopied, setJustCopied] = useState(false);
const [regenerating, setRegenerating] = useState(false);
@@ -180,7 +192,11 @@ function ActionRow({
if (regenerating || message.status === 'streaming') return;
setRegenerating(true);
try {
await api.messages.regenerate(message.chat_id, message.id);
if (actions?.onRegenerate) {
await actions.onRegenerate(message.chat_id, message.id);
} else {
await api.messages.regenerate(message.chat_id, message.id);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'regenerate failed');
} finally {
@@ -188,12 +204,30 @@ function ActionRow({
}
}
async function resend() {
if (!canResend) return;
try {
if (actions?.onResend) {
await actions.onResend(message.chat_id, message.content!);
} else {
await api.messages.send(message.chat_id, message.content!);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'resend failed');
}
}
async function fork() {
if (forking || message.status !== 'complete') return;
setForking(true);
try {
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
if (actions?.onFork) {
await actions.onFork(message.chat_id, message.id);
} else {
const chat = await api.chats.fork(message.chat_id, { messageId: message.id });
sessionEvents.emit({ type: 'refetch_messages' });
sessionEvents.emit({ type: 'open_chat_in_active_pane', chat_id: chat.id });
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'fork failed');
} finally {
@@ -205,7 +239,11 @@ function ActionRow({
if (deleting) return;
setDeleting(true);
try {
await api.messages.remove(message.chat_id, message.id);
if (actions?.onDelete) {
await actions.onDelete(message.chat_id, message.id);
} else {
await api.messages.remove(message.chat_id, message.id);
}
setDeleteOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'delete failed');
@@ -215,7 +253,9 @@ function ActionRow({
}
const isAssistant = message.role === 'assistant';
const isUser = message.role === 'user';
const canRegen = isAssistant && message.status !== 'streaming';
const canResend = isUser && message.status === 'complete' && !!message.content?.trim();
const canFork = message.status === 'complete';
const canDelete = message.status !== 'streaming';
const [openingPane, setOpeningPane] = useState(false);
@@ -279,7 +319,18 @@ function ActionRow({
>
{justCopied ? <Check className="size-3" /> : <Copy className="size-3" />}
</button>
{isAssistant && (
{canResend && (
<button
type="button"
onClick={() => void resend()}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Resend message"
title="Resend"
>
<RefreshCw className="size-3" />
</button>
)}
{isAssistant && !hiddenSet.has('openInPane') && (
<button
type="button"
onClick={() => void openInPane()}
@@ -303,26 +354,30 @@ function ActionRow({
<RefreshCw className={`size-3 ${regenerating ? 'animate-spin' : ''}`} />
</button>
)}
<button
type="button"
onClick={() => void fork()}
disabled={!canFork || forking}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Fork from here"
title="Fork from here"
>
<GitFork className="size-3" />
</button>
<button
type="button"
onClick={() => setDeleteOpen(true)}
disabled={!canDelete}
{!hiddenSet.has('fork') && (
<button
type="button"
onClick={() => void fork()}
disabled={!canFork || forking}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Fork from here"
title="Fork from here"
>
<GitFork className="size-3" />
</button>
)}
{!hiddenSet.has('delete') && (
<button
type="button"
onClick={() => setDeleteOpen(true)}
disabled={!canDelete}
className="inline-flex items-center justify-center size-6 rounded text-muted-foreground hover:bg-muted hover:text-destructive disabled:opacity-40 disabled:cursor-not-allowed max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="Delete message"
title="Delete message"
>
<Trash2 className="size-3" />
</button>
)}
</div>
<Dialog
open={deleteOpen}
@@ -536,7 +591,39 @@ function SummaryCard({ message }: { message: Message }) {
);
}
export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
// Collapsible "Thinking" block for assistant reasoning. Fed by either
// reasoning_text (coder wire / live reasoning_delta stream) or reasoning_parts
// (native inference, persisted from message_parts). Auto-expands while the turn
// is still streaming so the user watches it think (Paseo-style), then stays
// where the user left it once the turn completes — initial state is captured
// once at mount, so we never fight a manual collapse on later re-renders.
function ReasoningBlock({ text, streaming }: { text: string; streaming: boolean }) {
const [expanded, setExpanded] = useState(() => streaming);
return (
<div className="max-w-[90%] rounded-lg border bg-muted/30 text-sm">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex items-center gap-1.5 w-full px-3 py-1.5 text-left text-muted-foreground hover:text-foreground"
>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<Brain size={13} />
<span className="text-xs font-medium">Thinking</span>
{streaming && (
<span className="ml-1 inline-block w-1.5 h-3 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</button>
{expanded && (
<div className="px-3 pb-2.5 pt-0.5 text-xs leading-relaxed text-muted-foreground whitespace-pre-wrap break-words border-t">
{text}
</div>
)}
</div>
);
}
export function MessageBubble({ message, sessionChats, capHitInfo, actions, hideActions }: Props) {
const hiddenSet = new Set(hideActions ?? []);
// v1.11: anchored rolling summary row. Checked BEFORE the kind==='compact'
// branch because summary=true never coexists with kind='compact' (new
// compactions emit role='assistant' rows with kind='message'+summary=true).
@@ -585,7 +672,7 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
{message.content}
</div>
</SendToTerminalMenu>
<ActionRow message={message} />
<ActionRow message={message} actions={actions} hiddenSet={hiddenSet} />
</div>
);
}
@@ -595,16 +682,26 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
// v1.13.7: match the MessageList.flatten trim guard so a whitespace-only
// assistant turn doesn't render an empty bubble + dangling ActionRow.
const hasContent = message.content.trim().length > 0;
// Reasoning arrives as a pre-joined string (coder wire) or as parts (native
// inference). Read whichever is present; loose ?? chain tolerates the coder
// shape where reasoning_parts is undefined (see CLAUDE.md null-guard note).
const reasoningText = (
message.reasoning_text ??
message.reasoning_parts?.map((p) => p.text ?? '').join('') ??
''
).trim();
const hasReasoning = reasoningText.length > 0;
// v1.8.2: if metadata stamps an error reason, surface it inline under the
// generic "message failed" line. Keeps the user's eye where it already is
// rather than introducing a separate banner.
const errorMeta =
message.metadata !== null && message.metadata.kind === 'error'
message.metadata != null && message.metadata.kind === 'error'
? message.metadata
: null;
return (
<div className="group flex flex-col gap-2">
{hasReasoning && <ReasoningBlock text={reasoningText} streaming={isStreaming} />}
{(hasContent || isStreaming) && (
<SendToTerminalMenu>
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
@@ -627,7 +724,7 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
</div>
)}
{!isStreaming && <StatsLine message={message} />}
{!isStreaming && hasContent && <ActionRow message={message} />}
{!isStreaming && hasContent && <ActionRow message={message} actions={actions} hiddenSet={hiddenSet} />}
</div>
);
}

View File

@@ -273,7 +273,7 @@ export function MobileTabSwitcher({
<MoreHorizontal size={14} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="end" className="min-w-44">
{chat && (
<DropdownMenuItem onSelect={() => startRename(chat.id, chat.name)}>
<Edit2 size={14} /> Rename chat

View File

@@ -27,7 +27,7 @@ export function NewPaneMenu({ onAddPane, disabled }: Props) {
<Plus size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChevronRight, ChevronDown, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react';
import { api } from '@/api/client';
import { ChevronRight, ChevronDown, FilePlus, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react';
import { api, ApiError } from '@/api/client';
import type { FileEntry } from '@/api/types';
import { inferLanguage } from '@/lib/attachments';
import { sessionEvents } from '@/hooks/sessionEvents';
@@ -8,10 +8,22 @@ import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
import { useViewport } from '@/hooks/useViewport';
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
interface Props {
projectId: string;
sessionId: string;
}
const STORAGE_KEY = 'boocode.rightrail';
@@ -27,7 +39,7 @@ function joinPath(parent: string, name: string): string {
return `${parent}/${name}`;
}
export function RightRail({ projectId }: Props) {
export function RightRail({ projectId, sessionId }: Props) {
const { isMobile } = useViewport();
const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer();
const [open, setOpen] = useState(() => {
@@ -39,6 +51,39 @@ export function RightRail({ projectId }: Props) {
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null);
// New-file-from-pasted-text modal. Queues a pending_changes create via
// BooCoder; it then shows in the CoderPane DiffPanel for explicit apply.
const [newFileOpen, setNewFileOpen] = useState(false);
const [newFilePath, setNewFilePath] = useState('');
const [newFileContent, setNewFileContent] = useState('');
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const openNewFile = useCallback(() => {
setNewFilePath('');
setNewFileContent('');
setCreateError(null);
setNewFileOpen(true);
}, []);
const submitNewFile = useCallback(async () => {
const path = newFilePath.trim();
if (!path || creating) return;
setCreating(true);
setCreateError(null);
try {
await api.coder.createPendingFile(sessionId, path, newFileContent);
setNewFileOpen(false);
setNewFilePath('');
setNewFileContent('');
} catch (err) {
// 422 WriteGuardError surfaces via ApiError.message (the route's { error }).
setCreateError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : 'Failed to create file');
} finally {
setCreating(false);
}
}, [sessionId, newFilePath, newFileContent, creating]);
// Combined open state: on mobile use the global drawer state (toggled by
// the Session header's FolderTree button); on desktop use the persistent
// internal state.
@@ -163,6 +208,15 @@ export function RightRail({ projectId }: Props) {
<aside className={asideCls}>
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
<span className="text-xs font-medium flex-1">Files</span>
<button
type="button"
onClick={openNewFile}
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
aria-label="New file from pasted text"
title="New file"
>
<FilePlus size={14} />
</button>
<button
type="button"
onClick={closeRail}
@@ -225,6 +279,48 @@ export function RightRail({ projectId }: Props) {
onNavigate={(path) => void openFile(path)}
/>
)}
<Dialog open={newFileOpen} onOpenChange={setNewFileOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>New file from pasted text</DialogTitle>
<DialogDescription>
Queues a new file as a pending change. Review and apply it from the Coder pane.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="new-file-path" className="text-xs">Path (relative to project root)</Label>
<Input
id="new-file-path"
value={newFilePath}
onChange={(e) => setNewFilePath(e.target.value)}
placeholder="src/example.ts"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="new-file-content" className="text-xs">Content</Label>
<Textarea
id="new-file-content"
value={newFileContent}
onChange={(e) => setNewFileContent(e.target.value)}
placeholder="Paste file contents here…"
autoFocus
className="min-h-[180px] font-mono text-xs"
/>
</div>
{createError && <p className="text-xs text-destructive">{createError}</p>}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setNewFileOpen(false)} disabled={creating}>
Cancel
</Button>
<Button onClick={() => void submitNewFile()} disabled={creating || !newFilePath.trim()}>
{creating ? 'Creating…' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,185 +1,31 @@
import { useCallback, useEffect, useState } from 'react';
import { Archive, MessageSquare, Send, ChevronDown, ChevronRight, RotateCcw, Trash2 } from 'lucide-react';
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import type { Chat } from '@/api/types';
import { api } from '@/api/client';
import { Button } from '@/components/ui/button';
import { ChatInput } from '@/components/ChatInput';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { formatTokens } from '@/lib/format';
interface Props {
sessionId: string;
projectId: string;
chats: Chat[];
onOpenChat: (chatId: string) => void;
sessionId: string;
agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>;
onSend: (content: string) => void;
/** Create a chat and return its id. Used by slash-command handler. */
// Slash-command (skill) send from the landing page. The parent creates the
// chat, assigns it to the pane (so it transitions to ChatPane), and invokes
// the skill — same transition the text send uses. See useSessionChats.
onSkillInvoke: (skillName: string, userMessage: string | null) => void;
createChat: () => Promise<{ id: string }>;
onReopenChat: (chatId: string) => Promise<void>;
onArchiveChat: (chatId: string) => Promise<void>;
onRenameChat: (chatId: string, name: string) => Promise<void>;
onDeleteChat: (chatId: string) => Promise<void>;
}
function relTime(iso: string): string {
const now = Date.now();
const t = Date.parse(iso);
if (Number.isNaN(t)) return '';
const sec = Math.max(0, Math.floor((now - t) / 1000));
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
const day = Math.floor(hr / 24);
return `${day}d ago`;
}
interface ChatRowProps {
chat: Chat;
onClick: () => void;
dimmed?: boolean;
trailing?: React.ReactNode;
actions?: React.ReactNode;
renamingId: string | null;
renameValue: string;
setRenameValue: (s: string) => void;
onFinishRename: () => void;
onCancelRename: () => void;
onContextStartRename: () => void;
onContextArchive: () => void;
onContextDelete: () => void;
showContextMenu: boolean;
}
function ChatRow({
chat,
onClick,
dimmed,
trailing,
actions,
renamingId,
renameValue,
setRenameValue,
onFinishRename,
onCancelRename,
onContextStartRename,
onContextArchive,
onContextDelete,
showContextMenu,
}: ChatRowProps) {
const meta: string[] = [relTime(chat.updated_at)];
if (chat.message_count !== undefined && chat.message_count > 0) {
meta.push(`${chat.message_count} msg`);
}
const tokens = formatTokens(chat.effective_context_tokens);
if (tokens) meta.push(tokens);
const preview = chat.last_message_preview;
const isRenaming = renamingId === chat.id;
const inner = (
<button
type="button"
onClick={onClick}
className="w-full flex flex-col gap-0.5 px-3 py-2 hover:bg-muted/50 text-left"
>
<div className="flex items-center gap-2 min-w-0">
<MessageSquare className={`size-3.5 shrink-0 ${dimmed ? 'opacity-40' : 'opacity-70'}`} />
{isRenaming ? (
<input
autoFocus
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => onFinishRename()}
onKeyDown={(e) => {
if (e.key === 'Enter') onFinishRename();
if (e.key === 'Escape') onCancelRename();
}}
onClick={(e) => e.stopPropagation()}
className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0"
/>
) : (
<span className={`truncate text-sm flex-1 ${dimmed ? 'text-muted-foreground' : ''}`}>
{chat.name ?? 'New chat'}
</span>
)}
{trailing && (
<span className="text-xs text-muted-foreground shrink-0">{trailing}</span>
)}
{actions && (
<div className="flex items-center gap-0.5 shrink-0">{actions}</div>
)}
</div>
<div className="ml-5 text-xs text-muted-foreground tabular-nums">
{meta.join(' · ')}
</div>
{preview && (
<div className="ml-5 text-xs italic text-muted-foreground truncate">
{preview}
</div>
)}
</button>
);
if (!showContextMenu) return inner;
return (
<ContextMenu>
<ContextMenuTrigger asChild>{inner}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={onClick}>Open</ContextMenuItem>
<ContextMenuItem onSelect={onContextStartRename}>Rename</ContextMenuItem>
<ContextMenuItem onSelect={onContextArchive}>Archive</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive" onSelect={onContextDelete}>
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
export function SessionLandingPage({
chats,
onOpenChat,
onSend,
projectId,
sessionId,
agentId,
onAgentChange,
onSend,
onSkillInvoke,
createChat,
onReopenChat,
onArchiveChat,
onRenameChat,
onDeleteChat,
}: Props) {
const [composerValue, setComposerValue] = useState('');
const [chatId, setChatId] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [archiveConfirm, setArchiveConfirm] = useState<Chat | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Chat | null>(null);
const openChats = chats
.filter((c) => c.status === 'open')
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
const archivedChats = chats
.filter((c) => c.status === 'archived')
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
// Create a chat lazily on first send or slash command.
const ensureChat = useCallback(async (): Promise<string> => {
if (chatId) return chatId;
try {
@@ -192,207 +38,45 @@ export function SessionLandingPage({
}
}, [chatId, createChat]);
async function handleSend() {
const text = composerValue.trim();
const handleSend = useCallback(async (content: string) => {
const text = content.trim();
if (!text) return;
try {
const cid = await ensureChat();
await ensureChat();
onSend(text);
setComposerValue('');
} catch {
// Error already surfaced via toast.
}
}
}, [ensureChat, onSend]);
// v2.3: slash-command dispatch on landing page. Creates a chat first if
// one doesn't exist, then invokes the skill on that chat.
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => {
try {
const cid = await ensureChat();
await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null);
setComposerValue('');
} catch (err) {
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
}
}, [ensureChat]);
// Route to the parent, which creates the chat, assigns it to the pane (so the
// pane transitions to ChatPane and subscribes to the stream), then invokes the
// skill — mirroring the text-send transition. Doing the skill invoke locally
// (without the pane assignment) left the landing pane stuck/blank.
const handleSlashCommand = useCallback((skillName: string, userMessage: string) => {
onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
}, [onSkillInvoke]);
function startRename(chat: Chat) {
setRenamingId(chat.id);
setRenameValue(chat.name ?? '');
}
async function finishRename() {
if (renamingId && renameValue.trim()) {
await onRenameChat(renamingId, renameValue.trim());
}
setRenamingId(null);
}
// TODO: Landing page chat counts are a snapshot at mount. New messages in
// visible chats won't update the per-row stats until next mount/navigation.
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{openChats.length > 0 && (
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-2">Open chats</h3>
<ul className="divide-y rounded-md border">
{openChats.map((chat) => (
<li key={chat.id}>
<ChatRow
chat={chat}
onClick={() => onOpenChat(chat.id)}
renamingId={renamingId}
renameValue={renameValue}
setRenameValue={setRenameValue}
onFinishRename={() => void finishRename()}
onCancelRename={() => setRenamingId(null)}
onContextStartRename={() => startRename(chat)}
onContextArchive={() => setArchiveConfirm(chat)}
onContextDelete={() => setDeleteConfirm(chat)}
showContextMenu
actions={
<>
<Button
variant="ghost"
size="icon-sm"
aria-label="Archive chat"
title="Archive chat"
onClick={(e) => {
e.stopPropagation();
setArchiveConfirm(chat);
}}
>
<Archive size={14} />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete chat"
title="Delete chat"
className="text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
setDeleteConfirm(chat);
}}
>
<Trash2 size={14} />
</Button>
</>
}
/>
</li>
))}
</ul>
</div>
)}
{archivedChats.length > 0 && (
<div>
<button
type="button"
onClick={() => setShowArchived(!showArchived)}
className="flex items-center gap-1 text-xs font-medium text-muted-foreground mb-2 hover:text-foreground"
>
{showArchived ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Archived chats ({archivedChats.length})
</button>
{showArchived && (
<ul className="divide-y rounded-md border">
{archivedChats.map((chat) => (
<li key={chat.id}>
<ChatRow
chat={chat}
onClick={() => void onReopenChat(chat.id)}
dimmed
trailing={<><RotateCcw size={10} className="inline mr-1" />Restore</>}
renamingId={null}
renameValue=""
setRenameValue={() => {}}
onFinishRename={() => {}}
onCancelRename={() => {}}
onContextStartRename={() => {}}
onContextArchive={() => {}}
onContextDelete={() => {}}
showContextMenu={false}
/>
</li>
))}
</ul>
)}
</div>
)}
{openChats.length === 0 && archivedChats.length === 0 && (
<div className="text-sm text-muted-foreground py-8 text-center">
No chats yet. Type below to start a conversation.
</div>
)}
<div className="flex-1 flex items-center justify-center px-6">
<p className="text-sm text-muted-foreground">
Send a message to start.
</p>
</div>
{/* v2.3: ChatInput with slash-command support replaces the bare Textarea.
chatId is created lazily on first send/slash. */}
<div className="border-t px-4 py-3 shrink-0">
<ChatInput
projectId={projectId}
onSend={handleSend}
onSlashCommand={handleSlashCommand}
chatId={chatId ?? undefined}
chatLabel={chatId ? undefined : 'Chat'}
disabled={false}
/>
</div>
<Dialog open={archiveConfirm !== null} onOpenChange={(open) => { if (!open) setArchiveConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Archive chat?</DialogTitle>
<DialogDescription>
Moves {archiveConfirm ? `"${archiveConfirm.name ?? 'New chat'}"` : 'this chat'} to the Archived chats section. You can restore it any time.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setArchiveConfirm(null)}>
Cancel
</Button>
<Button
onClick={() => {
if (archiveConfirm) void onArchiveChat(archiveConfirm.id);
setArchiveConfirm(null);
}}
>
Archive
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete chat?</DialogTitle>
<DialogDescription>
Permanently delete{' '}
<span className="font-mono font-medium text-foreground">{deleteConfirm?.name || '(unnamed)'}</span>
{' '}and all its messages. This cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 justify-end pt-2">
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
if (deleteConfirm) void onDeleteChat(deleteConfirm.id);
setDeleteConfirm(null);
}}
>
Delete
</Button>
</div>
</DialogContent>
</Dialog>
<ChatInput
disabled={false}
projectId={projectId}
sessionId={sessionId}
agentId={agentId ?? null}
onAgentChange={onAgentChange}
onSend={handleSend}
onSlashCommand={handleSlashCommand}
chatId={chatId ?? undefined}
chatLabel="Chat"
messages={[]}
modelContextLimit={null}
/>
</div>
);
}

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';
@@ -8,9 +8,19 @@ export interface SlashCommandItem {
description?: string;
}
export interface SlashCommandGroup {
label: string;
items: SlashCommandItem[];
icon?: ReactNode;
}
interface Props {
query: string;
items: SlashCommandItem[];
// Optional segmented rendering. When provided, items are shown under labeled
// group headers (in order). `items` is ignored. BooChat passes only `items`
// (flat) so its menu is unchanged — grouping is opt-in.
groups?: SlashCommandGroup[];
inputRef: RefObject<HTMLElement | null>;
onSelect: (name: string) => void;
onClose: () => void;
@@ -28,6 +38,7 @@ function filterByPrefix(items: SlashCommandItem[], query: string): SlashCommandI
export function SlashCommandPicker({
query,
items,
groups,
inputRef,
onSelect,
onClose,
@@ -35,7 +46,21 @@ export function SlashCommandPicker({
}: Props) {
const [highlightIndex, setHighlightIndex] = useState(0);
const popoverRef = useRef<HTMLDivElement>(null);
const filtered = useMemo(() => filterByPrefix(items, query), [items, query]);
// When grouped, filter each group and drop empties; otherwise the flat list.
const filteredGroups = useMemo(
() =>
groups
? groups
.map((g) => ({ label: g.label, icon: g.icon, items: filterByPrefix(g.items, query) }))
.filter((g) => g.items.length > 0)
: null,
[groups, query],
);
// Flat list drives keyboard nav + Enter selection across all groups.
const filtered = useMemo(
() => (filteredGroups ? filteredGroups.flatMap((g) => g.items) : filterByPrefix(items, query)),
[filteredGroups, items, query],
);
const [rect, setRect] = useState<DOMRect | null>(
() => inputRef.current?.getBoundingClientRect() ?? null,
@@ -130,6 +155,36 @@ export function SlashCommandPicker({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rect, vvTick]);
const renderItem = (item: SlashCommandItem, i: number) => (
<div
key={`${i}-${item.name}`}
role="option"
aria-selected={i === highlightIndex}
data-highlighted={i === highlightIndex}
className={cn(
'w-full text-left px-2.5 py-2 cursor-pointer block',
i === highlightIndex && 'bg-muted',
)}
onMouseEnter={() => setHighlightIndex(i)}
onClick={() => onSelect(item.name)}
>
<div className="font-mono text-xs font-bold text-foreground">/{item.name}</div>
{item.description && (
<div
className="text-xs text-muted-foreground overflow-hidden"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{item.description}
</div>
)}
</div>
);
let runningIndex = -1;
const popover = filtered.length === 0 ? (
<div
ref={popoverRef}
@@ -146,34 +201,17 @@ export function SlashCommandPicker({
className="z-50 bg-popover border border-border rounded-md shadow min-w-[320px] max-w-[420px] max-h-[320px] overflow-y-auto overscroll-contain touch-pan-y"
style={style}
>
{filtered.map((item, i) => (
<div
key={item.name}
role="option"
aria-selected={i === highlightIndex}
data-highlighted={i === highlightIndex}
className={cn(
'w-full text-left px-2.5 py-2 cursor-pointer block',
i === highlightIndex && 'bg-muted',
)}
onMouseEnter={() => setHighlightIndex(i)}
onClick={() => onSelect(item.name)}
>
<div className="font-mono text-xs font-bold text-foreground">/{item.name}</div>
{item.description && (
<div
className="text-xs text-muted-foreground overflow-hidden"
style={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{item.description}
{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 flex items-center gap-1.5">
{g.icon}
{g.label}
</div>
{g.items.map((item) => renderItem(item, (runningIndex += 1)))}
</div>
)}
</div>
))}
))
: filtered.map((item, i) => renderItem(item, i))}
</div>
);

View File

@@ -37,6 +37,7 @@ interface Props {
project: Project | null;
/** New BooCode opens a fresh coder session; chat/terminal split in-place. */
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
onCoderConnectedChange?: (paneId: string, connected: boolean) => void;
}
export function Workspace({
@@ -48,6 +49,7 @@ export function Workspace({
chatsHook,
session,
project,
onCoderConnectedChange,
onAddPane,
}: Props) {
const {
@@ -80,6 +82,7 @@ export function Workspace({
deleteChat,
renameChat,
handleLandingSend,
handleLandingSkill,
} = chatsHook;
const { isMobile } = useViewport();
@@ -141,6 +144,7 @@ export function Workspace({
// Per-coder-pane WS connection (status dot lives in the pane header).
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
const [coderLabels, setCoderLabels] = useState<Record<string, string>>({});
return (
<div className="flex flex-col h-full min-h-0">
@@ -212,24 +216,23 @@ export function Workspace({
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
/>
)}
{isCoder && (
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-2 py-1 shrink-0">
{isCoder && !isMobile && (
<div className="flex items-center gap-1 border-b border-border px-2 py-1 shrink-0">
<Code size={12} className="text-muted-foreground" />
<span className="text-xs text-muted-foreground">BooCode</span>
<div className="ml-auto flex items-center gap-1.5">
<div className="ml-auto flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="New pane"
title="New pane"
>
<Plus size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
@@ -241,23 +244,12 @@ export function Workspace({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span
className={cn(
'inline-block w-1.5 h-1.5 rounded-full shrink-0',
coderConnected[pane.id] ? 'bg-green-500' : 'bg-red-500',
)}
title={coderConnected[pane.id] ? 'Connected' : 'Disconnected'}
/>
{panes.length > 1 && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removePane(idx);
}}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:size-7"
aria-label="Close BooCode pane"
title="Close BooCode pane"
onClick={(e) => { e.stopPropagation(); removePane(idx); }}
className="inline-flex items-center justify-center size-5 rounded text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Close pane"
>
<X size={12} />
</button>
@@ -283,7 +275,7 @@ export function Workspace({
<Plus size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-40">
<DropdownMenuContent align="end" className="w-fit">
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
<MessageSquare size={14} /> New BooChat
</DropdownMenuItem>
@@ -354,9 +346,15 @@ export function Workspace({
chatId={activePaneChatId(pane)}
chatPending={isPaneChatPending(pane.id)}
projectPath={project?.path}
onConnectedChange={(connected) =>
onConnectedChange={(connected) => {
setCoderConnected((prev) =>
prev[pane.id] === connected ? prev : { ...prev, [pane.id]: connected },
);
onCoderConnectedChange?.(pane.id, connected);
}}
onAgentLabelChange={(label) =>
setCoderLabels((prev) =>
prev[pane.id] === label ? prev : { ...prev, [pane.id]: label },
)
}
/>
@@ -384,19 +382,13 @@ export function Workspace({
/>
) : (
<SessionLandingPage
sessionId={sessionId}
projectId={projectId}
chats={chats}
sessionId={sessionId}
agentId={agentId}
onAgentChange={onAgentChange}
createChat={() => api.chats.create(sessionId)}
onOpenChat={(chatId) => openChatInPane(idx, chatId)}
onSend={(content) => void handleLandingSend(idx, content)}
onReopenChat={async (chatId) => {
await unarchiveChat(chatId);
openChatInPane(idx, chatId);
}}
onArchiveChat={archiveChat}
onRenameChat={renameChat}
onDeleteChat={deleteChat}
onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)}
/>
)}
</div>

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

@@ -0,0 +1,21 @@
interface IconProps {
size?: number;
className?: string;
}
export function ClaudeIcon({ size = 14, className }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd" className={className}>
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" />
</svg>
);
}
export function OpenCodeIcon({ size = 14, className }: IconProps) {
return (
<svg width={size} height={size} viewBox="96 64 288 384" fill="currentColor" className={className}>
<path d="M320 224V352H192V224H320Z" opacity={0.4} />
<path fillRule="evenodd" clipRule="evenodd" d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z" />
</svg>
);
}

View File

@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, type ReactNode } from 'react';
import { MarkdownRenderer } from '@/components/MarkdownRenderer';
import { MessageBubble, type MessageActions } from '@/components/MessageBubble';
import { ToolCallGroup } from '@/components/ToolCallGroup';
import { ToolCallLine, type ToolRun } from '@/components/ToolCallLine';
import { AskUserInputCard } from '@/components/AskUserInputCard';
import { wireToolCallToRun, type CoderToolCallWire } from '@/lib/coder-tools';
import type { Message } from '@/api/types';
export interface CoderMessageWire {
id: string;
@@ -141,54 +142,16 @@ function groupToolRuns(items: RenderItem[]): RenderItem[] {
return out;
}
function CoderTextBubble({ message }: { message: CoderMessageWire }) {
const isUser = message.role === 'user';
const isStreaming = message.status === 'streaming';
const hasText = message.content.trim().length > 0;
const hasReasoning = (message.reasoning_text?.trim().length ?? 0) > 0;
if (isUser) {
return (
<div className="flex flex-col items-end gap-1">
<div className="max-w-[80%] rounded-lg bg-primary text-primary-foreground px-3 py-2 text-sm whitespace-pre-wrap break-words min-w-0">
{message.content}
</div>
</div>
);
}
return (
<div className="flex flex-col gap-2">
{hasReasoning && (
<details className="rounded border border-border/40 bg-muted/20 px-2 py-1">
<summary className="cursor-pointer text-xs text-muted-foreground select-none">Reasoning</summary>
<pre className="mt-1 max-h-48 overflow-y-auto whitespace-pre-wrap text-[11px] text-muted-foreground font-mono">
{message.reasoning_text}
</pre>
</details>
)}
{(hasText || (isStreaming && !hasReasoning)) && (
<div className="max-w-[90%] text-sm leading-relaxed space-y-2 break-words min-w-0">
{hasText ? <MarkdownRenderer content={message.content} /> : null}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 align-baseline bg-muted-foreground/60 animate-pulse" />
)}
</div>
)}
{message.status === 'failed' && (
<div className="text-xs text-destructive">message failed</div>
)}
</div>
);
}
interface Props {
messages: CoderTimelineWire[];
chatId?: string;
footer?: ReactNode;
actions?: MessageActions;
}
export function CoderMessageList({ messages, chatId, footer }: Props) {
const CODER_HIDDEN_ACTIONS: ('fork' | 'delete' | 'openInPane')[] = ['fork', 'openInPane'];
export function CoderMessageList({ messages, chatId, footer, actions }: Props) {
const endRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const isNearBottomRef = useRef(true);
@@ -220,7 +183,14 @@ export function CoderMessageList({ messages, chatId, footer }: Props) {
<div className="max-w-[1000px] mx-auto w-full px-6 py-4 space-y-4">
{renderItems.map((item) => {
if (item.kind === 'message') {
return <CoderTextBubble key={item.message.id} message={item.message} />;
return (
<MessageBubble
key={item.message.id}
message={item.message as unknown as Message}
actions={actions}
hideActions={CODER_HIDDEN_ACTIONS}
/>
);
}
if (item.kind === 'tool_run') {
if (item.run.call.name === 'ask_user_input' && chatId) {

View File

@@ -4,11 +4,11 @@
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Code, Send, 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 { AgentCommandsHint } from '@/components/AgentCommandsHint';
import { SlashCommandPicker } from '@/components/SlashCommandPicker';
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';
@@ -32,6 +32,8 @@ interface CoderMessage {
id: string;
function: { name: string; arguments: string };
}>;
ctx_used?: number | null;
ctx_max?: number | null;
}
interface CoderToolMessage {
@@ -63,6 +65,7 @@ interface Props {
chatPending?: boolean;
projectPath?: string;
onConnectedChange?: (connected: boolean) => void;
onAgentLabelChange?: (label: string) => void;
}
interface WsHandlers {
@@ -91,6 +94,8 @@ type RawCoderMessage = {
| { id: string; name: string; args?: Record<string, unknown> }
| { id: string; function: { name: string; arguments: string } }
> | null;
ctx_used?: number | null;
ctx_max?: number | null;
};
function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null {
@@ -126,6 +131,8 @@ function mapCoderTimelineRow(raw: RawCoderMessage): CoderTimelineMessage | null
status: (raw.status ?? 'complete') as CoderMessage['status'],
...(reasoning_text ? { reasoning_text } : {}),
...(tool_calls?.length ? { tool_calls } : {}),
ctx_used: raw.ctx_used ?? null,
ctx_max: raw.ctx_max ?? null,
};
}
@@ -228,7 +235,12 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
);
const next = prev.map((m) =>
m.id === frame.message_id && m.role !== 'tool'
? { ...m, status: 'complete' as const }
? {
...m,
status: 'complete' as const,
ctx_used: (frame as any).ctx_used ?? (m as any).ctx_used ?? null,
ctx_max: (frame as any).ctx_max ?? (m as any).ctx_max ?? null,
}
: m,
);
if (completed) {
@@ -343,7 +355,7 @@ function usePendingChanges(sessionId: string) {
useEffect(() => { refresh(); }, [refresh]);
const approve = useCallback(async (changeId: string) => {
const res = await fetch(`/api/coder/sessions/${sessionId}/pending/${changeId}/approve`, {
const res = await fetch(`/api/coder/pending/${changeId}/apply`, {
method: 'POST',
});
if (res.ok) {
@@ -352,7 +364,7 @@ function usePendingChanges(sessionId: string) {
}, [sessionId]);
const reject = useCallback(async (changeId: string) => {
const res = await fetch(`/api/coder/sessions/${sessionId}/pending/${changeId}/reject`, {
const res = await fetch(`/api/coder/pending/${changeId}/reject`, {
method: 'POST',
});
if (res.ok) {
@@ -463,6 +475,7 @@ export function CoderPane({
chatPending = false,
projectPath,
onConnectedChange,
onAgentLabelChange,
}: Props) {
const [agentConfig, setAgentConfig] = useState<AgentSessionConfig>({
provider: 'boocode',
@@ -470,6 +483,12 @@ export function CoderPane({
modeId: null,
thinkingOptionId: null,
});
useEffect(() => {
const parts = [agentConfig.provider || 'boocode'];
if (agentConfig.model) parts.push(agentConfig.model);
onAgentLabelChange?.(parts.join(' · '));
}, [agentConfig.provider, agentConfig.model, onAgentLabelChange]);
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [permissionPrompt, setPermissionPrompt] = useState<PermissionPrompt | null>(null);
const [permissionBusy, setPermissionBusy] = useState(false);
@@ -492,6 +511,50 @@ export function CoderPane({
[displayedCommands],
);
// v2.5.9: segmented slash menu — the active agent's commands first, then
// BooCoder skills. boocode has no separate "commands" group (it IS native),
// so it shows only Skills. Empty groups are dropped.
const agentCommands = useMemo(
() =>
agentConfig.provider === 'boocode'
? []
: mergeCommandsByName(providerCommands, liveTaskCommands),
[agentConfig.provider, providerCommands, liveTaskCommands],
);
const skillItems = useMemo(
() => skills.map((s) => ({ name: s.name, description: s.description })),
[skills],
);
const slashGroups = useMemo(() => {
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: 'BooCoder skills',
items: skillItems,
icon: <Sparkles className="size-3 shrink-0" />,
});
}
return groups;
}, [agentCommands, skillItems, agentConfig.provider]);
const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
onConnectedChange,
onPermissionRequested: (prompt) => {
@@ -515,6 +578,8 @@ export function CoderPane({
const { changes, loading, refresh, approve, reject } = usePendingChanges(sessionId);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [queue, setQueue] = useState<string[]>([]);
const queueProcessing = useRef(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
// Refresh pending changes when a message_complete arrives
@@ -658,43 +723,103 @@ export function CoderPane({
setMessages,
]);
const handleSlashSelect = useCallback((name: string) => {
const next = `/${name} `;
setInput(next);
setSlashState(null);
requestAnimationFrame(() => {
const ta = inputRef.current;
if (ta) {
ta.selectionStart = ta.selectionEnd = next.length;
ta.focus();
}
});
}, []);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setInput(newValue);
if (isSlashCommandToken(newValue)) {
setSlashState({ query: slashQuery(newValue) });
} else {
setSlashState(null);
const sendOneMessage = useCallback(async (text: string) => {
if (!chatId) return;
setSending(true);
setPermissionPrompt(null);
setLiveTaskCommands([]);
const tempId = `temp-${Date.now()}`;
setMessages((prev) => [...prev, { id: tempId, role: 'user', content: text, status: 'complete' }]);
try {
const data = await api.coder.sendMessage(sessionId, {
content: text,
pane_id: paneId,
chat_id: chatId,
provider: agentConfig.provider !== 'boocode' ? agentConfig.provider : undefined,
model: agentConfig.model || undefined,
mode_id: agentConfig.modeId ?? undefined,
thinking_option_id: agentConfig.thinkingOptionId ?? undefined,
});
if (data.user_message_id) {
setMessages((prev) =>
prev.map((m) => (m.id === tempId ? { ...m, id: data.user_message_id! } : m))
);
}
if (data.task_id) {
setActiveTaskId(data.task_id);
} else {
setActiveTaskId(null);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : 'failed to send');
} finally {
setSending(false);
}
}, []);
}, [sessionId, paneId, chatId, agentConfig, setMessages]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (slashState) return;
if (e.nativeEvent.isComposing) return;
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
},
[handleSend, slashState]
);
// Drain queue when not busy
useEffect(() => {
if (sending || queue.length === 0 || queueProcessing.current) return;
queueProcessing.current = true;
const next = queue[0]!;
setQueue((prev) => prev.slice(1));
sendOneMessage(next).finally(() => { queueProcessing.current = false; });
}, [sending, queue, sendOneMessage]);
const handleChatInputSend = useCallback(async (content: string) => {
const text = content.trim();
if (!text || !chatId) return;
if (sending) {
setQueue((prev) => [...prev, text]);
return;
}
await sendOneMessage(text);
}, [sending, chatId, sendOneMessage]);
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
if (!chatId) return;
// Only BooCoder skills route here; an agent's own commands (not skills) fall
// through to a literal send in ChatInput. Skills run under the active
// provider: boocode → native inference; external → body injected into a task.
if (!skillsByName.has(skillName)) return;
setSending(true);
setPermissionPrompt(null);
setLiveTaskCommands([]);
try {
const data = await api.coder.skillInvoke(
sessionId,
paneId,
skillName,
userMessage.length > 0 ? userMessage : null,
agentConfig.provider !== 'boocode'
? {
provider: agentConfig.provider,
model: agentConfig.model || undefined,
mode_id: agentConfig.modeId ?? undefined,
thinking_option_id: agentConfig.thinkingOptionId ?? undefined,
}
: undefined,
);
if (data.task_id) setActiveTaskId(data.task_id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
} finally {
setSending(false);
}
}, [chatId, sessionId, paneId, agentConfig, skillsByName]);
return (
<div className="flex flex-col h-full bg-background">
<AgentComposerBar
projectPath={projectPath}
value={agentConfig}
onChange={setAgentConfig}
onProviderCommandsChange={handleProviderCommandsChange}
connected={connected}
/>
{/* Chat area — BooChat-style timeline (text + tool runs as siblings) */}
<div className="flex-1 min-h-0 flex flex-col">
{messages.length === 0 ? (
@@ -706,6 +831,9 @@ export function CoderPane({
<CoderMessageList
messages={messages as CoderTimelineWire[]}
chatId={chatId}
actions={{
onResend: async (_chatId, content) => { await sendOneMessage(content); },
}}
footer={
activeTaskId && !permissionPrompt && sending === false ? (
<p className="text-xs text-muted-foreground animate-pulse">Agent running</p>
@@ -738,44 +866,17 @@ export function CoderPane({
{/* Composer + input */}
<div className="shrink-0 border-t border-border">
{displayedCommands.length > 0 && <AgentCommandsHint commands={displayedCommands} />}
<AgentComposerBar
projectPath={projectPath}
value={agentConfig}
onChange={setAgentConfig}
onProviderCommandsChange={handleProviderCommandsChange}
<ChatInput
disabled={sending || !chatId || chatPending}
projectId={projectPath ?? ''}
onSend={handleChatInputSend}
onSlashCommand={handleChatInputSlash}
slashGroups={slashGroups}
chatId={chatId ?? undefined}
chatLabel="BooCode"
messages={messages as unknown as import('@/api/types').Message[]}
modelContextLimit={null}
/>
<div className="p-2">
<div className="flex items-end gap-2">
<textarea
ref={inputRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Type / for commands…"
rows={1}
className="flex-1 resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring max-h-32 min-h-[36px] max-md:min-h-[44px]"
/>
<button
type="button"
onClick={() => void handleSend()}
disabled={!input.trim() || sending || !chatId || chatPending}
className="inline-flex items-center justify-center size-9 max-md:min-h-[44px] max-md:min-w-[44px] rounded-md bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
aria-label="Send message"
>
<Send size={16} />
</button>
</div>
</div>
{slashState && (
<SlashCommandPicker
query={slashState.query}
items={displayedCommands}
inputRef={inputRef}
onSelect={handleSlashSelect}
onClose={() => setSlashState(null)}
/>
)}
</div>
</div>
);

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

@@ -162,6 +162,10 @@ export interface ChatStatusEvent {
reason?: ErrorReason;
}
export interface RefetchMessagesEvent {
type: 'refetch_messages';
}
export type SessionEvent =
| SessionRenamedEvent
| ProjectCreatedEvent
@@ -186,7 +190,8 @@ export type SessionEvent =
| ProjectArchivedEvent
| ProjectUnarchivedEvent
| ProjectUpdatedEvent
| ChatStatusEvent;
| ChatStatusEvent
| RefetchMessagesEvent;
type Listener = (event: SessionEvent) => void;
const listeners = new Set<Listener>();

View File

@@ -24,6 +24,7 @@ export interface UseSessionChatsResult {
deleteChat: (chatId: string) => Promise<void>;
renameChat: (chatId: string, name: string) => Promise<void>;
handleLandingSend: (paneIdx: number, content: string) => Promise<void>;
handleLandingSkill: (paneIdx: number, skillName: string, userMessage: string | null) => Promise<void>;
}
export function useSessionChats(
@@ -166,6 +167,25 @@ export function useSessionChats(
}
}, [sessionId]);
// Slash-command equivalent of handleLandingSend: the initial (landing) chat
// must create the chat AND assign it to the pane (openChatInPane) before
// invoking the skill, so the pane transitions to ChatPane and subscribes to
// the chat's stream. Skipping the assignment left the pane stuck on the
// landing page while the skill ran invisibly (and could blank the pane).
const handleLandingSkill = useCallback(
async (paneIdx: number, skillName: string, userMessage: string | null) => {
try {
const chat = await api.chats.create(sessionId);
setChats((prev) => (prev.some((c) => c.id === chat.id) ? prev : [chat, ...prev]));
openChatInPaneRef.current(paneIdx, chat.id);
await api.chats.skillInvoke(chat.id, skillName, userMessage);
} catch (err) {
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
}
},
[sessionId],
);
return {
chats,
setChats,
@@ -175,5 +195,6 @@ export function useSessionChats(
deleteChat,
renameChat,
handleLandingSend,
handleLandingSkill,
};
}

View File

@@ -294,5 +294,21 @@ export function useSessionStream(sessionId: string | undefined) {
};
}, [sessionId]);
useEffect(() => {
if (!sessionId) return;
return sessionEvents.subscribe((event) => {
if (event.type === 'refetch_messages') {
void api.messages
.list(sessionId)
.then((messages) => {
setState((s) => applyFrame(s, { type: 'snapshot', messages }));
})
.catch((err: unknown) => {
console.warn('refetch_messages failed', err);
});
}
});
}, [sessionId]);
return state;
}

View File

@@ -184,6 +184,7 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
case 'chat_unarchived':
case 'chat_deleted':
case 'chat_status':
case 'refetch_messages':
return prev;
case 'project_archived': {
const next = prev.projects.filter((p) => p.id !== event.project_id);

View File

@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import type { ReactNode } from 'react';
import { useLocation } from 'react-router-dom';
import { useViewport } from './useViewport';
interface SidebarDrawerState {
open: boolean;
@@ -13,13 +14,17 @@ const Ctx = createContext<SidebarDrawerState | null>(null);
export function SidebarDrawerProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const location = useLocation();
const { isMobile } = useViewport();
// Auto-close on navigation. Effect fires once on mount too (open default
// is false, so no observable effect) and on every pathname change after.
useEffect(() => {
setOpen(false);
}, [location.pathname]);
// Close drawer on orientation change (landscape→portrait transition).
useEffect(() => {
setOpen(false);
}, [isMobile]);
const toggle = useCallback(() => setOpen((v) => !v), []);
return <Ctx.Provider value={{ open, setOpen, toggle }}>{children}</Ctx.Provider>;

View File

@@ -31,13 +31,48 @@ export function useViewport(): ViewportSnapshot {
if (typeof window === 'undefined') return;
const mobileMq = window.matchMedia(`(max-width: ${MOBILE_MAX}px)`);
const tabletMq = window.matchMedia(`(min-width: ${MOBILE_MAX + 1}px) and (max-width: ${TABLET_MAX}px)`);
const update = () => setState(snapshot());
const update = () =>
setState((prev) => {
const next = snapshot();
// Bail if nothing changed — visualViewport 'resize' fires on every
// URL-bar show/hide and scroll, and a fresh object would re-render
// every consumer needlessly.
if (
prev.isMobile === next.isMobile &&
prev.isTablet === next.isTablet &&
prev.width === next.width
) {
return prev;
}
return next;
});
// matchMedia 'change' alone is not enough on iOS Safari/Vivaldi: when a
// backgrounded tab is restored (bfcache) or refocused, no 'change' fires,
// and the width captured at first paint can be a stale/oversized value
// (iOS reports the wrong innerWidth for a beat before layout settles). That
// leaves isMobile=false on a phone, so the sidebar renders as a permanent
// desktop column with no way to close it. Re-snapshot on every signal that
// accompanies a rejoin/viewport correction, not just breakpoint crossings.
const onVisibility = () => {
if (document.visibilityState === 'visible') update();
};
mobileMq.addEventListener('change', update);
tabletMq.addEventListener('change', update);
window.addEventListener('resize', update);
window.addEventListener('orientationchange', update);
window.addEventListener('pageshow', update);
document.addEventListener('visibilitychange', onVisibility);
window.visualViewport?.addEventListener('resize', update);
update();
return () => {
mobileMq.removeEventListener('change', update);
tabletMq.removeEventListener('change', update);
window.removeEventListener('resize', update);
window.removeEventListener('orientationchange', update);
window.removeEventListener('pageshow', update);
document.removeEventListener('visibilitychange', onVisibility);
window.visualViewport?.removeEventListener('resize', update);
};
}, []);

View File

@@ -50,8 +50,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,7 +135,7 @@ 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;
removeChatFromPanes: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void;
@@ -492,14 +492,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 +515,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next;
});
return openedId;
}, []);
const removePane = useCallback((idx: number) => {

View File

@@ -57,15 +57,17 @@ export function inferLanguage(filename: string): string | null {
export function flattenToMessage(attachments: Attachment[], text: string): string {
if (attachments.length === 0) return text;
const blocks = attachments.map(a => {
const fence = '```' + (a.language ?? '');
let header: string;
if (a.kind === 'lines') {
header = `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`;
} else if (a.kind === 'paste') {
header = `// from: pasted text (${a.content.split('\n').length} lines)`;
} else {
header = `// from: ${a.filename}`;
// Pasted text is raw context, not code from a file — insert it verbatim with
// no ``` fence or provenance header. The chip only exists to keep the textarea
// tidy while composing; on send it should be exactly what the user pasted.
if (a.kind === 'paste') {
return a.content;
}
const fence = '```' + (a.language ?? '');
const header =
a.kind === 'lines'
? `// from: ${a.filename}:${a.range?.[0] ?? '?'}-${a.range?.[1] ?? '?'}`
: `// from: ${a.filename}`;
return `${fence}\n${header}\n${a.content}\n\`\`\``;
});
return [...blocks, text].filter(Boolean).join('\n\n');

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

@@ -6,7 +6,7 @@ import {
useParams,
useSearchParams,
} from 'react-router-dom';
import { ChevronRight, FolderTree, Menu } from 'lucide-react';
import { ChevronRight, FolderTree, Menu, X } from 'lucide-react';
import { api } from '@/api/client';
import type { Project, Session as SessionType } from '@/api/types';
import { sessionEvents } from '@/hooks/sessionEvents';
@@ -61,6 +61,9 @@ function SessionInner({ sessionId }: { sessionId: string }) {
initializeFirstChatIfEmpty,
validatePanes,
} = panesHook;
const [coderConnected, setCoderConnected] = useState<Record<string, boolean>>({});
const activePane = panes[activePaneIdx];
const activeIsCoder = activePane?.kind === 'coder';
const openChatInActivePane = useCallback(
(chatId: string) => openChatInPane(activePaneIdxRef.current, chatId),
@@ -120,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) {
@@ -153,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
@@ -402,6 +419,16 @@ function SessionInner({ sessionId }: { sessionId: string }) {
onAddPane={addPaneAndSwitch}
disabled={panes.length >= MAX_PANES}
/>
{activeIsCoder && activePane && panes.length > 1 && (
<button
type="button"
onClick={() => removePane(activePaneIdx)}
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded-full bg-muted/40 hover:bg-muted/70 text-foreground shrink-0"
aria-label="Close pane"
>
<X size={16} />
</button>
)}
</div>
</>
) : (
@@ -495,6 +522,11 @@ function SessionInner({ sessionId }: { sessionId: string }) {
session={session}
project={project}
onAddPane={addPaneAndSwitch}
onCoderConnectedChange={(paneId, connected) =>
setCoderConnected((prev) =>
prev[paneId] === connected ? prev : { ...prev, [paneId]: connected },
)
}
/>
)}
</div>

View File

@@ -15,9 +15,12 @@ WORKDIR /build
RUN apk add --no-cache git ca-certificates build-base
# Build codecontext from the v3.2.1 tag.
# Build codecontext from the boocode-ts fork (has .codecontextignore support).
# Source is staged into the build context by the pre-build step:
# tar -czf codecontext/fork.tar.gz -C /opt/forks/codecontext .
# CGO is required: codecontext binds tree-sitter via cgo.
RUN git clone --depth=1 --branch v3.2.1 https://github.com/nmakod/codecontext.git /build/codecontext
COPY fork.tar.gz /build/fork.tar.gz
RUN mkdir -p /build/codecontext && tar -xzf /build/fork.tar.gz -C /build/codecontext
WORKDIR /build/codecontext
RUN CGO_ENABLED=1 GOOS=linux go build -o /build/codecontext-bin ./cmd/codecontext

View File

@@ -191,7 +191,7 @@ func startChild() error {
// initial scan target — codecontext rebuilds the graph against whatever
// target_dir each call carries, so this is just a valid bootstrap path
// (the default "." is the alpine root and trips on transient /proc fds).
child = exec.Command("codecontext", "mcp", "--target=/opt/projects", "--watch=true")
child = exec.Command("codecontext", "mcp", "--target=/opt/projects", "--watch=true", "--respect-gitignore")
var err error
childStdin, err = child.StdinPipe()
if err != nil {

View File

@@ -258,3 +258,67 @@ Output:
- Data flow map (entry → transform → output)
- Conventions observed
- Areas that need deeper investigation
## Planner
---
temperature: 0.6
top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
steps: 10
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes]
description: Produces actionable step plans from requirements. Read-only — never modifies files.
---
You produce actionable step plans. You do not modify files.
Process:
1. Restate the goal. Confirm scope and constraints.
2. Read the relevant code areas with view_file, list_dir, grep. Understand the current state before planning.
3. Identify dependencies between steps. Order them so each step has its prerequisites met.
4. Estimate complexity per step (small / medium / large).
5. Call out risks and assumptions that could invalidate the plan.
Output:
- Goal: one line
- Prerequisites: what must be true before starting
- Steps: numbered, each with file paths, what changes, and acceptance criteria
- Risks: what could go wrong, how to detect it
- Verification: how to confirm the plan succeeded (test commands, type checks, manual checks)
Rules:
- Every step must be independently verifiable.
- Do not produce code. Describe what to change, not the change itself.
- If a step affects more than 3 files, break it into sub-steps.
- Flag any step that requires a database migration or env var change.
## Builder
---
temperature: 0.6
top_p: 0.95
top_k: 20
min_p: 0.0
presence_penalty: 0.0
steps: 50
tools: [find_files, get_codebase_overview, get_dependencies, get_file_analysis, get_framework_analysis, get_semantic_neighborhoods, get_symbol_info, grep, list_dir, search_symbols, view_file, watch_changes, request_read_access, view_truncated_output, ask_user_input, git_status, get_blast_radius, get_hot_files, get_middleware, get_routes, edit_file, create_file, delete_file, apply_pending, rewind]
description: Implements changes using read and write tools. Routes all writes through pending changes.
---
You implement. Read the code, make the changes, verify they work.
Process:
1. Read the target files and understand the current state.
2. Use grep and get_dependencies to find all call sites and dependents.
3. Make changes via edit_file / create_file. All writes queue in pending_changes.
4. Review pending changes before calling apply_pending.
5. After applying, verify: read the modified files, check that the change is correct.
Rules:
- All file modifications go through edit_file / create_file / delete_file. Never bypass pending_changes.
- Read before writing. Understand what exists before changing it.
- Match existing code conventions: naming, imports, error handling patterns.
- One logical change per edit. Do not bundle unrelated changes.
- If a change breaks an import or type, fix it in the same batch before applying.
- Use rewind if a batch of changes is wrong. Do not apply broken changes.
- When done, state what changed and what the user should verify (type check, test, manual check)

View File

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

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

View File

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

View File

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

View File

@@ -0,0 +1,94 @@
# v2.6 Tasks — Persistent agent sessions
Phased so each phase is independently shippable and smoke-testable. Phase 1
(OpenCode server) delivers the most value on the cleanest API; goose/qwen warm
ACP follows; hardening last.
## Phase 0 — Foundations (no behavior change)
- [ ] 0.1 Add `session_worktrees` + `agent_sessions` tables (per `(session_id, agent)`)
to `apps/coder/src/schema.sql` (idempotent; see design §3).
- [ ] 0.2 Define `AgentBackend` / `AgentSessionHandle` interface + normalized `onEvent`
event union (reuse shapes from `acp-dispatch.ts`).
- [ ] 0.3 Scaffold `agent-pool.ts` with lazy get-or-create keyed by `(chat, agent)`,
health, `dispose()`; wire `app.addHook('onClose')` to dispose alongside dispatcher `stop()`.
## Phase 1 — OpenCode server backend (multi-turn, warm)
- [ ] 1.1 Add `@opencode-ai/sdk` to `apps/coder/package.json`; pin to installed opencode major.
- [ ] 1.2 `backends/opencode-server.ts`: spawn `opencode serve` once (random
`OPENCODE_SERVER_PASSWORD`, allocated port), `createOpencodeClient`, wait for ready line.
- [ ] 1.3 Single `/event` SSE read loop; demux by `properties.sessionID`; map
`message.part.delta`/`updated` (text + reasoning) + tool parts to `onEvent`.
- [ ] 1.4 Port Paseo `streamedPartKeys` reasoning dedup (delta vs final part).
- [ ] 1.5 `ensureSession`: reuse the `(chat, opencode)` `agent_sessions` row if present
(resume on switch-back), else `client.session.create()` → store `agent_session_id`.
- [ ] 1.6 `prompt`: send via SDK with `x-opencode-directory` = session worktree + `model`.
- [ ] 1.7 Dispatcher: when `agent==='opencode'`, route to pool backend instead of
`dispatchViaAcp`; keep broker frames + `persistExternalAgentTurn` identical.
- [ ] 1.8 Persistent worktree: chat-keyed `createWorktree` (shared across agents);
capture base commit in `session_worktrees`; reuse across turns and agents.
- [ ] 1.9 Per-session concurrency: replace global `running` with `Map<sessionId,Promise>`;
`poll()` skips sessions with an in-flight turn.
- [ ] 1.10 Per-turn diff → supersede prior `pending_changes` row for the session (latest-wins).
- [ ] **Smoke 1:** two messages in one opencode chat → same `agent_session_id`, same worktree,
no second `createWorktree`; agent references turn-1 edits; reasoning shows once; turn-2 faster.
## Phase 1 (UX) — Attribution & switch affordances (design §9)
- [ ] U.1 Stamp `pending_changes.agent` at queue time (worktree path → task agent;
native write tools → `'boocode'`; manual RightRail create → NULL).
- [ ] U.2 Add `agent` to `listPending` response + frontend `PendingChange` type.
- [ ] U.3 Extract `providerIcon()` to a shared helper; DiffPanel renders an agent badge
per row + a "Changes from X, Y" note when the pending set spans >1 agent (§9a).
- [ ] U.4 `GET /api/sessions/:id/agent-sessions` route + `api.coder.agentSessions` +
`useAgentSessions(sessionId)` (refetch on `message_complete`) (§9b).
- [ ] U.5 `AgentComposerBar` optional `sessionId` prop → resumed / history / new-session
chip beside the Provider picker; hidden on fresh chats and other callers (§9b).
- [ ] **Smoke U:** stage edits with opencode then boocode → DiffPanel badges each row to the
right agent; composer shows "resumed" when re-selecting opencode, "new session" for goose.
## Phase 2 — Warm ACP backend (goose, qwen)
- [ ] 2.1 `backends/warm-acp.ts`: persistent spawn + `ClientSideConnection`; `initialize` +
`session/new` once; reuse `acp-dispatch.ts` `handleSessionUpdate`.
- [ ] 2.2 `prompt`: `session/prompt` on the warm connection per turn; per-turn abort signal only.
- [ ] 2.3 Child supervision: detached lifetime, exit handler marks `status='crashed'`.
- [ ] 2.4 Dispatcher routes `goose`/`qwen` to warm backend; keep one-shot fallback for arena/MCP
(or opt those into pool too — decide in review).
- [ ] **Smoke 2:** two messages in a goose chat reuse the same process + ACP session + worktree;
reasoning still renders; no per-turn respawn.
- [ ] **Smoke 2b (switch round-trip):** opencode → boocode → opencode in one chat — opencode
resumes the SAME `agent_session_id` (memory intact), boocode saw opencode's turns as
history, all three shared the one worktree, and no agent was locked to the chat.
## Phase 3 — Lifecycle hardening
- [ ] 3.1 Idle TTL eviction keyed per `(chat, agent)`; reattach-on-next-turn from `agent_sessions`.
- [ ] 3.2 Crash recovery: opencode server restart recreates sessions; ACP re-`session/new`.
- [ ] 3.3 Chat close/archive hook → `closeSession` for every `(chat, agent)` + remove the
shared `session_worktrees` row + worktree; mark agent rows `status='closed'`.
- [ ] 3.4 Orphan worktree reaper (extend periodic sweeper) + max-live-worktrees LRU cap.
- [ ] 3.5 Re-baseline worktree diff after `apply_pending`.
- [ ] 3.6 Reconnect test: restart BooCoder mid-session → next turn reattaches/recreates cleanly.
- [ ] 3.7 Staging-boundary hint in DiffPanel (§9c): muted one-liner when the selected
provider can't see another agent's unapplied worktree edits (derived from per-change
`agent` + current provider; no new state).
## Tests
- [ ] T.1 `agent-pool` unit: get-or-create, idle evict, dispose drains in-flight (DB-opt-in pattern).
- [ ] T.2 opencode SSE demux + reasoning dedup unit (fixture event stream).
- [ ] T.3 per-session concurrency: two sessions run concurrently, one session serializes.
## Docs
- [ ] D.1 Update `CLAUDE.md` (BooCoder dispatch section) + `BOOCODER.md` health/contract.
- [ ] D.2 Note opencode `@opencode-ai/sdk` dep + `OPENCODE_SERVER_PASSWORD` env in env docs.
- [ ] D.3 `CHANGELOG.md` entry on tag (`v2.6.0-persistent-agent-sessions`).
## Build / deploy gate
- [ ] B.1 `pnpm -C apps/server build && pnpm -C apps/coder build` clean.
- [ ] B.2 `pnpm -C apps/server test` (+ DB-opt-in) green.
- [ ] B.3 Deploy: `sudo systemctl restart boocoder`; `curl :9502/api/health` reports tool count.