Compare commits
24 Commits
v2.5.6-pro
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 937920df06 | |||
| e05469c6ae | |||
| 0e026be5f8 | |||
| 315cdd23e2 | |||
| 6d24726c3a | |||
| 1bbeaf95c7 | |||
| e30a9e8b23 | |||
| 140ff26204 | |||
| a97293b5d9 | |||
| 63adb218e6 | |||
| d0334ca544 | |||
| 024ffc0b92 | |||
| 691eef1b30 | |||
| e92c51578d | |||
| 6d03690a65 | |||
| 21384cce5b | |||
| 920f8b75a6 | |||
| e83d9b7d5b | |||
| f302969c71 | |||
| 2d997ecb6c | |||
| dc3859975d | |||
| 23a33e893a | |||
| 8bf86ecb92 | |||
| fe52250d78 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -16,5 +16,5 @@ data/*
|
|||||||
!data/AGENTS.md
|
!data/AGENTS.md
|
||||||
!data/skills/
|
!data/skills/
|
||||||
!data/mcp.json
|
!data/mcp.json
|
||||||
!data/coder-providers.json
|
!data/coder-providers.example.json
|
||||||
codecontext/fork.tar.gz
|
codecontext/fork.tar.gz
|
||||||
|
|||||||
78
BOOCODER.md
78
BOOCODER.md
@@ -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.
|
- 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.
|
- 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.
|
- When reporting counts (tools, tests, files, routes, lines), derive the number from a command (`grep -c`, `wc -l`, test runner output) — not from memory or approximation.
|
||||||
|
|
||||||
|
## Provider lifecycle (v2.3)
|
||||||
|
|
||||||
|
BooCoder's coding agents are a **config-backed registry**: built-ins live in `provider-registry.ts`, and `data/coder-providers.json` layers overrides + custom entries on top. Registration ≠ installation — the config lists what you *want*; a probe reports what's *ready*.
|
||||||
|
|
||||||
|
### Config file: `data/coder-providers.json`
|
||||||
|
|
||||||
|
Resolved from `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`; dev/host path `/opt/boocode/data/coder-providers.json`). It is **gitignored** — it's live runtime config that the coder reads *and writes* (UI toggles `PATCH` it), so tracking it would churn `git status`. The tracked reference is `data/coder-providers.example.json`; copy it to `coder-providers.json` to seed overrides. A missing file, invalid JSON, or a schema mismatch all fall back to built-ins-only — loading never throws at startup.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"goose": { "enabled": false },
|
||||||
|
"amp-acp": {
|
||||||
|
"extends": "acp",
|
||||||
|
"label": "Amp",
|
||||||
|
"description": "ACP wrapper for Amp",
|
||||||
|
"command": ["amp-acp"],
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-provider override fields (all optional):
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `extends` | `"acp"` — required for a NEW (custom) provider; built-in overrides omit it |
|
||||||
|
| `label` | Display name (required for custom) |
|
||||||
|
| `description` | Sub-label shown in the picker / settings |
|
||||||
|
| `command` | `[binary, ...args]` to spawn (required for custom; overrides a built-in's default argv) |
|
||||||
|
| `env` | Extra env vars merged into the spawn |
|
||||||
|
| `enabled` | Default `true`; `false` hides it from the composer |
|
||||||
|
| `order` | UI sort key |
|
||||||
|
| `models` / `additionalModels` | Replace / merge onto the discovered model list |
|
||||||
|
|
||||||
|
A PATCH to one provider id **replaces that id's override object wholesale** (per-id shallow merge), so to flip a single field keep the rest; a `null` value for an id deletes its override (reverts to the built-in default).
|
||||||
|
|
||||||
|
### Refresh contract
|
||||||
|
|
||||||
|
The snapshot is cached and a provider's cold ACP probe (tier-2) is **skipped** while `available_agents.last_probed_at` is younger than `PROVIDER_PROBE_TTL_MS` (default `86400000` = 24h). Opening the composer is therefore fast and does not re-probe. To force a cold re-probe (after installing a CLI or editing models): **`POST /api/providers/refresh`** (the Refresh button in the Providers settings tab), which clears the cache and re-probes.
|
||||||
|
|
||||||
|
### Enable / disable
|
||||||
|
|
||||||
|
Two ways:
|
||||||
|
- **Settings → Providers tab** — open the sidebar → **Settings** → **Providers**: toggle a provider on/off, refresh it, or open its diagnostic. (Earlier builds exposed a gear in the composer; that control was moved into Settings.)
|
||||||
|
- **Edit the config** (`"enabled": false`) then `POST /api/providers/refresh`.
|
||||||
|
|
||||||
|
A **disabled** provider leaves the composer's provider picker but stays listed in the Providers tab (status "Disabled") so you can re-enable it. **Native `boocode` is always-on** — an `enabled:false` on it is ignored (with a warn log) and it is never rendered as toggleable.
|
||||||
|
|
||||||
|
### Adding a custom ACP provider
|
||||||
|
|
||||||
|
- **Catalog modal**: Providers tab → **Add provider** → pick an entry → it PATCHes the config (`extends:'acp'` + label + command, enabled) and refreshes that provider.
|
||||||
|
- **Hand-edit** `data/coder-providers.json`: add an id with `extends:'acp'`, `label`, and `command`, then `POST /api/providers/refresh`.
|
||||||
|
|
||||||
|
Either way, **adding to config does NOT install the binary.** Until the CLI is on `PATH` the provider shows **"Not installed"** (status `unavailable`) and does not appear in the composer picker.
|
||||||
|
|
||||||
|
### Known limitation — subset refresh
|
||||||
|
|
||||||
|
`POST /api/providers/refresh` accepts an optional `{ "providers": ["id", ...] }` body and returns a `refreshed` count scoped to that subset — **but the underlying cold re-probe currently covers ALL installed providers**, not just the requested subset. True per-provider force is a future change (it needs a snapshot-internal parameter). This is intentional for now, not a bug: a subset refresh still re-probes everything; only the reported count is scoped.
|
||||||
|
|
||||||
|
### Deploy + smoke
|
||||||
|
|
||||||
|
Two deploy targets:
|
||||||
|
- **Routes (host service):** `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`
|
||||||
|
- **Web UI (container):** `docker compose up --build -d boocode`
|
||||||
|
|
||||||
|
Green gate (verified across phases 1–5): `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)
|
||||||
|
```
|
||||||
|
|||||||
40
CHANGELOG.md
40
CHANGELOG.md
@@ -2,6 +2,46 @@
|
|||||||
|
|
||||||
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.
|
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||||
|
|
||||||
|
## v2.6.1-phase1-opencode — 2026-05-30
|
||||||
|
|
||||||
|
v2.6 Phase 1: opencode runs as a warm HTTP server (`apps/coder/src/services/backends/opencode-server.ts`) — one `opencode serve` per BooCoder process, one opencode session per BooCode session resumed across turns via the new `agent_sessions` table, with a single SSE read loop, reasoning dedup ported from Paseo, an inactivity watchdog, and a stale-session guard (crashed-not-resumed + a `config_hash` fingerprint over `opencode_server|<model>`, deliberately excluding the ephemeral server port so cross-restart resume survives). Builds on the `v2.6.0-phase0-foundations` schema/interface scaffold. The batch's hard-won fixes: opencode streams `session.next.*` events (not `message.part.*`), and `event.subscribe()` must pass the session's worktree `directory` or events route to the server CWD and turns come back empty; model strings must be `llama-swap/`-prefixed and present in opencode's own config, with `agent-probe` now populating `available_agents.models` via `mergeLlamaSwap` so the frontend stops sending an empty model; `session_worktrees`/`agent_sessions` FKs are `ON DELETE CASCADE` so session deletion no longer 500s. Also bundled: dcp-message-id tag stripping from opencode text output, a reopen-closed-pane control, the `[+]`/split-pane button separation, auto-name using the session's loaded model, and a `systematic-debugging` slash command. Smoke 1 verified end-to-end (two turns, session reuse, turn 2 ~9x faster). Known Phase 1 limit: one SSE stream scoped to the most-recent session's directory — concurrent opencode sessions in different worktrees collide (warns; per-session SSE is Phase 2).
|
||||||
|
|
||||||
|
## v2.5.15-acp-path-guard — 2026-05-29
|
||||||
|
|
||||||
|
Security fix + repo hygiene. Fixes a path-traversal in the ACP filesystem bridge (`acp-client-fs.ts`, flagged by the automated push security review): the worktree guard used an unbounded `startsWith(resolve(worktreePath))`, so a sibling path sharing the worktree as a string prefix (`<worktree>-evil/…`) escaped the scope — and `writeWorktreeTextFile` writes to disk directly (no `pending_changes` gate), so a confused/buggy ACP agent could write outside its worktree. Now uses a separator-bounded check matching `write_guard.ts` (`resolve()` + `startsWith(root + sep)` / `=== root`) via a shared `resolveInWorktree`, with a regression test covering `../` traversal and the sibling-prefix bug. Symlink-swap/`O_NOFOLLOW` hardening was intentionally skipped — consistent with `write_guard`'s no-realpath stance, and the agent already runs with host FS access so this is a containment guard, not a trust boundary. Separately, stops tracking the live `data/coder-providers.json` (it's runtime config the UI reads *and writes* on provider toggles, which churned `git status`) — it's now gitignored with a tracked `data/coder-providers.example.json` reference; the loader falls back to built-ins-only when the live file is absent. The provider-type duplication (coder ↔ web) stays guarded by the existing text-identity `provider-types-parity.test.ts` — a shared package was considered and declined (drift is already prevented; not worth the Docker/build-order risk at solo scale).
|
||||||
|
|
||||||
|
## v2.5.14-claude-md — 2026-05-29
|
||||||
|
|
||||||
|
Docs-only — CLAUDE.md session-learnings update, no code. Adds gotchas surfaced while shipping the v2.3 provider-lifecycle batch: the host `boocoder.service` keeps running the old process after `pnpm -C apps/coder build` (stale-process tell = new routes 404 while old routes 200, restart don't re-debug); the `boocode` container `build: .` deploys the working tree, so web edits are live on the Vite dev server but not production until `docker compose up --build -d boocode`; `PATCH /api/providers/config` replaces a provider's override wholesale (send `{...existing, enabled}` or a custom ACP entry's command is wiped) and `data/coder-providers.json` is live config not to be committed as code; external agents dispatch one-shot with no context/token tracking (only native `boocode` tracks ctx; OpenCode-as-server is the unshipped `v2-6-persistent-agent-sessions` plan); the `ui/` primitive inventory with `button role=switch` / Dialog fallbacks for the absent switch/sheet; and the mobile Dialog-with-list scroll-containment recipe. Also backfills previously-uncommitted doc bullets for the `v2.5.7`–`v2.5.11` coder work (provider-type parity test, async ACP command discovery, AgentComposerBar `installed` filter, provider-registry path disambiguation).
|
||||||
|
|
||||||
|
## v2.5.13-provider-lifecycle-phase5 — 2026-05-29
|
||||||
|
|
||||||
|
Closeout of the v2.3 provider-lifecycle batch — the web UI (Phase 5) plus docs (Phase 6). Provider management moved into **Settings → Providers**: a tab listing every registered provider with a status badge (Available / Disabled / Not installed / Error / Loading), an enable/disable toggle, a per-provider refresh, and a plaintext diagnostic; toggling sends the provider's *full* override (preserving a custom ACP entry's command under the wholesale-replace PATCH merge) then refetches the snapshot. The composer's provider picker now filters to `enabled && (status === 'ready' || 'loading')`, so disabled and unavailable providers drop out of the picker and are managed only in settings (native `boocode` always shows). A curated ACP catalog (`apps/web/src/data/acp-provider-catalog.ts`) + `AddProviderModal` register custom providers via `PATCH /api/providers/config` then a subset refresh, and the web client gained `getProvidersConfig` / `patchProvidersConfig` / `refreshProviders` / `getProviderDiagnostic`. Two mobile fixes ship alongside: the Settings pane is now reachable on phones (opening it pushes `?pane=` atomically so the mobile URL-sync effect keeps it active instead of snapping back to the chat pane), and the Add-provider modal caps to the viewport with a single `overscroll-contain` scroll region so the list scrolls instead of dragging the whole modal. This completes the arc begun in `v2.5.4-provider-lifecycle-phase1` (config-backed registry over the built-ins) → `v2.5.5-provider-lifecycle-phase2` (loading/unavailable snapshot lifecycle + tier-2 probe TTL gate) → `v2.5.6-provider-lifecycle-phase3` (generic `resolveLaunchSpec` ACP dispatch) → `v2.5.12-provider-lifecycle-phase4` (config GET/PATCH, subset refresh, diagnostic HTTP API). Docs landed in `BOOCODER.md` (config file, refresh contract, enable/disable, custom ACP, the honest subset-refresh known limitation) and `docs/DEFERRED-WORK.md` §2 is marked addressed; the remaining Tier-2 follow-ups (WS `provider_snapshot_updated` frame, `available_agents.enabled` column, shared types package, MCP provider tools) stay deferred.
|
||||||
|
|
||||||
|
## v2.5.12-provider-lifecycle-phase4 — 2026-05-29
|
||||||
|
|
||||||
|
Phase 4 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §6): the HTTP API to read, patch, refresh, and diagnose providers. `routes/providers.ts` gains `GET /api/providers/config` (the raw loaded `CoderProvidersFile`), `PATCH /api/providers/config` (a partial providers map — an id's override object is replaced wholesale, a `null` value deletes it), an optional `{ providers?: string[] }` body on `POST /api/providers/refresh` (the `refreshed` count reflects the requested subset; the force probe itself still covers all installed providers, since per-provider force is a snapshot-internal change left to a later phase), and `GET /api/providers/:id/diagnostic` returning JSON `{ diagnostic: string }` — a read-only report (resolved def, install_path, last_probed_at, enabled, `which` availability, last cached probe error) with no probe spawn. PATCH correctness is the whole story: the order is validate→save→reload→clear, a malformed body or an invalid merged config returns 422 without writing the file, and a `save()` failure returns 500 without reloading the registry or clearing the snapshot cache, so on-disk and in-memory state can never diverge. New pure `mergeProviderConfigPatch` + `ProviderConfigPatchSchema` in `provider-config.ts`, a read-only `peekSnapshotEntry` cache accessor (source of the diagnostic's last-error — no probe/cache logic change), and a new `provider-diagnostic.ts` formatter. The web client gains `api.coder.getProvidersConfig` / `patchProvidersConfig` / `refreshProviders(providers?)` / `getProviderDiagnostic`, with mirrored `ProviderOverride` / `CoderProvidersFile` / `ProviderConfigPatch` types; the existing `/api/coder/*` proxy blanket-forwards the new routes with no change. +28 tests (134 coder total: pure merge/validate, the diagnostic formatter, and `app.inject` route tests proving the 422-no-write and save-fail-no-divergence guards). The diagnostic returns JSON rather than the §8 plaintext so it flows through the JSON `request` client helper (reconciling design §6.4's `{ diagnostic }` with §8's string report). No UI (Phase 5). Builds on `v2.5.6-provider-lifecycle-phase3`.
|
||||||
|
|
||||||
|
## v2.5.11-claude-skill-discovery — 2026-05-29
|
||||||
|
|
||||||
|
Surface Claude Code's real enabled commands + plugin skills in the coder slash menu, with icons separating commands from plugin skills. New `claude-command-discovery.ts` reads (user-global scope) `~/.claude/commands/*.md` plus every enabled plugin in `~/.claude/settings.json:enabledPlugins` — each plugin's user-scope install path contributes `skills/<name>/SKILL.md` (kind `skill`) and `commands/*.md` (kind `command`), parsed from frontmatter, bare names, deduped. The snapshot's claude branch discovers these **live** (claude is PTY, no ACP probe; the snapshot cache rate-limits the fs reads). The `/` menu now renders up to three icon'd groups: **`<agent> commands`** (Terminal), **`<agent> skills`** (Puzzle — claude's plugin skills / opencode is all commands), and **BooCoder skills** (Sparkles), via a new optional `icon` on `SlashCommandGroup`. `AgentCommand` gains a `kind` field, added identically to the coder and web copies (the `provider-types-parity` test enforces it); `mergeCommandsByName` is now generic so it preserves the tag. Invocation is unchanged — picking a claude command/skill sends `/name` to claude (PTY), which executes it. Project-local plugins + `<cwd>/.claude/commands` deferred. BooChat unaffected (flat skills). Smoke-test the claude skill slash-execution on the host.
|
||||||
|
|
||||||
|
## v2.5.10-opencode-live-commands — 2026-05-29
|
||||||
|
|
||||||
|
Surface opencode's real (live ACP) command set in the coder slash menu without needing a dispatch. Two fixes: (1) the cold ACP probe (`acp-probe.ts`) captured `available_commands` but read `probedCommands` synchronously right after `newSession` — racing opencode's async `available_commands_update` notification, so it captured **zero** and only the 7-item static manifest showed. The probe now waits briefly (poll up to 3s for the first batch + a 300ms settle, capped under the 30s probe timeout) so the commands are actually captured. (2) Captured commands are persisted to a new `available_agents.commands` JSONB column and served (merged with the manifest) on the tier-2-probe-skip path, so the agent's discovered commands survive once the model list is warm and show without a dispatch. Boot warms this via the `force: true` startup snapshot. apps/coder only (probe + schema + snapshot). Caveat: depends on opencode emitting `available_commands_update` on session creation rather than only after a prompt — to be confirmed on the host. Claude (PTY) disk/plugin discovery deferred.
|
||||||
|
|
||||||
|
## 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
|
## 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`.
|
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`.
|
||||||
|
|||||||
19
CLAUDE.md
19
CLAUDE.md
@@ -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.
|
- **`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/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/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).
|
- **`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).
|
||||||
- **`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.
|
- **`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.
|
||||||
- **`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/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`.
|
- **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`.
|
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
|
||||||
@@ -80,12 +80,19 @@ Route registration: all routes registered in `index.ts` via `register*Routes(app
|
|||||||
- Write-capable coding agent. Runs as a **systemd service on the host** (`boocoder.service`), NOT in Docker. Fastify server at port 9502, connects to postgres at `127.0.0.1:5500`.
|
- 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.
|
- **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`.
|
- 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 })`.
|
- 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).
|
- 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.
|
- `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).
|
- 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`.
|
- 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.
|
- `apps/coder/web/` is a STANDALONE fallback SPA served at `:9502` directly. The PRIMARY BooCoder frontend is the `CoderPane` in BooChat's SPA (`apps/web/src/components/panes/CoderPane.tsx`), accessible via the "Coder" pane in the workspace at `code.indifferentketchup.com`. Both exist; the pane is what Sam uses.
|
||||||
|
- **Provider snapshot lifecycle** (`apps/coder/src/services/`): `provider-config.ts` (Zod config, never-throws on bad input) → `provider-config-registry.ts` (`buildResolvedRegistry`, singleton) → `provider-snapshot.ts` (two-tier probe: tier-1 fast presence, tier-2 cold ACP probe skipped unless force / stale `PROVIDER_PROBE_TTL_MS` 24h / dbEmpty; cached). Verify live: `curl http://100.114.205.53:9502/api/providers/snapshot` — returns providers + models + commands, the exact shape `AgentComposerBar` renders.
|
||||||
|
- `PATCH /api/providers/config` replaces a provider id's override object **wholesale** (per-id shallow merge) — to flip one field send `{...existing, enabled}`, or a custom ACP entry's `command`/`label` is wiped and it drops out of the resolved registry. `data/coder-providers.json` is **gitignored** (it's live runtime config — the coder reads AND writes it on UI toggles); the tracked reference is `data/coder-providers.example.json`. The loader falls back to `{providers:{}}` (built-ins only) when the live file is absent, so a fresh checkout needs no copy.
|
||||||
|
- **opencode** runs as a warm HTTP server (v2.6 Phase 1, `services/backends/opencode-server.ts` — `opencode serve` per BooCoder process, one opencode session per BooCode session, resumed via `agent_sessions`). goose/qwen/claude still dispatch **one-shot** ACP/PTY with no ctx/token usage; only native `boocode` (llama-swap engine) tracks ctx. Paseo's per-provider native clients (design §12) deliberately not ported.
|
||||||
|
- **opencode SSE** (`opencode-server.ts`): live streaming arrives as `session.next.text.delta` / `session.next.reasoning.delta` / `session.next.tool.{called,success,failed}` — NOT `message.part.*` (those are terminal/post-hoc). `client.event.subscribe({ directory })` MUST pass the session's worktree directory; omit it and opencode scopes events to the server's `process.cwd()` → zero session events (empty turns, 180s watchdog timeout). One SSE stream at a time scoped to the last session's dir — concurrent opencode sessions in different worktrees collide (known Phase 1 limit, warns). Turn completes on `session.idle`; `promptAsync` is fire-and-forget (204).
|
||||||
|
- **opencode model strings** must be provider-prefixed (`llama-swap/<model>`) AND exist in `~/.config/opencode/opencode.json` `provider.llama-swap.models` — not merely loadable by llama-swap. `parseModel` infers `llama-swap/` for a bare id; the dispatcher coalesces empty→DEFAULT_MODEL then prefixes. `agent-probe` populates opencode's `available_agents.models` via `mergeLlamaSwap` (fetches `/v1/models`); empty model list → frontend sends `''` → no inference (`input:0`, empty turn).
|
||||||
|
- **agent_sessions resume**: `config_hash = sha256('opencode_server|<model>')` — must NOT include the server port (random per boot; including it breaks cross-restart resume). `session_worktrees` + `agent_sessions` FKs to `sessions(id)` are `ON DELETE CASCADE` (else DELETE /api/sessions/:id 500s on FK violation). The `@opencode-ai/sdk` v2 client takes flattened params (`{sessionID, directory, parts, model:{providerID,modelID}}`), imports `createOpencodeClient` from `@opencode-ai/sdk/v2/client`.
|
||||||
|
|
||||||
### Frontend (`apps/web/src/`)
|
### Frontend (`apps/web/src/`)
|
||||||
|
|
||||||
@@ -145,6 +152,7 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
|||||||
- Tag naming: `vMAJOR.MINOR.PATCH-slug` (e.g. `v1.13.13-ws-publish`). Monotonic per minor — the slug describes the batch's content so the tag name alone is enough to recall what shipped. No letter suffixes (`-a`/`-b`), no pseudo-ranges (`v1.11.x`), no slug-only sub-versions sharing a number (`v1.13.15-tools` + `-openspec` + `-agentlint` — split into sequential patches instead).
|
- 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 3–6 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.
|
- `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 3–6 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).
|
- 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`.
|
- 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.
|
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
||||||
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. `psql` is not on the host PATH — for an interactive query use `docker exec boocode_db psql -U boocode -d boochat -c "..."`. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
|
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. `psql` is not on the host PATH — for an interactive query use `docker exec boocode_db psql -U boocode -d boochat -c "..."`. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
|
||||||
@@ -172,10 +180,12 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
|||||||
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
|
- 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.
|
- **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.
|
- 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.
|
- `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.
|
- 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`.
|
- `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.
|
- 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.
|
- 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.
|
- **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`.
|
- **Sentinels** are `role='system'` rows with structured `metadata.kind` (`cap_hit`, `doom_loop`). UI-only — `buildMessagesPayload` strips them via `isAnySentinel` so the LLM never sees them. A new kind requires arms in `MessageMetadata` in BOTH `apps/server/src/types/api.ts` AND `apps/web/src/api/types.ts`, plus a render branch in `apps/web/src/components/MessageBubble.tsx`.
|
||||||
@@ -191,6 +201,9 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
|||||||
- **CoderPane uses ChatInput** (`components/panes/CoderPane.tsx`): shares the same `ChatInput` component as BooChat for full parity — attachments, paste-to-chip, auto-grow textarea, queued messages during send. CoderPane's `sendOneMessage` is the send callback; queued messages drain via `useEffect` when `sending` goes false.
|
- **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`).
|
- **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.
|
- **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.
|
- **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.
|
- **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).
|
- **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).
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@agentclientprotocol/sdk": "^0.22.1",
|
"@agentclientprotocol/sdk": "^0.22.1",
|
||||||
"@boocode/server": "workspace:*",
|
"@boocode/server": "workspace:*",
|
||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^7.0.4",
|
||||||
|
"@opencode-ai/sdk": "~1.15.0",
|
||||||
"@fastify/websocket": "^10.0.1",
|
"@fastify/websocket": "^10.0.1",
|
||||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { registerProviderRoutes } from './routes/providers.js';
|
|||||||
import { registerWebSocket } from './routes/ws.js';
|
import { registerWebSocket } from './routes/ws.js';
|
||||||
// Phase 4: dispatcher + agent probe
|
// Phase 4: dispatcher + agent probe
|
||||||
import { createDispatcher } from './services/dispatcher.js';
|
import { createDispatcher } from './services/dispatcher.js';
|
||||||
|
import { agentPool } from './services/agent-pool.js';
|
||||||
import { probeAgents } from './services/agent-probe.js';
|
import { probeAgents } from './services/agent-probe.js';
|
||||||
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
|
import { getProviderSnapshot, persistProbedModels } from './services/provider-snapshot.js';
|
||||||
import { setPermissionHooks } from './services/permission-waiter.js';
|
import { setPermissionHooks } from './services/permission-waiter.js';
|
||||||
@@ -178,7 +179,12 @@ async function main() {
|
|||||||
// Phase 4: dispatcher — polls tasks table and runs inference
|
// Phase 4: dispatcher — polls tasks table and runs inference
|
||||||
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
|
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
|
||||||
dispatcher.start();
|
dispatcher.start();
|
||||||
app.addHook('onClose', () => dispatcher.stop());
|
app.addHook('onClose', async () => {
|
||||||
|
// stop() first so in-flight dispatcher turns settle, then drain the pool.
|
||||||
|
// Pool is empty in Phase 0 (nothing spawns yet) — dispose() is inert.
|
||||||
|
await dispatcher.stop();
|
||||||
|
await agentPool.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
// Register routes
|
// Register routes
|
||||||
registerMessageRoutes(app, sql, broker, inferenceApi);
|
registerMessageRoutes(app, sql, broker, inferenceApi);
|
||||||
|
|||||||
211
apps/coder/src/routes/__tests__/providers.routes.test.ts
Normal file
211
apps/coder/src/routes/__tests__/providers.routes.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,29 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { z } from 'zod';
|
||||||
import type { Sql } from '../db.js';
|
import type { Sql } from '../db.js';
|
||||||
import type { Config } from '../config.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 {
|
export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
|
||||||
app.get<{ Querystring: { cwd?: string } }>('/api/providers/snapshot', async (req, _reply) => {
|
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);
|
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();
|
clearProviderSnapshotCache();
|
||||||
const entries = await getProviderSnapshot(sql, config, undefined, true);
|
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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ const SkillInvokeBody = z.object({
|
|||||||
pane_id: z.string().min(1).max(200),
|
pane_id: z.string().min(1).max(200),
|
||||||
skill_name: z.string().min(1),
|
skill_name: z.string().min(1),
|
||||||
user_message: z.string().max(64_000).nullable().optional(),
|
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 {
|
interface InferenceApi {
|
||||||
@@ -39,9 +45,9 @@ export function registerSkillRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = req.params.sessionId;
|
const sessionId = req.params.sessionId;
|
||||||
const { pane_id, skill_name } = parsed.data;
|
const { pane_id, skill_name, provider, model, mode_id, thinking_option_id } = parsed.data;
|
||||||
const sessionRows = await sql<{ id: string }[]>`
|
const sessionRows = await sql<{ id: string; project_id: string }[]>`
|
||||||
SELECT id FROM sessions WHERE id = ${sessionId}
|
SELECT id, project_id FROM sessions WHERE id = ${sessionId}
|
||||||
`;
|
`;
|
||||||
if (sessionRows.length === 0) {
|
if (sessionRows.length === 0) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
@@ -69,6 +75,31 @@ export function registerSkillRoutes(
|
|||||||
return { error: 'unknown_skill', message: `unknown skill: ${skill_name}` };
|
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, {
|
const { result, toolCall } = await runSkillInvokeTransaction(sql, {
|
||||||
sessionId,
|
sessionId,
|
||||||
chatId,
|
chatId,
|
||||||
|
|||||||
@@ -66,12 +66,70 @@ 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 models JSONB DEFAULT '[]'::jsonb;
|
||||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
|
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
|
||||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
|
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.
|
-- 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 mode_id TEXT;
|
||||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_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;
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS feature_values JSONB;
|
||||||
|
|
||||||
|
-- v2.6: one shared worktree per session (all agents/panes in the session operate in it).
|
||||||
|
CREATE TABLE IF NOT EXISTS session_worktrees (
|
||||||
|
session_id UUID PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
worktree_path TEXT NOT NULL,
|
||||||
|
base_commit TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
|
||||||
|
);
|
||||||
|
-- Migrate existing FK to CASCADE (idempotent: drops the old constraint if present).
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'session_worktrees_session_id_fkey'
|
||||||
|
AND confdeltype <> 'c'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE session_worktrees DROP CONSTRAINT session_worktrees_session_id_fkey;
|
||||||
|
ALTER TABLE session_worktrees ADD CONSTRAINT session_worktrees_session_id_fkey
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- v2.6: one backend session per (session, agent); resumed on switch-back.
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||||
|
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
|
agent TEXT NOT NULL,
|
||||||
|
backend TEXT NOT NULL,
|
||||||
|
agent_session_id TEXT,
|
||||||
|
server_port INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'idle',
|
||||||
|
last_active_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
PRIMARY KEY (session_id, agent),
|
||||||
|
CONSTRAINT agent_sessions_backend_chk CHECK (backend IN ('opencode_server', 'acp_warm')),
|
||||||
|
CONSTRAINT agent_sessions_status_chk CHECK (status IN ('idle', 'active', 'crashed', 'closed'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Migrate existing agent_sessions FK to CASCADE.
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'agent_sessions_session_id_fkey'
|
||||||
|
AND confdeltype <> 'c'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE agent_sessions DROP CONSTRAINT agent_sessions_session_id_fkey;
|
||||||
|
ALTER TABLE agent_sessions ADD CONSTRAINT agent_sessions_session_id_fkey
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- v2.6: config fingerprint for stale-session detection (auto-recover on model change).
|
||||||
|
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS config_hash TEXT;
|
||||||
|
|
||||||
|
-- v2.6: attribution for DiffPanel badges (Phase 1 UX reads this).
|
||||||
|
ALTER TABLE pending_changes ADD COLUMN IF NOT EXISTS agent TEXT;
|
||||||
|
|
||||||
-- LISTEN/NOTIFY fast path: every tasks INSERT (from any call site — routes,
|
-- 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
|
-- 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
|
-- transaction, so the dispatcher reacts immediately instead of waiting for the
|
||||||
|
|||||||
50
apps/coder/src/services/__tests__/acp-client-fs.test.ts
Normal file
50
apps/coder/src/services/__tests__/acp-client-fs.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { readWorktreeTextFile, writeWorktreeTextFile } from '../acp-client-fs.js';
|
||||||
|
|
||||||
|
const created: string[] = [];
|
||||||
|
function freshWorktree(): string {
|
||||||
|
const wt = mkdtempSync(join(tmpdir(), 'acp-wt-'));
|
||||||
|
created.push(wt);
|
||||||
|
return wt;
|
||||||
|
}
|
||||||
|
afterEach(() => {
|
||||||
|
for (const d of created.splice(0)) {
|
||||||
|
try {
|
||||||
|
rmSync(d, { recursive: true, force: true });
|
||||||
|
rmSync(`${d}-evil`, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('acp-client-fs worktree scoping', () => {
|
||||||
|
it('writes then reads a file inside the worktree', async () => {
|
||||||
|
const wt = freshWorktree();
|
||||||
|
await writeWorktreeTextFile(wt, 'sub/dir/note.txt', 'hello');
|
||||||
|
expect(await readWorktreeTextFile(wt, 'sub/dir/note.txt')).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects ../ traversal on read', async () => {
|
||||||
|
const wt = freshWorktree();
|
||||||
|
await expect(readWorktreeTextFile(wt, '../../etc/passwd')).rejects.toThrow(/escapes worktree/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects ../ traversal on write', async () => {
|
||||||
|
const wt = freshWorktree();
|
||||||
|
await expect(writeWorktreeTextFile(wt, '../escape.txt', 'x')).rejects.toThrow(/escapes worktree/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a sibling-prefix path (the unbounded-startsWith bug)', async () => {
|
||||||
|
const wt = freshWorktree();
|
||||||
|
// Absolute path that shares the worktree as a STRING prefix but is a sibling
|
||||||
|
// dir: `<wt>-evil/...`. A bare `startsWith(<wt>)` wrongly admits it.
|
||||||
|
await expect(readWorktreeTextFile(wt, `${wt}-evil/secret.txt`)).rejects.toThrow(/escapes worktree/);
|
||||||
|
await expect(writeWorktreeTextFile(wt, `${wt}-evil/secret.txt`, 'x')).rejects.toThrow(
|
||||||
|
/escapes worktree/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -61,6 +61,22 @@ describe('buildResolvedRegistry', () => {
|
|||||||
warn.mockRestore();
|
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', () => {
|
it('REGRESSION: empty config returns exactly the built-ins, all enabled', () => {
|
||||||
const reg = buildResolvedRegistry(PROVIDERS, { providers: {} });
|
const reg = buildResolvedRegistry(PROVIDERS, { providers: {} });
|
||||||
expect(reg.size).toBe(PROVIDERS.length);
|
expect(reg.size).toBe(PROVIDERS.length);
|
||||||
|
|||||||
96
apps/coder/src/services/__tests__/provider-config.test.ts
Normal file
96
apps/coder/src/services/__tests__/provider-config.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
prefixLlamaSwapModels,
|
prefixLlamaSwapModels,
|
||||||
clearProviderSnapshotCache,
|
clearProviderSnapshotCache,
|
||||||
getProviderSnapshot,
|
getProviderSnapshot,
|
||||||
|
peekSnapshotEntry,
|
||||||
} from '../provider-snapshot.js';
|
} from '../provider-snapshot.js';
|
||||||
import { loadProviderConfig } from '../provider-config-registry.js';
|
import { loadProviderConfig } from '../provider-config-registry.js';
|
||||||
|
|
||||||
@@ -293,6 +294,49 @@ describe('getProviderSnapshot', () => {
|
|||||||
expect(boocode?.installed).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 () => {
|
it('2.7 warm cache: a second snapshot within the warm window spawns ZERO probes', async () => {
|
||||||
loadConfigFixture({});
|
loadConfigFixture({});
|
||||||
mockProbe.mockResolvedValue({
|
mockProbe.mockResolvedValue({
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
import { dirname, isAbsolute, resolve, sep } from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an ACP-supplied path against the agent worktree and reject anything
|
||||||
|
* that escapes it. Mirrors `write_guard.ts`'s check: `resolve()` to normalize
|
||||||
|
* `../` segments, then a **separator-bounded** prefix test — a bare
|
||||||
|
* `startsWith(root)` wrongly admits a sibling dir like `<root>-evil/...`.
|
||||||
|
*
|
||||||
|
* No realpath (consistent with `write_guard.ts`: the target may not exist yet on
|
||||||
|
* write). This is a containment guard for the ACP fs bridge, not a hard trust
|
||||||
|
* boundary — the agent process already runs with host FS access; symlink-swap
|
||||||
|
* hardening (`O_NOFOLLOW`/realpath) is out of scope here.
|
||||||
|
*/
|
||||||
|
function resolveInWorktree(worktreePath: string, filePath: string): string {
|
||||||
|
const root = resolve(worktreePath);
|
||||||
|
const absolute = isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath);
|
||||||
|
if (absolute !== root && !absolute.startsWith(root + sep)) {
|
||||||
|
throw new Error(`path escapes worktree: ${filePath}`);
|
||||||
|
}
|
||||||
|
return absolute;
|
||||||
|
}
|
||||||
|
|
||||||
/** Resolve an ACP path against the agent worktree and read a slice of lines. */
|
/** Resolve an ACP path against the agent worktree and read a slice of lines. */
|
||||||
export async function readWorktreeTextFile(
|
export async function readWorktreeTextFile(
|
||||||
@@ -8,10 +28,7 @@ export async function readWorktreeTextFile(
|
|||||||
line?: number | null,
|
line?: number | null,
|
||||||
limit?: number | null,
|
limit?: number | null,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
|
const absolute = resolveInWorktree(worktreePath, filePath);
|
||||||
if (!absolute.startsWith(resolve(worktreePath))) {
|
|
||||||
throw new Error(`path escapes worktree: ${filePath}`);
|
|
||||||
}
|
|
||||||
const raw = await fs.readFile(absolute, 'utf8');
|
const raw = await fs.readFile(absolute, 'utf8');
|
||||||
if (!line && !limit) return raw;
|
if (!line && !limit) return raw;
|
||||||
const lines = raw.split(/\r?\n/);
|
const lines = raw.split(/\r?\n/);
|
||||||
@@ -26,10 +43,7 @@ export async function writeWorktreeTextFile(
|
|||||||
filePath: string,
|
filePath: string,
|
||||||
content: string,
|
content: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
|
const absolute = resolveInWorktree(worktreePath, filePath);
|
||||||
if (!absolute.startsWith(resolve(worktreePath))) {
|
|
||||||
throw new Error(`path escapes worktree: ${filePath}`);
|
|
||||||
}
|
|
||||||
await fs.mkdir(dirname(absolute), { recursive: true });
|
await fs.mkdir(dirname(absolute), { recursive: true });
|
||||||
await fs.writeFile(absolute, content, 'utf8');
|
await fs.writeFile(absolute, content, 'utf8');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,17 @@ export async function probeAcpProvider(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const session = await connection.newSession({ cwd, mcpServers: [] });
|
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);
|
const result = parseSessionResponse(session, agent);
|
||||||
result.commands = probedCommands;
|
result.commands = probedCommands;
|
||||||
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});
|
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});
|
||||||
|
|||||||
85
apps/coder/src/services/agent-backend.ts
Normal file
85
apps/coder/src/services/agent-backend.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* v2.6 — AgentBackend abstraction (Phase 0 scaffold; types only, zero runtime logic).
|
||||||
|
*
|
||||||
|
* The core abstraction for persistent agent sessions. Two implementations land
|
||||||
|
* later: `OpenCodeServerBackend` (Phase 1, opencode HTTP server) and
|
||||||
|
* `WarmAcpBackend` (Phase 2, long-lived ACP process). Backends emit
|
||||||
|
* transport-agnostic `AgentEvent`s; the dispatcher maps them to WS frames.
|
||||||
|
*
|
||||||
|
* Nothing imports this file yet — it must compile standalone.
|
||||||
|
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2.
|
||||||
|
*/
|
||||||
|
import type { AcpToolSnapshot } from './acp-tool-snapshot.js';
|
||||||
|
import type { AgentCommand } from './provider-types.js';
|
||||||
|
|
||||||
|
/** Backend transport kind. Mirrors `agent_sessions.backend` CHECK in schema.sql. */
|
||||||
|
export type AgentBackendKind = 'opencode_server' | 'acp_warm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized, transport-agnostic events a backend emits during a turn (§2).
|
||||||
|
* Derived from acp-dispatch's session-update handling, but WITHOUT the WS
|
||||||
|
* envelope (message_id/chat_id) — the dispatcher owns frame mapping.
|
||||||
|
*
|
||||||
|
* `tool_call` vs `tool_update` are kept distinct on purpose: acp-dispatch
|
||||||
|
* currently merges both into one snapshot frame, but opencode's SSE
|
||||||
|
* distinguishes tool-start from tool-result, so the contract carries both.
|
||||||
|
* `commands` mirrors the ACP `available_commands_update` path (v2.5.10).
|
||||||
|
*/
|
||||||
|
export type AgentEvent =
|
||||||
|
| { type: 'text'; text: string }
|
||||||
|
| { type: 'reasoning'; text: string }
|
||||||
|
| { type: 'tool_call'; toolCall: AcpToolSnapshot }
|
||||||
|
| { type: 'tool_update'; toolCall: AcpToolSnapshot }
|
||||||
|
| { type: 'commands'; commands: AgentCommand[] };
|
||||||
|
|
||||||
|
/** Params to establish (or look up) a backend session (§2). */
|
||||||
|
export interface EnsureSessionOpts {
|
||||||
|
agent: string;
|
||||||
|
/** Resolved model id. */
|
||||||
|
model: string;
|
||||||
|
/** Shared per-session worktree (one per `sessions.id`, not per pane). */
|
||||||
|
worktreePath: string;
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opaque handle to a live backend session, persisted to `agent_sessions` (§2). */
|
||||||
|
export interface AgentSessionHandle {
|
||||||
|
sessionId: string;
|
||||||
|
agent: string;
|
||||||
|
backend: AgentBackendKind;
|
||||||
|
/** Provider's own session id (resume token); null until the backend assigns one. */
|
||||||
|
agentSessionId: string | null;
|
||||||
|
/** opencode HTTP server port; null for ACP backends. */
|
||||||
|
serverPort: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-turn context passed to `prompt` (§2). */
|
||||||
|
export interface PromptCtx {
|
||||||
|
worktreePath: string;
|
||||||
|
model: string;
|
||||||
|
signal: AbortSignal;
|
||||||
|
onEvent: (e: AgentEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of a completed turn (§2). Diff/persist happen outside the backend. */
|
||||||
|
export interface TurnResult {
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The core backend abstraction (§2). Implementations: OpenCodeServerBackend
|
||||||
|
* (Phase 1), WarmAcpBackend (Phase 2).
|
||||||
|
*/
|
||||||
|
export interface AgentBackend {
|
||||||
|
/** Lazy: spawn server / warm process if not already up for this (session, agent). §2 */
|
||||||
|
ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle>;
|
||||||
|
/** Send a prompt; stream events via ctx.onEvent; resolves when the turn completes. §2 */
|
||||||
|
prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise<TurnResult>;
|
||||||
|
/** Graceful teardown of one session (session close or idle timeout). §2 */
|
||||||
|
closeSession(handle: AgentSessionHandle): Promise<void>;
|
||||||
|
/** Full teardown — kills all spawned servers/processes. §2 */
|
||||||
|
dispose(): Promise<void>;
|
||||||
|
/** Liveness for health endpoint + dispatcher fallback decision. §2 */
|
||||||
|
health(): 'up' | 'down';
|
||||||
|
}
|
||||||
44
apps/coder/src/services/agent-pool.ts
Normal file
44
apps/coder/src/services/agent-pool.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* v2.6 — AgentPool (Phase 0 scaffold).
|
||||||
|
*
|
||||||
|
* Lazy get-or-create registry of `AgentBackend` instances keyed by
|
||||||
|
* `${sessionId}:${agent}`. Phase 0 ships the skeleton only: an in-memory Map,
|
||||||
|
* lookup / register / health, and clean disposal wired to the server's onClose.
|
||||||
|
* Spawning lands in Phase 1/2; nothing populates the map yet.
|
||||||
|
*
|
||||||
|
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2.
|
||||||
|
*/
|
||||||
|
import type { AgentBackend } from './agent-backend.js';
|
||||||
|
|
||||||
|
export class AgentPool {
|
||||||
|
private readonly backends = new Map<string, AgentBackend>();
|
||||||
|
|
||||||
|
private key(sessionId: string, agent: string): string {
|
||||||
|
return `${sessionId}:${agent}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map lookup only. Spawning is Phase 1/2 — never creates here. */
|
||||||
|
get(sessionId: string, agent: string): AgentBackend | undefined {
|
||||||
|
return this.backends.get(this.key(sessionId, agent));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store a backend instance for this (session, agent). */
|
||||||
|
register(sessionId: string, agent: string, backend: AgentBackend): void {
|
||||||
|
this.backends.set(this.key(sessionId, agent), backend);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Summary for the health endpoint. */
|
||||||
|
health(): { size: number } {
|
||||||
|
return { size: this.backends.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispose every backend and clear the map. Tolerates throwing backends. */
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
const entries = [...this.backends.values()];
|
||||||
|
this.backends.clear();
|
||||||
|
await Promise.allSettled(entries.map((b) => b.dispose()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single shared instance — referenced only by the server's onClose hook in Phase 0. */
|
||||||
|
export const agentPool = new AgentPool();
|
||||||
@@ -4,7 +4,7 @@ import { exec as execCb, execFile as execFileCb } from 'node:child_process';
|
|||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { PROVIDERS_BY_NAME } from './provider-registry.js';
|
import { PROVIDERS_BY_NAME } from './provider-registry.js';
|
||||||
import { resolveAcpProbeBinaries } from './acp-spawn.js';
|
import { resolveAcpProbeBinaries } from './acp-spawn.js';
|
||||||
import { clearProviderSnapshotCache } from './provider-snapshot.js';
|
import { clearProviderSnapshotCache, fetchLlamaSwapModels, prefixLlamaSwapModels } from './provider-snapshot.js';
|
||||||
import { readQwenSettingsModels } from './qwen-settings.js';
|
import { readQwenSettingsModels } from './qwen-settings.js';
|
||||||
import { loadConfig } from '../config.js';
|
import { loadConfig } from '../config.js';
|
||||||
import { loadProviderConfig } from './provider-config-registry.js';
|
import { loadProviderConfig } from './provider-config-registry.js';
|
||||||
@@ -117,6 +117,15 @@ export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<voi
|
|||||||
if (agentName === 'qwen') {
|
if (agentName === 'qwen') {
|
||||||
models = await readQwenSettingsModels();
|
models = await readQwenSettingsModels();
|
||||||
}
|
}
|
||||||
|
if (providerDef?.mergeLlamaSwap) {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
const llamaModels = prefixLlamaSwapModels(await fetchLlamaSwapModels(config));
|
||||||
|
models = [...models, ...llamaModels];
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ agent: agentName, err: err instanceof Error ? err.message : String(err) }, 'agent-probe: llama-swap model fetch failed (non-fatal)');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = resolved.configLabel ?? resolved.label;
|
const label = resolved.configLabel ?? resolved.label;
|
||||||
|
|||||||
748
apps/coder/src/services/backends/opencode-server.ts
Normal file
748
apps/coder/src/services/backends/opencode-server.ts
Normal file
@@ -0,0 +1,748 @@
|
|||||||
|
/**
|
||||||
|
* v2.6 Phase 1 — OpenCodeServerBackend.
|
||||||
|
*
|
||||||
|
* Warm, multi-turn backend for the `opencode` agent. One `opencode serve` HTTP
|
||||||
|
* server per BooCoder process; one opencode session per BooCode session (resumed
|
||||||
|
* on switch-back); a single SSE read loop demuxes all sessions' events.
|
||||||
|
*
|
||||||
|
* Implements the Phase 0 `AgentBackend` interface. Emits transport-agnostic
|
||||||
|
* `AgentEvent`s — the dispatcher (Phase 1.7, NOT wired in this batch) maps them
|
||||||
|
* to WS frames. No dispatcher/route references this file yet.
|
||||||
|
*
|
||||||
|
* Spec: openspec/changes/v2-6-persistent-agent-sessions/design.md §2 / §2a.
|
||||||
|
* SDK shapes verified by direct read of @opencode-ai/sdk@1.15.12 dist .d.ts:
|
||||||
|
* - client methods take FLATTENED params (sessionID/directory/body all inline),
|
||||||
|
* not {path,query,body}. create→{directory}, promptAsync→{sessionID,directory,
|
||||||
|
* parts,model}, abort→{sessionID,directory}. model is {providerID,modelID}.
|
||||||
|
* - client.event() resolves to { stream: AsyncGenerator<GlobalEvent> }; the
|
||||||
|
* real event is chunk.payload (discriminate on chunk.payload.type).
|
||||||
|
* - promptAsync is fire-and-forget (204); the turn completes via a
|
||||||
|
* 'session.idle' event for that opencode session id.
|
||||||
|
*/
|
||||||
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { createServer } from 'node:net';
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import {
|
||||||
|
createOpencodeClient,
|
||||||
|
type OpencodeClient,
|
||||||
|
type Event,
|
||||||
|
type Part,
|
||||||
|
type ToolPart,
|
||||||
|
type ToolState,
|
||||||
|
type AssistantMessage,
|
||||||
|
} from '@opencode-ai/sdk/v2/client';
|
||||||
|
import type { ToolCallStatus } from '@agentclientprotocol/sdk';
|
||||||
|
import type { Sql } from '../../db.js';
|
||||||
|
import type { AcpToolSnapshot } from '../acp-tool-snapshot.js';
|
||||||
|
import type {
|
||||||
|
AgentBackend,
|
||||||
|
AgentEvent,
|
||||||
|
AgentSessionHandle,
|
||||||
|
EnsureSessionOpts,
|
||||||
|
PromptCtx,
|
||||||
|
TurnResult,
|
||||||
|
} from '../agent-backend.js';
|
||||||
|
|
||||||
|
const READY_TIMEOUT_MS = 30_000;
|
||||||
|
const SSE_RECONNECT_DELAY_MS = 1_000;
|
||||||
|
/**
|
||||||
|
* No-activity backstop for an in-flight turn. opencode streams reasoning/text/tool
|
||||||
|
* deltas continuously while working, so "zero events for this long" means the turn
|
||||||
|
* is wedged or its terminal event (session.idle) was lost (see the reconnect race
|
||||||
|
* below). Generous so a legitimately slow turn never trips it.
|
||||||
|
*/
|
||||||
|
const TURN_INACTIVITY_MS = 180_000;
|
||||||
|
|
||||||
|
/** One in-flight turn's emitter + completion settler. */
|
||||||
|
interface TurnState {
|
||||||
|
onEvent: (e: AgentEvent) => void;
|
||||||
|
settle: (r: TurnResult) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-(opencode session) demux state. dedup sets scoped here, cleared per turn. */
|
||||||
|
interface SessionState {
|
||||||
|
boocodeSessionId: string;
|
||||||
|
agentSessionId: string;
|
||||||
|
/** Worktree directory for SDK `directory` routing; refreshed each turn from ctx. */
|
||||||
|
worktreePath: string;
|
||||||
|
/** dedup gate: `${type}:${id}` added on delta, deleted-and-tested on updated. Cleared at turn end. */
|
||||||
|
streamedPartKeys: Set<string>;
|
||||||
|
/** partID → 'text' | 'reasoning', so a delta with a non-'reasoning' field is still classed right. Cleared at turn end. */
|
||||||
|
partTypeById: Map<string, string>;
|
||||||
|
activeTurn: TurnState | null;
|
||||||
|
/** Inactivity backstop timer for the active turn; null when no turn in flight. */
|
||||||
|
watchdog: ReturnType<typeof setTimeout> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenCodeServerBackendDeps {
|
||||||
|
sql: Sql;
|
||||||
|
log: FastifyBaseLogger;
|
||||||
|
/** Absolute path to the opencode binary (resolved from available_agents at wiring time, Phase 1.7). */
|
||||||
|
opencodeBinary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpenCodeServerBackend implements AgentBackend {
|
||||||
|
readonly backend = 'opencode_server' as const;
|
||||||
|
|
||||||
|
private readonly sql: Sql;
|
||||||
|
private readonly log: FastifyBaseLogger;
|
||||||
|
private readonly opencodeBinary: string;
|
||||||
|
|
||||||
|
private child: ChildProcess | null = null;
|
||||||
|
private client: OpencodeClient | null = null;
|
||||||
|
private port: number | null = null;
|
||||||
|
private up = false;
|
||||||
|
private serverStarting: Promise<void> | null = null;
|
||||||
|
private sseRunning = false;
|
||||||
|
|
||||||
|
/** opencode session id → demux state. Maintained by ensureSession; read by the SSE loop. */
|
||||||
|
private readonly byOpencodeId = new Map<string, SessionState>();
|
||||||
|
|
||||||
|
constructor(deps: OpenCodeServerBackendDeps) {
|
||||||
|
this.sql = deps.sql;
|
||||||
|
this.log = deps.log;
|
||||||
|
this.opencodeBinary = deps.opencodeBinary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** §2: liveness for the health endpoint + dispatcher fallback decision. */
|
||||||
|
health(): 'up' | 'down' {
|
||||||
|
return this.up ? 'up' : 'down';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Server lifecycle (1.2: spawn once + client + ready) ─────────────────────
|
||||||
|
|
||||||
|
/** Lazy: start the single server on first use. Idempotent — one server per backend. */
|
||||||
|
private ensureServer(): Promise<void> {
|
||||||
|
if (!this.serverStarting) this.serverStarting = this.startServer();
|
||||||
|
return this.serverStarting;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startServer(): Promise<void> {
|
||||||
|
const port = await freePort();
|
||||||
|
|
||||||
|
// Phase 1: run unsecured on loopback (opencode's documented default — serve.ts
|
||||||
|
// only WARNS when OPENCODE_SERVER_PASSWORD is unset). The real boundary is the
|
||||||
|
// 127.0.0.1 bind. Defense-in-depth basic-auth is deferred: the hey-api client's
|
||||||
|
// auth wiring + opencode's exact scheme must be confirmed against a live server
|
||||||
|
// first, else every request 401s. Recon explicitly said "do NOT block on it".
|
||||||
|
const child = spawn(this.opencodeBinary, ['serve', '--hostname', '127.0.0.1', '--port', String(port)], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env },
|
||||||
|
});
|
||||||
|
this.child = child;
|
||||||
|
this.port = port;
|
||||||
|
|
||||||
|
// Child lifetime is the backend's (the pool's), NOT a request's. We never tie
|
||||||
|
// it to a per-turn abort signal. On unexpected exit we mark down + log; crash
|
||||||
|
// recovery is Phase 3.
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
this.up = false;
|
||||||
|
this.log.warn({ code, signal, port }, 'opencode-server: child exited (recovery is Phase 3)');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForReady(child, READY_TIMEOUT_MS);
|
||||||
|
|
||||||
|
this.client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` });
|
||||||
|
this.up = true;
|
||||||
|
this.log.info({ port }, 'opencode-server: ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SSE read loop + demux + translate (1.3) + dedup (1.4) ───────────────────
|
||||||
|
|
||||||
|
/** Per-directory SSE subscription. opencode scopes events by directory (defaults
|
||||||
|
* to process.cwd if omitted) — so we must subscribe with the same directory used
|
||||||
|
* to create the session. Called from ensureSession; reconnects while up. */
|
||||||
|
private startEventLoop(directory: string): void {
|
||||||
|
if (this.sseRunning) return;
|
||||||
|
this.sseRunning = true;
|
||||||
|
this.sseDirectory = directory;
|
||||||
|
void this.runEventLoop(directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sseDirectory: string | null = null;
|
||||||
|
|
||||||
|
private async runEventLoop(directory: string): Promise<void> {
|
||||||
|
while (this.up && this.client) {
|
||||||
|
try {
|
||||||
|
const sub = await this.client.event.subscribe({ directory });
|
||||||
|
for await (const ev of sub.stream) {
|
||||||
|
this.dispatchEvent(ev);
|
||||||
|
}
|
||||||
|
if (this.up) {
|
||||||
|
await this.reconcileInFlight();
|
||||||
|
await sleep(SSE_RECONNECT_DELAY_MS);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!this.up) break;
|
||||||
|
this.log.warn({ err: errMsg(err) }, 'opencode-server: event loop error; reconnecting');
|
||||||
|
await this.reconcileInFlight();
|
||||||
|
await sleep(SSE_RECONNECT_DELAY_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.sseRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Demux one event to the owning session's active turn. Unknown/between-turns → drop. */
|
||||||
|
private dispatchEvent(ev: Event): void {
|
||||||
|
switch (ev.type) {
|
||||||
|
// ─── session.next.* — live streaming events (the primary path) ─────────
|
||||||
|
case 'session.next.text.delta': {
|
||||||
|
const p = ev.properties;
|
||||||
|
const st = this.byOpencodeId.get(p.sessionID);
|
||||||
|
if (!st?.activeTurn) return;
|
||||||
|
this.bumpActivity(st);
|
||||||
|
const cleaned = stripDcpTags(p.delta);
|
||||||
|
if (cleaned) st.activeTurn.onEvent({ type: 'text', text: cleaned });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'session.next.reasoning.delta': {
|
||||||
|
const p = ev.properties;
|
||||||
|
const st = this.byOpencodeId.get(p.sessionID);
|
||||||
|
if (!st?.activeTurn) return;
|
||||||
|
this.bumpActivity(st);
|
||||||
|
st.activeTurn.onEvent({ type: 'reasoning', text: p.delta });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'session.next.tool.called': {
|
||||||
|
const p = ev.properties;
|
||||||
|
const st = this.byOpencodeId.get(p.sessionID);
|
||||||
|
if (!st?.activeTurn) return;
|
||||||
|
this.bumpActivity(st);
|
||||||
|
const snap: AcpToolSnapshot = {
|
||||||
|
toolCallId: p.callID,
|
||||||
|
title: p.tool,
|
||||||
|
kind: null,
|
||||||
|
status: 'in_progress',
|
||||||
|
rawInput: p.input,
|
||||||
|
rawOutput: undefined,
|
||||||
|
};
|
||||||
|
st.activeTurn.onEvent({ type: 'tool_call', toolCall: snap });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'session.next.tool.success': {
|
||||||
|
const p = ev.properties;
|
||||||
|
const st = this.byOpencodeId.get(p.sessionID);
|
||||||
|
if (!st?.activeTurn) return;
|
||||||
|
this.bumpActivity(st);
|
||||||
|
const output = p.content?.map((c) => ('text' in c ? (c as { text: string }).text : '')).join('') ?? '';
|
||||||
|
const snap: AcpToolSnapshot = {
|
||||||
|
toolCallId: p.callID,
|
||||||
|
title: p.callID,
|
||||||
|
kind: null,
|
||||||
|
status: 'completed',
|
||||||
|
rawInput: undefined,
|
||||||
|
rawOutput: output,
|
||||||
|
};
|
||||||
|
st.activeTurn.onEvent({ type: 'tool_update', toolCall: snap });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'session.next.tool.failed': {
|
||||||
|
const p = ev.properties;
|
||||||
|
const st = this.byOpencodeId.get(p.sessionID);
|
||||||
|
if (!st?.activeTurn) return;
|
||||||
|
this.bumpActivity(st);
|
||||||
|
const snap: AcpToolSnapshot = {
|
||||||
|
toolCallId: p.callID,
|
||||||
|
title: p.callID,
|
||||||
|
kind: null,
|
||||||
|
status: 'failed',
|
||||||
|
rawInput: undefined,
|
||||||
|
rawOutput: errToString(p.error),
|
||||||
|
};
|
||||||
|
st.activeTurn.onEvent({ type: 'tool_update', toolCall: snap });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ─── message.part.* — terminal/post-hoc events (dedup gate) ────────────
|
||||||
|
case 'message.part.delta': {
|
||||||
|
const p = ev.properties;
|
||||||
|
const st = this.byOpencodeId.get(p.sessionID);
|
||||||
|
if (!st?.activeTurn) return;
|
||||||
|
this.bumpActivity(st);
|
||||||
|
const isReasoning = p.field === 'reasoning' || st.partTypeById.get(p.partID) === 'reasoning';
|
||||||
|
if (isReasoning) {
|
||||||
|
st.streamedPartKeys.add(`reasoning:${p.partID}`);
|
||||||
|
st.activeTurn.onEvent({ type: 'reasoning', text: p.delta });
|
||||||
|
} else if (p.field === 'text') {
|
||||||
|
st.streamedPartKeys.add(`text:${p.partID}`);
|
||||||
|
const cleaned = stripDcpTags(p.delta);
|
||||||
|
if (cleaned) st.activeTurn.onEvent({ type: 'text', text: cleaned });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'message.part.updated': {
|
||||||
|
const part = ev.properties.part;
|
||||||
|
const st = this.byOpencodeId.get(part.sessionID);
|
||||||
|
if (!st?.activeTurn) return;
|
||||||
|
this.bumpActivity(st);
|
||||||
|
this.handleUpdatedPart(part, st);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ─── lifecycle ─────────────────────────────────────────────────────────
|
||||||
|
case 'session.idle': {
|
||||||
|
this.byOpencodeId.get(ev.properties.sessionID)?.activeTurn?.settle({ ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'session.error': {
|
||||||
|
const sid = ev.properties.sessionID;
|
||||||
|
if (!sid) return;
|
||||||
|
this.byOpencodeId.get(sid)?.activeTurn?.settle({ ok: false, error: errToString(ev.properties.error) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Terminal part: dedup gate for text/reasoning; tool parts → tool_call/tool_update. */
|
||||||
|
private handleUpdatedPart(part: Part, st: SessionState): void {
|
||||||
|
const turn = st.activeTurn;
|
||||||
|
if (!turn) return;
|
||||||
|
|
||||||
|
if (part.type === 'text' || part.type === 'reasoning') {
|
||||||
|
st.partTypeById.set(part.id, part.type);
|
||||||
|
const key = resolvePartDedupeKey(part, part.type);
|
||||||
|
if (key && st.streamedPartKeys.delete(key)) return; // already streamed via delta
|
||||||
|
const raw = part.text ?? '';
|
||||||
|
const text = part.type === 'text' ? stripDcpTags(raw) : raw;
|
||||||
|
if (text && part.time?.end != null) {
|
||||||
|
turn.onEvent({ type: part.type, text });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === 'tool') {
|
||||||
|
const snap = toolPartToSnapshot(part);
|
||||||
|
const status = part.state?.status;
|
||||||
|
// tool_call on start (pending/running), tool_update on terminal (completed/error).
|
||||||
|
// The current ACP path merges both into one frame; the contract keeps them
|
||||||
|
// distinct because opencode's SSE distinguishes start from result.
|
||||||
|
const event: AgentEvent =
|
||||||
|
status === 'completed' || status === 'error'
|
||||||
|
? { type: 'tool_update', toolCall: snap }
|
||||||
|
: { type: 'tool_call', toolCall: snap };
|
||||||
|
turn.onEvent(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// NOTE: opencode's SSE payload union carries no available-commands event, so the
|
||||||
|
// AgentEvent 'commands' arm is intentionally never emitted here (1.3).
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── turn-completion resilience (watchdog + reconnect reconcile) ─────────────
|
||||||
|
|
||||||
|
/** Reset the inactivity backstop on any event routed to a session's active turn. */
|
||||||
|
private bumpActivity(st: SessionState): void {
|
||||||
|
if (!st.activeTurn) return;
|
||||||
|
if (st.watchdog) clearTimeout(st.watchdog);
|
||||||
|
st.watchdog = setTimeout(() => {
|
||||||
|
void this.onTurnStall(st);
|
||||||
|
}, TURN_INACTIVITY_MS);
|
||||||
|
st.watchdog.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Watchdog fired: reconcile once; if the server says still-running we can't tell, so fail closed.
|
||||||
|
* Also mark the agent_sessions row crashed so a stale session isn't resumed next turn. */
|
||||||
|
private async onTurnStall(st: SessionState): Promise<void> {
|
||||||
|
const settled = await this.reconcile(st);
|
||||||
|
if (!settled) {
|
||||||
|
this.log.warn({ agentSessionId: st.agentSessionId }, 'opencode-server: turn stalled (no activity), failing + marking crashed');
|
||||||
|
await this.sql`
|
||||||
|
UPDATE agent_sessions SET status = 'crashed'
|
||||||
|
WHERE agent_session_id = ${st.agentSessionId}
|
||||||
|
`.catch(() => {});
|
||||||
|
st.activeTurn?.settle({ ok: false, error: 'turn timed out (no activity)' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconcile every in-flight turn against the server (called after an SSE drop). */
|
||||||
|
private async reconcileInFlight(): Promise<void> {
|
||||||
|
const states = [...this.byOpencodeId.values()].filter((s) => s.activeTurn);
|
||||||
|
if (states.length === 0) return;
|
||||||
|
await Promise.allSettled(states.map((s) => this.reconcile(s)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the server whether this session's turn already finished — recovers a
|
||||||
|
* session.idle/error lost during an SSE gap. Returns true if it settled the turn.
|
||||||
|
* Inconclusive (still running / call failed) → false; the watchdog covers that.
|
||||||
|
*/
|
||||||
|
private async reconcile(st: SessionState): Promise<boolean> {
|
||||||
|
const turn = st.activeTurn;
|
||||||
|
if (!turn || !this.client) return false;
|
||||||
|
try {
|
||||||
|
const res = await this.client.session.messages({
|
||||||
|
sessionID: st.agentSessionId,
|
||||||
|
directory: st.worktreePath,
|
||||||
|
});
|
||||||
|
if (res.error || !res.data) return false;
|
||||||
|
let lastAssistant: AssistantMessage | undefined;
|
||||||
|
for (let i = res.data.length - 1; i >= 0; i--) {
|
||||||
|
const info = res.data[i]!.info;
|
||||||
|
if (info.role === 'assistant') {
|
||||||
|
lastAssistant = info;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!lastAssistant) return false;
|
||||||
|
if (lastAssistant.error != null) {
|
||||||
|
turn.settle({ ok: false, error: errToString(lastAssistant.error) });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (lastAssistant.time.completed != null) {
|
||||||
|
turn.settle({ ok: true });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false; // still running — the live stream will deliver session.idle
|
||||||
|
} catch {
|
||||||
|
return false; // inconclusive — watchdog backstop covers it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ensureSession: create-or-resume against agent_sessions (1.5) ────────────
|
||||||
|
|
||||||
|
async ensureSession(sessionId: string, opts: EnsureSessionOpts): Promise<AgentSessionHandle> {
|
||||||
|
await this.ensureServer();
|
||||||
|
if (!this.client) throw new Error('opencode-server: client not ready after ensureServer');
|
||||||
|
|
||||||
|
const configHash = sessionConfigHash(opts.model);
|
||||||
|
const [row] = await this.sql<{ agent_session_id: string | null; status: string; config_hash: string | null }[]>`
|
||||||
|
SELECT agent_session_id, status, config_hash FROM agent_sessions
|
||||||
|
WHERE session_id = ${sessionId} AND agent = ${opts.agent}
|
||||||
|
`;
|
||||||
|
let agentSessionId = row?.agent_session_id ?? null;
|
||||||
|
|
||||||
|
// Don't resume crashed sessions or sessions whose config drifted (model change).
|
||||||
|
const shouldResume = agentSessionId
|
||||||
|
&& row!.status !== 'crashed'
|
||||||
|
&& (row!.config_hash == null || row!.config_hash === configHash);
|
||||||
|
|
||||||
|
if (!shouldResume) {
|
||||||
|
if (agentSessionId) {
|
||||||
|
this.log.info({ sessionId, oldStatus: row!.status, hashMatch: row!.config_hash === configHash },
|
||||||
|
'opencode-server: not resuming stale session, creating fresh');
|
||||||
|
this.byOpencodeId.delete(agentSessionId);
|
||||||
|
}
|
||||||
|
const created = await this.client.session.create({ directory: opts.worktreePath });
|
||||||
|
if (created.error || !created.data) {
|
||||||
|
throw new Error(`opencode-server: session.create failed: ${errToString(created.error)}`);
|
||||||
|
}
|
||||||
|
agentSessionId = created.data.id;
|
||||||
|
await this.sql`
|
||||||
|
INSERT INTO agent_sessions
|
||||||
|
(session_id, agent, backend, agent_session_id, server_port, status, last_active_at, config_hash)
|
||||||
|
VALUES
|
||||||
|
(${sessionId}, ${opts.agent}, 'opencode_server', ${agentSessionId}, ${this.port}, 'active', clock_timestamp(), ${configHash})
|
||||||
|
ON CONFLICT (session_id, agent) DO UPDATE SET
|
||||||
|
backend = 'opencode_server',
|
||||||
|
agent_session_id = EXCLUDED.agent_session_id,
|
||||||
|
server_port = EXCLUDED.server_port,
|
||||||
|
status = 'active',
|
||||||
|
last_active_at = clock_timestamp(),
|
||||||
|
config_hash = EXCLUDED.config_hash
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
await this.sql`
|
||||||
|
UPDATE agent_sessions
|
||||||
|
SET status = 'active', last_active_at = clock_timestamp(), server_port = ${this.port}, config_hash = ${configHash}
|
||||||
|
WHERE session_id = ${sessionId} AND agent = ${opts.agent}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both branches above guarantee agentSessionId is non-null.
|
||||||
|
const ocSessionId = agentSessionId!;
|
||||||
|
|
||||||
|
// Start (or re-start) the SSE event loop scoped to this session's directory.
|
||||||
|
// opencode scopes events by the `directory` query param; without it events
|
||||||
|
// default to the server's CWD which doesn't match our worktree paths.
|
||||||
|
//
|
||||||
|
// KNOWN Phase 1 LIMITATION: one SSE stream at a time, scoped to a single
|
||||||
|
// directory. Under 1.9 concurrency, if two opencode sessions use different
|
||||||
|
// worktree directories simultaneously, re-subscribing for the second drops
|
||||||
|
// the first session's events (the watchdog backstop prevents a full hang,
|
||||||
|
// but streamed content is lost). Phase 2 should move to per-session SSE
|
||||||
|
// subscriptions or a directory-agnostic event path.
|
||||||
|
if (!this.sseRunning || this.sseDirectory !== opts.worktreePath) {
|
||||||
|
if (this.sseRunning && this.sseDirectory && this.sseDirectory !== opts.worktreePath) {
|
||||||
|
this.log.warn(
|
||||||
|
{ prev: this.sseDirectory, next: opts.worktreePath },
|
||||||
|
'opencode-server: SSE directory changed — concurrent sessions will lose events from the previous directory',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.sseRunning = false;
|
||||||
|
this.startEventLoop(opts.worktreePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register / refresh the demux entry the SSE loop keys on. Preserve an existing
|
||||||
|
// entry (and any in-flight turn) — just refresh the routing fields.
|
||||||
|
const existing = this.byOpencodeId.get(ocSessionId);
|
||||||
|
if (existing) {
|
||||||
|
existing.boocodeSessionId = sessionId;
|
||||||
|
existing.worktreePath = opts.worktreePath;
|
||||||
|
} else {
|
||||||
|
this.byOpencodeId.set(ocSessionId, {
|
||||||
|
boocodeSessionId: sessionId,
|
||||||
|
agentSessionId: ocSessionId,
|
||||||
|
worktreePath: opts.worktreePath,
|
||||||
|
streamedPartKeys: new Set(),
|
||||||
|
partTypeById: new Map(),
|
||||||
|
activeTurn: null,
|
||||||
|
watchdog: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
agent: opts.agent,
|
||||||
|
backend: 'opencode_server',
|
||||||
|
agentSessionId: ocSessionId,
|
||||||
|
serverPort: this.port,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── prompt: send one turn (1.6) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async prompt(handle: AgentSessionHandle, input: string, ctx: PromptCtx): Promise<TurnResult> {
|
||||||
|
if (!this.client) throw new Error('opencode-server: client not ready');
|
||||||
|
const oc = handle.agentSessionId;
|
||||||
|
if (!oc) throw new Error('opencode-server: handle has no agentSessionId');
|
||||||
|
|
||||||
|
let state = this.byOpencodeId.get(oc);
|
||||||
|
if (!state) {
|
||||||
|
state = {
|
||||||
|
boocodeSessionId: handle.sessionId,
|
||||||
|
agentSessionId: oc,
|
||||||
|
worktreePath: ctx.worktreePath,
|
||||||
|
streamedPartKeys: new Set(),
|
||||||
|
partTypeById: new Map(),
|
||||||
|
activeTurn: null,
|
||||||
|
watchdog: null,
|
||||||
|
};
|
||||||
|
this.byOpencodeId.set(oc, state);
|
||||||
|
}
|
||||||
|
const session = state;
|
||||||
|
// Authoritative per-turn directory for SDK routing + reconcile.
|
||||||
|
session.worktreePath = ctx.worktreePath;
|
||||||
|
const client = this.client;
|
||||||
|
|
||||||
|
return await new Promise<TurnResult>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const cleanup = () => {
|
||||||
|
session.activeTurn = null;
|
||||||
|
if (session.watchdog) {
|
||||||
|
clearTimeout(session.watchdog);
|
||||||
|
session.watchdog = null;
|
||||||
|
}
|
||||||
|
session.streamedPartKeys.clear();
|
||||||
|
session.partTypeById.clear();
|
||||||
|
ctx.signal.removeEventListener('abort', onAbort);
|
||||||
|
};
|
||||||
|
const settle = (r: TurnResult) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
resolve(r);
|
||||||
|
};
|
||||||
|
const onAbort = () => {
|
||||||
|
// Abort the turn only — never the server.
|
||||||
|
client.session.abort({ sessionID: oc, directory: ctx.worktreePath }).catch(() => {});
|
||||||
|
settle({ ok: false, error: 'aborted' });
|
||||||
|
};
|
||||||
|
|
||||||
|
session.activeTurn = { onEvent: ctx.onEvent, settle };
|
||||||
|
this.bumpActivity(session); // arm the inactivity backstop
|
||||||
|
|
||||||
|
if (ctx.signal.aborted) {
|
||||||
|
onAbort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
|
||||||
|
const model = parseModel(ctx.model);
|
||||||
|
client.session
|
||||||
|
.promptAsync({
|
||||||
|
sessionID: oc,
|
||||||
|
directory: ctx.worktreePath,
|
||||||
|
parts: [{ type: 'text', text: input }],
|
||||||
|
...(model ? { model } : {}),
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
// promptAsync is fire-and-forget (204); the turn completes via session.idle.
|
||||||
|
// Only a submission error settles here.
|
||||||
|
if (res.error) settle({ ok: false, error: errToString(res.error) });
|
||||||
|
})
|
||||||
|
.catch((err) => settle({ ok: false, error: errMsg(err) }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── teardown ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async closeSession(handle: AgentSessionHandle): Promise<void> {
|
||||||
|
if (handle.agentSessionId) this.byOpencodeId.delete(handle.agentSessionId);
|
||||||
|
await this.sql`
|
||||||
|
UPDATE agent_sessions SET status = 'closed'
|
||||||
|
WHERE session_id = ${handle.sessionId} AND agent = ${handle.agent}
|
||||||
|
`.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
this.up = false;
|
||||||
|
const child = this.child;
|
||||||
|
this.child = null;
|
||||||
|
this.client = null;
|
||||||
|
this.byOpencodeId.clear();
|
||||||
|
if (child && !child.killed) {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
if (!child.killed) child.kill('SIGKILL');
|
||||||
|
}, 5_000);
|
||||||
|
t.unref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** BooCoder model string "provider/model" → opencode's structured {providerID, modelID}. */
|
||||||
|
function parseModel(model: string | undefined): { providerID: string; modelID: string } | undefined {
|
||||||
|
if (!model || !model.trim()) return undefined;
|
||||||
|
const trimmed = model.trim();
|
||||||
|
const idx = trimmed.indexOf('/');
|
||||||
|
if (idx > 0 && idx < trimmed.length - 1) {
|
||||||
|
return { providerID: trimmed.slice(0, idx), modelID: trimmed.slice(idx + 1) };
|
||||||
|
}
|
||||||
|
// No slash but non-empty → infer llama-swap (the only configured provider).
|
||||||
|
// Guard against bare '/' or trailing/leading slash.
|
||||||
|
if (idx < 0 && trimmed.length > 0) {
|
||||||
|
return { providerID: 'llama-swap', modelID: trimmed };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ported verbatim from Paseo opencode-agent.ts: id → message-id fallback → null. */
|
||||||
|
function resolvePartDedupeKey(part: { id: string; messageID: string }, type: string): string | null {
|
||||||
|
if (part.id.trim().length > 0) return `${type}:${part.id}`;
|
||||||
|
if (part.messageID.trim().length > 0) return `${type}:message:${part.messageID}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** opencode ToolPart → ACP-shaped snapshot (reuses the existing persist/render path). */
|
||||||
|
function toolPartToSnapshot(part: ToolPart): AcpToolSnapshot {
|
||||||
|
const state = part.state;
|
||||||
|
let rawInput: unknown;
|
||||||
|
let rawOutput: unknown;
|
||||||
|
let title: string | undefined;
|
||||||
|
if (state) {
|
||||||
|
if ('input' in state) rawInput = (state as { input?: unknown }).input;
|
||||||
|
if ('output' in state) rawOutput = (state as { output?: unknown }).output;
|
||||||
|
else if ('error' in state) rawOutput = (state as { error?: unknown }).error;
|
||||||
|
if ('title' in state) title = (state as { title?: string }).title;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
toolCallId: part.callID,
|
||||||
|
title: title ?? part.tool,
|
||||||
|
kind: null,
|
||||||
|
status: mapToolStatus(state?.status),
|
||||||
|
rawInput,
|
||||||
|
rawOutput,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapToolStatus(s: ToolState['status'] | undefined): ToolCallStatus | null {
|
||||||
|
switch (s) {
|
||||||
|
case 'pending':
|
||||||
|
return 'pending';
|
||||||
|
case 'running':
|
||||||
|
return 'in_progress';
|
||||||
|
case 'completed':
|
||||||
|
return 'completed';
|
||||||
|
case 'error':
|
||||||
|
return 'failed';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bind-probe an ephemeral port on loopback. */
|
||||||
|
function freePort(): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const srv = createServer();
|
||||||
|
srv.unref();
|
||||||
|
srv.on('error', reject);
|
||||||
|
srv.listen(0, '127.0.0.1', () => {
|
||||||
|
const addr = srv.address();
|
||||||
|
if (addr && typeof addr === 'object') {
|
||||||
|
const { port } = addr;
|
||||||
|
srv.close(() => resolve(port));
|
||||||
|
} else {
|
||||||
|
srv.close(() => reject(new Error('opencode-server: could not determine a free port')));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve when the child prints the ready line; reject on timeout or early exit. */
|
||||||
|
function waitForReady(child: ChildProcess, timeoutMs: number): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let done = false;
|
||||||
|
let stderrBuf = '';
|
||||||
|
|
||||||
|
const finish = (err?: Error) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
child.stdout?.off('data', onOut);
|
||||||
|
child.stderr?.off('data', onErr);
|
||||||
|
child.off('exit', onExit);
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOut = (buf: Buffer) => {
|
||||||
|
if (buf.toString().includes('opencode server listening on')) finish();
|
||||||
|
};
|
||||||
|
const onErr = (buf: Buffer) => {
|
||||||
|
stderrBuf += buf.toString();
|
||||||
|
};
|
||||||
|
const onExit = (code: number | null) =>
|
||||||
|
finish(new Error(`opencode serve exited before ready (code ${code}); stderr: ${stderrBuf.slice(-2000)}`));
|
||||||
|
const timer = setTimeout(
|
||||||
|
() => finish(new Error(`opencode serve not ready in ${timeoutMs}ms; stderr: ${stderrBuf.slice(-2000)}`)),
|
||||||
|
timeoutMs,
|
||||||
|
);
|
||||||
|
|
||||||
|
child.stdout?.on('data', onOut);
|
||||||
|
child.stderr?.on('data', onErr);
|
||||||
|
child.on('exit', onExit);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip opencode-dcp plugin tags that render as literal text in the UI. */
|
||||||
|
function stripDcpTags(s: string): string {
|
||||||
|
return s.replace(/<dcp-message-id>[^<]*<\/dcp-message-id>/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function errMsg(e: unknown): string {
|
||||||
|
return e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errToString(e: unknown): string {
|
||||||
|
if (e == null) return 'unknown error';
|
||||||
|
if (typeof e === 'string') return e;
|
||||||
|
if (e instanceof Error) return e.message;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(e);
|
||||||
|
} catch {
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hash of stable config — detects model changes across sessions without
|
||||||
|
* invalidating on ephemeral state like the random server port (which changes
|
||||||
|
* every BooCoder restart). */
|
||||||
|
function sessionConfigHash(model: string): string {
|
||||||
|
return createHash('sha256').update(`opencode_server|${model}`).digest('hex').slice(0, 16);
|
||||||
|
}
|
||||||
108
apps/coder/src/services/claude-command-discovery.ts
Normal file
108
apps/coder/src/services/claude-command-discovery.ts
Normal 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)));
|
||||||
|
}
|
||||||
@@ -3,13 +3,17 @@ import type { FastifyBaseLogger } from 'fastify';
|
|||||||
import type { Broker } from '@boocode/server/broker';
|
import type { Broker } from '@boocode/server/broker';
|
||||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
|
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
|
||||||
import { dispatchViaAcp } from './acp-dispatch.js';
|
import { dispatchViaAcp } from './acp-dispatch.js';
|
||||||
import { getResolvedRegistry } from './provider-config-registry.js';
|
import { getResolvedRegistry } from './provider-config-registry.js';
|
||||||
import { dispatchViaPty } from './pty-dispatch.js';
|
import { dispatchViaPty } from './pty-dispatch.js';
|
||||||
import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
|
import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
|
||||||
import { getManifestCommands } from './provider-commands.js';
|
import { getManifestCommands } from './provider-commands.js';
|
||||||
import { persistExternalAgentTurn } from './agent-turn-persist.js';
|
import { persistExternalAgentTurn } from './agent-turn-persist.js';
|
||||||
|
import { snapshotToWireToolCall, type AcpToolSnapshot } from './acp-tool-snapshot.js';
|
||||||
|
import { agentPool } from './agent-pool.js';
|
||||||
|
import { OpenCodeServerBackend } from './backends/opencode-server.js';
|
||||||
|
import type { AgentBackend, AgentEvent } from './agent-backend.js';
|
||||||
|
|
||||||
interface InferenceRunner {
|
interface InferenceRunner {
|
||||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||||
@@ -35,47 +39,65 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
const { sql, inference, broker, log, config } = deps;
|
const { sql, inference, broker, log, config } = deps;
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
let listener: { unlisten: () => Promise<void> } | null = null;
|
let listener: { unlisten: () => Promise<void> } | null = null;
|
||||||
let running = false;
|
let polling = false;
|
||||||
let stopping = false;
|
let stopping = false;
|
||||||
let inflightPromise: Promise<void> | null = null;
|
// v2.6 (1.9): per-session in-flight registry replaces the global `running`
|
||||||
|
// boolean. Key = session_id (or `task:<id>` for sessionless tasks). Sessions
|
||||||
|
// without an in-flight turn run concurrently; within a session, strictly one
|
||||||
|
// turn at a time.
|
||||||
|
const inflight = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
// Shared entry point for both the poll timer and the NOTIFY listener. poll()'s
|
// 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
|
// `polling`/`stopping` guard makes this safe to call concurrently — a notify
|
||||||
// arriving mid-task returns immediately and never double-dispatches.
|
// arriving mid-poll returns immediately and never double-dispatches.
|
||||||
function triggerPoll(reason: string): void {
|
function triggerPoll(reason: string): void {
|
||||||
poll().catch((err) => {
|
poll().catch((err) => {
|
||||||
log.error({ err, reason }, 'dispatcher: poll error');
|
log.error({ err, reason }, 'dispatcher: poll error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function concurrencyKey(task: { id: string; session_id: string | null }): string {
|
||||||
|
return task.session_id ?? `task:${task.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function poll(): Promise<void> {
|
async function poll(): Promise<void> {
|
||||||
if (running || stopping) return;
|
// `polling` serializes poll() execution itself (timer + NOTIFY can fire
|
||||||
|
// concurrently) so we never double-select a task. It does NOT serialize task
|
||||||
// Grab one pending task
|
// execution — that's what `inflight` (keyed per session) governs.
|
||||||
const rows = await sql<{
|
if (polling || stopping) return;
|
||||||
id: string;
|
polling = true;
|
||||||
project_id: string;
|
try {
|
||||||
input: string;
|
// Oldest-first; start every pending task whose session isn't already busy.
|
||||||
agent: string | null;
|
const rows = await sql<{
|
||||||
model: string | null;
|
id: string;
|
||||||
mode_id: string | null;
|
project_id: string;
|
||||||
thinking_option_id: string | null;
|
input: string;
|
||||||
session_id: string | null;
|
agent: string | null;
|
||||||
}[]>`
|
model: string | null;
|
||||||
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id
|
mode_id: string | null;
|
||||||
FROM tasks
|
thinking_option_id: string | null;
|
||||||
WHERE state = 'pending'
|
session_id: string | null;
|
||||||
ORDER BY created_at
|
}[]>`
|
||||||
LIMIT 1
|
SELECT id, project_id, input, agent, model, mode_id, thinking_option_id, session_id
|
||||||
`;
|
FROM tasks
|
||||||
if (rows.length === 0) return;
|
WHERE state = 'pending'
|
||||||
|
ORDER BY created_at
|
||||||
const task = rows[0]!;
|
LIMIT 50
|
||||||
running = true;
|
`;
|
||||||
inflightPromise = runTask(task).finally(() => {
|
for (const task of rows) {
|
||||||
running = false;
|
if (stopping) break;
|
||||||
inflightPromise = null;
|
const key = concurrencyKey(task);
|
||||||
});
|
if (inflight.has(key)) continue; // this session already has an in-flight turn
|
||||||
|
// Register synchronously (before any await) so a later row in this pass
|
||||||
|
// with the same key is skipped and a concurrent poll can't re-pick it.
|
||||||
|
const p = runTask(task).finally(() => {
|
||||||
|
inflight.delete(key);
|
||||||
|
});
|
||||||
|
inflight.set(key, p);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
polling = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runTask(task: {
|
async function runTask(task: {
|
||||||
@@ -96,7 +118,13 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
SELECT name, supports_acp, install_path FROM available_agents WHERE name = ${task.agent}
|
SELECT name, supports_acp, install_path FROM available_agents WHERE name = ${task.agent}
|
||||||
`;
|
`;
|
||||||
if (agentRow) {
|
if (agentRow) {
|
||||||
await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
|
// v2.6 (1.7): opencode routes to the warm pool backend; every other
|
||||||
|
// external agent keeps the existing one-shot ACP/PTY path untouched.
|
||||||
|
if (task.agent === 'opencode') {
|
||||||
|
await runOpenCodeServerTask(task, agentRow.install_path);
|
||||||
|
} else {
|
||||||
|
await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Agent specified but not available — fall through to Path A with a warning
|
// Agent specified but not available — fall through to Path A with a warning
|
||||||
@@ -456,6 +484,274 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Path B (opencode): warm OpenCode server backend (v2.6 1.7 + 1.10) ───────
|
||||||
|
|
||||||
|
// OpenCode runs ONE server per BooCoder process, shared across all sessions
|
||||||
|
// (the backend multiplexes sessions internally), so it's pooled under a fixed
|
||||||
|
// key rather than per-session. Warm ACP backends (Phase 2) will be per-session.
|
||||||
|
const OPENCODE_POOL_KEY = '__opencode_server__';
|
||||||
|
|
||||||
|
function getOpenCodeBackend(installPath: string | null): AgentBackend {
|
||||||
|
let backend = agentPool.get(OPENCODE_POOL_KEY, 'opencode');
|
||||||
|
if (!backend) {
|
||||||
|
backend = new OpenCodeServerBackend({ sql, log, opencodeBinary: installPath ?? 'opencode' });
|
||||||
|
agentPool.register(OPENCODE_POOL_KEY, 'opencode', backend);
|
||||||
|
}
|
||||||
|
return backend;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOpenCodeServerTask(
|
||||||
|
task: {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
input: string;
|
||||||
|
agent: string | null;
|
||||||
|
model: string | null;
|
||||||
|
mode_id: string | null;
|
||||||
|
thinking_option_id: string | null;
|
||||||
|
session_id: string | null;
|
||||||
|
},
|
||||||
|
installPath: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const taskId = task.id;
|
||||||
|
const agent = 'opencode';
|
||||||
|
log.info({ taskId, agent }, 'dispatcher: starting task (path B — opencode server)');
|
||||||
|
|
||||||
|
const [project] = await sql<{ path: string | null }[]>`
|
||||||
|
SELECT path FROM projects WHERE id = ${task.project_id}
|
||||||
|
`;
|
||||||
|
const projectPath = project?.path;
|
||||||
|
if (!projectPath) {
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks
|
||||||
|
SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no path — cannot create worktree'
|
||||||
|
WHERE id = ${taskId}
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ac = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// execution_path = 'acp' — the schema CHECK has no 'opencode_server' value
|
||||||
|
// (schema is frozen at Phase 0); the warm-vs-one-shot distinction lives in
|
||||||
|
// agent_sessions.backend. Reuse the closest existing value.
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks
|
||||||
|
SET state = 'running', started_at = clock_timestamp(), execution_path = 'acp'
|
||||||
|
WHERE id = ${taskId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Resolve session + chat (mirrors runExternalAgent).
|
||||||
|
let sessionId: string;
|
||||||
|
let chatId: string;
|
||||||
|
if (task.session_id) {
|
||||||
|
sessionId = task.session_id;
|
||||||
|
const chats = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
|
||||||
|
`;
|
||||||
|
if (chats.length === 0) {
|
||||||
|
const [chat] = await sql<{ id: string }[]>`
|
||||||
|
INSERT INTO chats (session_id, name, status)
|
||||||
|
VALUES (${sessionId}, 'External agent execution', 'open')
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
chatId = chat!.id;
|
||||||
|
} else {
|
||||||
|
chatId = chats[0]!.id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const sessionName = `Task [${agent}]: ${task.input.slice(0, 30)}`;
|
||||||
|
const [session] = await sql<{ id: string }[]>`
|
||||||
|
INSERT INTO sessions (project_id, name, model, status)
|
||||||
|
VALUES (${task.project_id}, ${sessionName}, ${task.model ?? config.DEFAULT_MODEL}, 'open')
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
sessionId = session!.id;
|
||||||
|
const [chat] = await sql<{ id: string }[]>`
|
||||||
|
INSERT INTO chats (session_id, name, status)
|
||||||
|
VALUES (${sessionId}, 'External agent execution', 'open')
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
chatId = chat!.id;
|
||||||
|
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!task.session_id) {
|
||||||
|
await sql`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persistent, session-keyed worktree (shared across turns; NOT torn down
|
||||||
|
// per turn — Phase 3 reaps it). Captures base_commit for a stable diff.
|
||||||
|
const { worktreePath, baseCommit } = await ensureSessionWorktree(sql, projectPath, sessionId, {
|
||||||
|
signal: ac.signal,
|
||||||
|
});
|
||||||
|
log.info({ taskId, worktreePath }, 'dispatcher: session worktree ready');
|
||||||
|
|
||||||
|
const [assistantMsg] = await sql<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
const assistantId = assistantMsg!.id;
|
||||||
|
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'message_started',
|
||||||
|
message_id: assistantId,
|
||||||
|
chat_id: chatId,
|
||||||
|
role: 'assistant',
|
||||||
|
} as WsFrame);
|
||||||
|
|
||||||
|
const manifestCommands = getManifestCommands(agent);
|
||||||
|
if (manifestCommands.length > 0) {
|
||||||
|
setTaskCommands(taskId, manifestCommands);
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'agent_commands',
|
||||||
|
task_id: taskId,
|
||||||
|
session_id: sessionId,
|
||||||
|
commands: manifestCommands,
|
||||||
|
} as WsFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accumulate the turn's stream for persistence + the final message content.
|
||||||
|
const textChunks: string[] = [];
|
||||||
|
const reasoningChunks: string[] = [];
|
||||||
|
const toolSnaps = new Map<string, AcpToolSnapshot>();
|
||||||
|
|
||||||
|
// Map transport-agnostic AgentEvents → the SAME WS frames the ACP path emits.
|
||||||
|
// This boundary is where message_id/chat_id get attached (the backend never
|
||||||
|
// owns them).
|
||||||
|
const onEvent = (e: AgentEvent): void => {
|
||||||
|
switch (e.type) {
|
||||||
|
case 'text':
|
||||||
|
textChunks.push(e.text);
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'delta',
|
||||||
|
message_id: assistantId,
|
||||||
|
chat_id: chatId,
|
||||||
|
content: e.text,
|
||||||
|
} as WsFrame);
|
||||||
|
break;
|
||||||
|
case 'reasoning':
|
||||||
|
reasoningChunks.push(e.text);
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'reasoning_delta',
|
||||||
|
message_id: assistantId,
|
||||||
|
chat_id: chatId,
|
||||||
|
content: e.text,
|
||||||
|
} as WsFrame);
|
||||||
|
break;
|
||||||
|
case 'tool_call':
|
||||||
|
case 'tool_update':
|
||||||
|
toolSnaps.set(e.toolCall.toolCallId, e.toolCall);
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'tool_call',
|
||||||
|
message_id: assistantId,
|
||||||
|
chat_id: chatId,
|
||||||
|
tool_call: snapshotToWireToolCall(e.toolCall),
|
||||||
|
} as WsFrame);
|
||||||
|
break;
|
||||||
|
case 'commands':
|
||||||
|
// opencode-server doesn't emit these today; ignore if it ever does.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// opencode expects provider-prefixed model ids (e.g. 'llama-swap/qwen3.6-35b…').
|
||||||
|
// DEFAULT_MODEL is bare (no prefix) because native inference uses it directly
|
||||||
|
// against llama-swap. Coalesce empty string (frontend sends '' when no models
|
||||||
|
// listed) and prefix bare ids so parseModel always succeeds.
|
||||||
|
const rawModel = (task.model && task.model.trim()) || config.DEFAULT_MODEL;
|
||||||
|
const model = rawModel.includes('/') ? rawModel : `llama-swap/${rawModel}`;
|
||||||
|
const backend = getOpenCodeBackend(installPath);
|
||||||
|
const handle = await backend.ensureSession(sessionId, {
|
||||||
|
agent,
|
||||||
|
model,
|
||||||
|
worktreePath,
|
||||||
|
projectId: task.project_id,
|
||||||
|
});
|
||||||
|
const result = await backend.prompt(handle, task.input, {
|
||||||
|
worktreePath,
|
||||||
|
model,
|
||||||
|
signal: ac.signal,
|
||||||
|
onEvent,
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantContent = textChunks.join('').slice(0, 50_000);
|
||||||
|
const reasoningText = reasoningChunks.join('').slice(0, 200_000);
|
||||||
|
const outputSummary = (result.ok ? textChunks.join('') : result.error ?? 'opencode turn failed').slice(0, 500);
|
||||||
|
|
||||||
|
await persistExternalAgentTurn(sql, assistantId, [...toolSnaps.values()], reasoningText);
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
UPDATE messages
|
||||||
|
SET content = ${assistantContent}, status = 'complete', finished_at = clock_timestamp()
|
||||||
|
WHERE id = ${assistantId}
|
||||||
|
`;
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'message_complete',
|
||||||
|
message_id: assistantId,
|
||||||
|
chat_id: chatId,
|
||||||
|
} as WsFrame);
|
||||||
|
|
||||||
|
if (stopping) {
|
||||||
|
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||||
|
return; // worktree persists (no cleanup); backend stays warm
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1.10: diff the persistent worktree against its captured baseline and
|
||||||
|
// SUPERSEDE the session's prior pending row (latest-wins, one accumulating
|
||||||
|
// diff) instead of stacking. Stamp agent for DiffPanel attribution.
|
||||||
|
const diff = await diffWorktree(worktreePath, projectPath, {
|
||||||
|
signal: ac.signal,
|
||||||
|
baseRef: baseCommit ?? 'HEAD',
|
||||||
|
});
|
||||||
|
if (diff) {
|
||||||
|
await sql`
|
||||||
|
DELETE FROM pending_changes WHERE session_id = ${sessionId} AND status = 'pending'
|
||||||
|
`;
|
||||||
|
await sql`
|
||||||
|
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff, agent)
|
||||||
|
VALUES (${sessionId}, ${taskId}, ${projectPath}, 'edit', ${diff}, ${agent})
|
||||||
|
`;
|
||||||
|
log.info({ taskId, diffLength: diff.length }, 'dispatcher: diff superseded prior pending change');
|
||||||
|
} else {
|
||||||
|
log.info({ taskId }, 'dispatcher: no changes detected in session worktree');
|
||||||
|
}
|
||||||
|
|
||||||
|
// NO worktree cleanup — it's persistent (Phase 3 reaps it). Backend stays warm.
|
||||||
|
|
||||||
|
const [extCostRow] = await sql<{ total: number | null }[]>`
|
||||||
|
SELECT SUM(tokens_used)::int AS total
|
||||||
|
FROM messages
|
||||||
|
WHERE session_id = ${sessionId} AND tokens_used IS NOT NULL
|
||||||
|
`;
|
||||||
|
const extCostTokens = extCostRow?.total ?? null;
|
||||||
|
|
||||||
|
const finalState = result.ok ? 'completed' : 'failed';
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks
|
||||||
|
SET state = ${finalState}, ended_at = clock_timestamp(), output_summary = ${outputSummary}, cost_tokens = ${extCostTokens}
|
||||||
|
WHERE id = ${taskId}
|
||||||
|
`;
|
||||||
|
log.info({ taskId, agent, finalState, costTokens: extCostTokens }, 'dispatcher: task finished (opencode server)');
|
||||||
|
clearTaskCommands(taskId);
|
||||||
|
} catch (err) {
|
||||||
|
const errMsg = err instanceof Error ? err.message : String(err);
|
||||||
|
log.error({ taskId, agent, err: errMsg }, 'dispatcher: opencode server error');
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks
|
||||||
|
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||||
|
WHERE id = ${taskId}
|
||||||
|
`.catch(() => {});
|
||||||
|
clearTaskCommands(taskId);
|
||||||
|
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function waitForCompletion(assistantId: string): Promise<string> {
|
async function waitForCompletion(assistantId: string): Promise<string> {
|
||||||
@@ -514,9 +810,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
|||||||
});
|
});
|
||||||
listener = null;
|
listener = null;
|
||||||
}
|
}
|
||||||
if (inflightPromise) {
|
if (inflight.size > 0) {
|
||||||
log.info('dispatcher: waiting for in-flight task');
|
log.info({ count: inflight.size }, 'dispatcher: waiting for in-flight tasks');
|
||||||
await inflightPromise;
|
await Promise.allSettled([...inflight.values()]);
|
||||||
}
|
}
|
||||||
log.info('dispatcher: stopped');
|
log.info('dispatcher: stopped');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export interface ResolvedProviderDef extends ProviderDef {
|
|||||||
env: Record<string, string> | undefined;
|
env: Record<string, string> | undefined;
|
||||||
configLabel?: string;
|
configLabel?: string;
|
||||||
configDescription?: 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 }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,6 +65,8 @@ export function buildResolvedRegistry(
|
|||||||
env: ov?.env,
|
env: ov?.env,
|
||||||
configLabel: ov?.label,
|
configLabel: ov?.label,
|
||||||
configDescription: ov?.description,
|
configDescription: ov?.description,
|
||||||
|
configModels: ov?.models,
|
||||||
|
configAdditionalModels: ov?.additionalModels,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +93,8 @@ export function buildResolvedRegistry(
|
|||||||
env: ov.env,
|
env: ov.env,
|
||||||
configLabel: ov.label,
|
configLabel: ov.label,
|
||||||
configDescription: ov.description,
|
configDescription: ov.description,
|
||||||
|
configModels: ov.models,
|
||||||
|
configAdditionalModels: ov.additionalModels,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,41 @@ export const CoderProvidersFileSchema = z.object({
|
|||||||
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
|
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
|
||||||
export type CoderProvidersFile = z.infer<typeof CoderProvidersFileSchema>;
|
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. */
|
/** Read + parse + validate. Falls back to built-ins-only on any failure; never throws. */
|
||||||
export function load(path: string): CoderProvidersFile {
|
export function load(path: string): CoderProvidersFile {
|
||||||
let raw: string;
|
let raw: string;
|
||||||
|
|||||||
71
apps/coder/src/services/provider-diagnostic.ts
Normal file
71
apps/coder/src/services/provider-diagnostic.ts
Normal 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');
|
||||||
|
}
|
||||||
@@ -41,9 +41,18 @@ export const PROVIDERS: ProviderDef[] = [
|
|||||||
label: 'Claude Code',
|
label: 'Claude Code',
|
||||||
transport: 'pty',
|
transport: 'pty',
|
||||||
modelSource: 'static',
|
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: [
|
staticModels: [
|
||||||
{ id: 'claude-opus-4-20250514', label: 'Opus 4' },
|
{ id: 'opus', label: 'Opus (latest)' },
|
||||||
{ id: 'claude-sonnet-4-20250514', label: 'Sonnet 4' },
|
{ 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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,23 +11,25 @@ import {
|
|||||||
PROVIDER_MANIFEST,
|
PROVIDER_MANIFEST,
|
||||||
} from './provider-manifest.js';
|
} from './provider-manifest.js';
|
||||||
import { probeAcpProvider } from './acp-probe.js';
|
import { probeAcpProvider } from './acp-probe.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 { getManifestCommands, mergeCommands } from './provider-commands.js';
|
||||||
import { readQwenSettingsModels } from './qwen-settings.js';
|
import { readQwenSettingsModels } from './qwen-settings.js';
|
||||||
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
|
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
|
||||||
import { isCommandAvailable } from './command-availability.js';
|
import { isCommandAvailable } from './command-availability.js';
|
||||||
|
import { discoverClaudeCommands } from './claude-command-discovery.js';
|
||||||
|
|
||||||
interface AgentRow {
|
interface AgentRow {
|
||||||
name: string;
|
name: string;
|
||||||
install_path: string | null;
|
install_path: string | null;
|
||||||
supports_acp: boolean;
|
supports_acp: boolean;
|
||||||
models: ProviderModel[] | null;
|
models: ProviderModel[] | null;
|
||||||
|
commands: AgentCommand[] | null;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
transport: string | null;
|
transport: string | null;
|
||||||
last_probed_at: string | Date | null;
|
last_probed_at: string | Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
|
export async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`);
|
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`);
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
@@ -82,9 +84,22 @@ async function buildProviderEntry(
|
|||||||
const fallbackModes = getManifestModes(name);
|
const fallbackModes = getManifestModes(name);
|
||||||
const defaultModeId = getManifestDefaultModeId(name);
|
const defaultModeId = getManifestDefaultModeId(name);
|
||||||
const manifestCommands = getManifestCommands(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 label = agentRow?.label ?? resolved.configLabel ?? resolved.label;
|
||||||
const descr = resolved.configDescription ? { description: resolved.configDescription } : {};
|
const descr = resolved.configDescription ? { description: resolved.configDescription } : {};
|
||||||
|
|
||||||
|
// 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.
|
// ACP built-ins fall back to PTY transport when the installed binary lacks ACP.
|
||||||
let transport = resolved.transport;
|
let transport = resolved.transport;
|
||||||
if (agentRow && resolved.transport === 'acp' && !agentRow.supports_acp) {
|
if (agentRow && resolved.transport === 'acp' && !agentRow.supports_acp) {
|
||||||
@@ -104,7 +119,7 @@ async function buildProviderEntry(
|
|||||||
if (isNative) {
|
if (isNative) {
|
||||||
return {
|
return {
|
||||||
name, label: resolved.label, transport, status: 'ready',
|
name, label: resolved.label, transport, status: 'ready',
|
||||||
enabled: true, installed: true, models: llamaModels, modes: [],
|
enabled: true, installed: true, models: withConfigModels(llamaModels), modes: [],
|
||||||
defaultModeId: null, commands: manifestCommands,
|
defaultModeId: null, commands: manifestCommands,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -135,10 +150,12 @@ async function buildProviderEntry(
|
|||||||
|
|
||||||
// claude: static models + thinking options, no ACP probe (unchanged from v2.2).
|
// claude: static models + thinking options, no ACP probe (unchanged from v2.2).
|
||||||
if (name === 'claude') {
|
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 {
|
return {
|
||||||
name, label, transport, status: 'ready', enabled: true, installed: true,
|
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||||
models: attachClaudeThinking(models), modes: fallbackModes, defaultModeId,
|
models: attachClaudeThinking(withConfigModels(models)), modes: fallbackModes, defaultModeId,
|
||||||
commands: manifestCommands,
|
commands: mergeCommands(manifestCommands, discoverClaudeCommands()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +182,7 @@ async function buildProviderEntry(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
name, label, transport, status: 'ready', enabled: true, installed: true,
|
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||||
models: skipModels, modes: fallbackModes, defaultModeId, commands: manifestCommands,
|
models: withConfigModels(skipModels), modes: fallbackModes, defaultModeId, commands: dbCommands,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +205,7 @@ async function buildProviderEntry(
|
|||||||
name, label, transport,
|
name, label, transport,
|
||||||
status: probe.ok ? 'ready' : 'error',
|
status: probe.ok ? 'ready' : 'error',
|
||||||
enabled: true, installed: true,
|
enabled: true, installed: true,
|
||||||
models: probeModels,
|
models: withConfigModels(probeModels),
|
||||||
modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
|
modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
|
||||||
defaultModeId: probe.defaultModeId ?? defaultModeId,
|
defaultModeId: probe.defaultModeId ?? defaultModeId,
|
||||||
commands: mergeCommands(manifestCommands, probe.commands),
|
commands: mergeCommands(manifestCommands, probe.commands),
|
||||||
@@ -203,27 +220,10 @@ async function buildProviderEntry(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
name, label, transport, status: 'ready', enabled: true, installed: true,
|
name, label, transport, status: 'ready', enabled: true, installed: true,
|
||||||
models, modes: fallbackModes, defaultModeId, commands: manifestCommands,
|
models: withConfigModels(models), modes: fallbackModes, defaultModeId, commands: dbCommands,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Synchronous placeholder entries for a cache-miss while the build runs (§4.4). */
|
|
||||||
function loadingEntries(): ProviderSnapshotEntry[] {
|
|
||||||
return [...getResolvedRegistry().values()].map((r) => ({
|
|
||||||
name: r.id,
|
|
||||||
label: r.configLabel ?? r.label,
|
|
||||||
...(r.configDescription ? { description: r.configDescription } : {}),
|
|
||||||
transport: r.transport,
|
|
||||||
status: r.enabled ? ('loading' as const) : ('unavailable' as const),
|
|
||||||
enabled: r.enabled,
|
|
||||||
installed: false,
|
|
||||||
models: [],
|
|
||||||
modes: getManifestModes(r.id),
|
|
||||||
defaultModeId: getManifestDefaultModeId(r.id),
|
|
||||||
commands: getManifestCommands(r.id),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshotCache = new Map<string, { at: number; entries: ProviderSnapshotEntry[] }>();
|
const snapshotCache = new Map<string, { at: number; entries: ProviderSnapshotEntry[] }>();
|
||||||
const snapshotInflight = new Map<string, Promise<ProviderSnapshotEntry[]>>();
|
const snapshotInflight = new Map<string, Promise<ProviderSnapshotEntry[]>>();
|
||||||
const CACHE_TTL_MS = 5 * 60_000;
|
const CACHE_TTL_MS = 5 * 60_000;
|
||||||
@@ -249,7 +249,7 @@ export async function getProviderSnapshot(
|
|||||||
const build = async (): Promise<ProviderSnapshotEntry[]> => {
|
const build = async (): Promise<ProviderSnapshotEntry[]> => {
|
||||||
const llamaModels = await fetchLlamaSwapModels(config);
|
const llamaModels = await fetchLlamaSwapModels(config);
|
||||||
const agents = await sql<AgentRow[]>`
|
const agents = await sql<AgentRow[]>`
|
||||||
SELECT name, install_path, supports_acp, models, label, transport, last_probed_at 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 agentMap = new Map(agents.map((a) => [a.name, a]));
|
||||||
const ttlMs = config.PROVIDER_PROBE_TTL_MS;
|
const ttlMs = config.PROVIDER_PROBE_TTL_MS;
|
||||||
@@ -269,14 +269,13 @@ export async function getProviderSnapshot(
|
|||||||
});
|
});
|
||||||
snapshotInflight.set(cacheKey, promise);
|
snapshotInflight.set(cacheKey, promise);
|
||||||
|
|
||||||
// force → await the full build (cold probes included). Non-force cache miss →
|
// Await the build (force or cache-miss) and return terminal entries. The sync
|
||||||
// return loading entries synchronously; the build settles in the background
|
// `loading` return (design §4.4) is DEFERRED until Phase 5 ships the client
|
||||||
// and the next call returns it via cache / inflight (§4.4; client polls).
|
// poll that resolves it: without that poll, a single fetch lands on
|
||||||
if (force) return promise;
|
// installed:false `loading` entries, which AgentComposerBar filters out
|
||||||
promise.catch(() => {
|
// (`e.installed && ...`) → empty picker. Builds stay fast via the tier-2 skip
|
||||||
/* settled errors surface on the next call that awaits inflight/rebuilds */
|
// once available_agents.models is warm.
|
||||||
});
|
return promise;
|
||||||
return loadingEntries();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearProviderSnapshotCache(): void {
|
export function clearProviderSnapshotCache(): void {
|
||||||
@@ -284,6 +283,16 @@ export function clearProviderSnapshotCache(): void {
|
|||||||
snapshotInflight.clear();
|
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. */
|
/** Persist probed model lists back to available_agents for fast legacy reads. */
|
||||||
export async function persistProbedModels(
|
export async function persistProbedModels(
|
||||||
sql: Sql,
|
sql: Sql,
|
||||||
@@ -292,16 +301,34 @@ export async function persistProbedModels(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.name === 'boocode' || entry.models.length === 0) continue;
|
if (entry.name === 'boocode') continue;
|
||||||
const flatModels = entry.models.map(({ id, label }) => ({ id, label }));
|
let persisted = false;
|
||||||
await sql`
|
if (entry.models.length > 0) {
|
||||||
UPDATE available_agents
|
const flatModels = entry.models.map(({ id, label }) => ({ id, label }));
|
||||||
SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp()
|
await sql`
|
||||||
WHERE name = ${entry.name}
|
UPDATE available_agents
|
||||||
`;
|
SET models = ${sql.json(flatModels as never)}, last_probed_at = clock_timestamp()
|
||||||
count++;
|
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) {
|
if (count > 0) {
|
||||||
log.info({ count }, 'provider-snapshot: persisted models to available_agents');
|
log.info({ count }, 'provider-snapshot: persisted models/commands to available_agents');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'erro
|
|||||||
export interface AgentCommand {
|
export interface AgentCommand {
|
||||||
name: string;
|
name: string;
|
||||||
description?: 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
|
// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity is
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* After the agent completes, we diff the worktree against HEAD and
|
* After the agent completes, we diff the worktree against HEAD and
|
||||||
* queue the diff into pending_changes.
|
* queue the diff into pending_changes.
|
||||||
*/
|
*/
|
||||||
|
import type { Sql } from '../db.js';
|
||||||
import { hostExec } from './host-exec.js';
|
import { hostExec } from './host-exec.js';
|
||||||
|
|
||||||
const WORKTREE_BASE = '/tmp/booworktrees';
|
const WORKTREE_BASE = '/tmp/booworktrees';
|
||||||
@@ -45,7 +46,7 @@ export async function createWorktree(
|
|||||||
export async function diffWorktree(
|
export async function diffWorktree(
|
||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
projectPath: string,
|
projectPath: string,
|
||||||
opts?: { signal?: AbortSignal },
|
opts?: { signal?: AbortSignal; baseRef?: string },
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// First, commit any uncommitted changes in the worktree so we can diff branches
|
// First, commit any uncommitted changes in the worktree so we can diff branches
|
||||||
// Stage all changes
|
// Stage all changes
|
||||||
@@ -74,9 +75,13 @@ export async function diffWorktree(
|
|||||||
{ signal: opts?.signal, timeoutMs: 15_000 },
|
{ signal: opts?.signal, timeoutMs: 15_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Diff the worktree branch against the parent commit (HEAD of main tree)
|
// Diff the worktree branch against the baseline. Per-task callers default to the
|
||||||
|
// main tree's current HEAD; the session-worktree (opencode) path passes the
|
||||||
|
// captured base_commit so the accumulated diff is stable across turns even if
|
||||||
|
// project HEAD advances.
|
||||||
|
const baseRef = opts?.baseRef ?? 'HEAD';
|
||||||
const diffResult = await hostExec(
|
const diffResult = await hostExec(
|
||||||
`git -C ${shellEscape(projectPath)} diff HEAD...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`,
|
`git -C ${shellEscape(projectPath)} diff ${shellEscape(baseRef)}...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`,
|
||||||
{ signal: opts?.signal, timeoutMs: 60_000 },
|
{ signal: opts?.signal, timeoutMs: 60_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -111,6 +116,72 @@ export async function cleanupWorktree(
|
|||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── v2.6: session-keyed persistent worktree ────────────────────────────────
|
||||||
|
|
||||||
|
export interface SessionWorktree {
|
||||||
|
worktreePath: string;
|
||||||
|
baseCommit: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2.6: create-or-reuse ONE worktree per BooCode session (shared across all
|
||||||
|
* agents/turns in the session), recorded in `session_worktrees`. Unlike the
|
||||||
|
* per-task `createWorktree`, this persists — it is NOT torn down per turn
|
||||||
|
* (cleanup is Phase 3). Captures the project's current HEAD as `base_commit`
|
||||||
|
* so the accumulating diff has a stable baseline across turns.
|
||||||
|
*
|
||||||
|
* Distinct path namespace (`session-<id>` branch, `/sess-<id>` dir) so it never
|
||||||
|
* collides with the per-task worktrees that arena/new_task/MCP still use.
|
||||||
|
*/
|
||||||
|
export async function ensureSessionWorktree(
|
||||||
|
sql: Sql,
|
||||||
|
projectPath: string,
|
||||||
|
sessionId: string,
|
||||||
|
opts?: { signal?: AbortSignal },
|
||||||
|
): Promise<SessionWorktree> {
|
||||||
|
const [existing] = await sql<{ worktree_path: string; base_commit: string | null }[]>`
|
||||||
|
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId}
|
||||||
|
`;
|
||||||
|
if (existing) {
|
||||||
|
return { worktreePath: existing.worktree_path, baseCommit: existing.base_commit };
|
||||||
|
}
|
||||||
|
|
||||||
|
const worktreePath = `${WORKTREE_BASE}/sess-${sessionId}`;
|
||||||
|
const branchName = `session-${sessionId}`;
|
||||||
|
|
||||||
|
await hostExec(`mkdir -p ${WORKTREE_BASE}`, { signal: opts?.signal });
|
||||||
|
|
||||||
|
// Capture the baseline commit BEFORE branching, so the diff is stable even if
|
||||||
|
// project HEAD later advances.
|
||||||
|
const headResult = await hostExec(
|
||||||
|
`git -C ${shellEscape(projectPath)} rev-parse HEAD`,
|
||||||
|
{ signal: opts?.signal, timeoutMs: 10_000 },
|
||||||
|
);
|
||||||
|
const baseCommit = headResult.exitCode === 0 ? headResult.stdout.trim() || null : null;
|
||||||
|
|
||||||
|
const result = await hostExec(
|
||||||
|
`git -C ${shellEscape(projectPath)} worktree add ${shellEscape(worktreePath)} -b ${shellEscape(branchName)} HEAD`,
|
||||||
|
{ signal: opts?.signal, timeoutMs: 30_000 },
|
||||||
|
);
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
throw new Error(`Failed to create session worktree: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist. ON CONFLICT keeps the first writer's row if two turns race the create.
|
||||||
|
await sql`
|
||||||
|
INSERT INTO session_worktrees (session_id, worktree_path, base_commit)
|
||||||
|
VALUES (${sessionId}, ${worktreePath}, ${baseCommit})
|
||||||
|
ON CONFLICT (session_id) DO NOTHING
|
||||||
|
`;
|
||||||
|
const [row] = await sql<{ worktree_path: string; base_commit: string | null }[]>`
|
||||||
|
SELECT worktree_path, base_commit FROM session_worktrees WHERE session_id = ${sessionId}
|
||||||
|
`;
|
||||||
|
return {
|
||||||
|
worktreePath: row?.worktree_path ?? worktreePath,
|
||||||
|
baseCommit: row?.base_commit ?? baseCommit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Minimal shell escape for paths (single-quote wrapping). */
|
/** Minimal shell escape for paths (single-quote wrapping). */
|
||||||
function shellEscape(s: string): string {
|
function shellEscape(s: string): string {
|
||||||
// Replace single quotes with escaped version, wrap in single quotes
|
// Replace single quotes with escaped version, wrap in single quotes
|
||||||
|
|||||||
@@ -37,9 +37,11 @@ export async function maybeAutoNameChat(
|
|||||||
if ((counts[0]?.n ?? 0) < 1) return;
|
if ((counts[0]?.n ?? 0) < 1) return;
|
||||||
|
|
||||||
const chatRows = await ctx.sql<
|
const chatRows = await ctx.sql<
|
||||||
{ id: string; name: string | null; session_id: string }[]
|
{ id: string; name: string | null; session_id: string; model: string | null }[]
|
||||||
>`
|
>`
|
||||||
SELECT id, name, session_id FROM chats WHERE id = ${chatId}
|
SELECT c.id, c.name, c.session_id, s.model
|
||||||
|
FROM chats c JOIN sessions s ON s.id = c.session_id
|
||||||
|
WHERE c.id = ${chatId}
|
||||||
`;
|
`;
|
||||||
const chat = chatRows[0];
|
const chat = chatRows[0];
|
||||||
if (!chat) return;
|
if (!chat) return;
|
||||||
@@ -67,6 +69,7 @@ export async function maybeAutoNameChat(
|
|||||||
user: namingInput,
|
user: namingInput,
|
||||||
maxTokens: 30,
|
maxTokens: 30,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
|
fallbackModel: chat.model ?? undefined,
|
||||||
});
|
});
|
||||||
const name = cleanTitle(raw);
|
const name = cleanTitle(raw);
|
||||||
if (!name) {
|
if (!name) {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import type {
|
|||||||
AskUserAnswer,
|
AskUserAnswer,
|
||||||
ToolCostStat,
|
ToolCostStat,
|
||||||
ProviderSnapshotEntry,
|
ProviderSnapshotEntry,
|
||||||
|
CoderProvidersFile,
|
||||||
|
ProviderConfigPatch,
|
||||||
CoderSendMessageBody,
|
CoderSendMessageBody,
|
||||||
CoderSendMessageResponse,
|
CoderSendMessageResponse,
|
||||||
CoderMessageWire,
|
CoderMessageWire,
|
||||||
@@ -310,8 +312,23 @@ export const api = {
|
|||||||
const qs = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '';
|
const qs = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '';
|
||||||
return request<ProviderSnapshotEntry[]>(`/api/coder/providers/snapshot${qs}`);
|
return request<ProviderSnapshotEntry[]>(`/api/coder/providers/snapshot${qs}`);
|
||||||
},
|
},
|
||||||
refreshProviders: () =>
|
// v2.3 Phase 4: optional subset narrows the reported `refreshed` count.
|
||||||
request<{ refreshed: number }>('/api/coder/providers/refresh', { method: 'POST' }),
|
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) =>
|
sendMessage: (sessionId: string, body: CoderSendMessageBody) =>
|
||||||
request<CoderSendMessageResponse>(`/api/coder/sessions/${sessionId}/messages`, {
|
request<CoderSendMessageResponse>(`/api/coder/sessions/${sessionId}/messages`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -332,18 +349,32 @@ export const api = {
|
|||||||
request<CoderMessageWire[]>(
|
request<CoderMessageWire[]>(
|
||||||
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
|
`/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<{
|
request<{
|
||||||
user_message_id: string;
|
user_message_id: string;
|
||||||
assistant_message_id: string;
|
assistant_message_id?: string;
|
||||||
synth_assistant_id: string;
|
synth_assistant_id?: string;
|
||||||
tool_message_id: string;
|
tool_message_id?: string;
|
||||||
|
task_id?: string;
|
||||||
|
dispatched?: boolean;
|
||||||
}>(`/api/coder/sessions/${sessionId}/skill_invoke`, {
|
}>(`/api/coder/sessions/${sessionId}/skill_invoke`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
pane_id: paneId,
|
pane_id: paneId,
|
||||||
skill_name: skillName,
|
skill_name: skillName,
|
||||||
user_message: userMessage,
|
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
|
// Queue a new-file create from the RightRail browser → BooCoder
|
||||||
|
|||||||
@@ -253,6 +253,31 @@ export interface ProviderSnapshotEntry {
|
|||||||
fetchedAt?: 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 {
|
export interface AgentSessionConfig {
|
||||||
provider: string;
|
provider: string;
|
||||||
model: string;
|
model: string;
|
||||||
@@ -273,6 +298,9 @@ export interface PermissionPrompt {
|
|||||||
export interface AgentCommand {
|
export interface AgentCommand {
|
||||||
name: string;
|
name: string;
|
||||||
description?: 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 {
|
export interface CoderSendMessageBody {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Check, ChevronDown, RefreshCw, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
|
import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
|
||||||
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
|
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
||||||
@@ -92,9 +92,11 @@ interface PickerProps {
|
|||||||
options: Array<{ id: string; label: string }>;
|
options: Array<{ id: string; label: string }>;
|
||||||
onPick: (id: string) => void;
|
onPick: (id: string) => void;
|
||||||
icon?: React.ReactNode;
|
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 { isMobile } = useViewport();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
|
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
|
||||||
@@ -129,7 +131,7 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker
|
|||||||
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"
|
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}
|
{icon}
|
||||||
<span className="truncate max-w-[120px]">{currentLabel}</span>
|
{!iconOnly && <span className="truncate max-w-[120px]">{currentLabel}</span>}
|
||||||
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
|
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
|
||||||
@@ -174,8 +176,12 @@ interface Props {
|
|||||||
|
|
||||||
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) {
|
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) {
|
||||||
const allEntries = useProviderSnapshot(projectPath);
|
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(
|
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],
|
[allEntries],
|
||||||
);
|
);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
@@ -198,6 +204,35 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
|||||||
onChange(resolveConfig(entry, prefs));
|
onChange(resolveConfig(entry, prefs));
|
||||||
}, [entries, onChange, value.provider]);
|
}, [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(
|
const currentEntry = useMemo(
|
||||||
() => entries?.find((e) => e.name === value.provider),
|
() => entries?.find((e) => e.name === value.provider),
|
||||||
[entries, value.provider],
|
[entries, value.provider],
|
||||||
@@ -281,7 +316,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
|||||||
value={value.provider}
|
value={value.provider}
|
||||||
options={providerOptions}
|
options={providerOptions}
|
||||||
onPick={pickProvider}
|
onPick={pickProvider}
|
||||||
icon={providerIcon(value.provider)}
|
icon={
|
||||||
|
currentEntry?.status === 'loading'
|
||||||
|
? <Loader2 size={13} className="shrink-0 animate-spin" />
|
||||||
|
: providerIcon(value.provider)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<CompactPicker
|
<CompactPicker
|
||||||
label="Mode"
|
label="Mode"
|
||||||
@@ -290,6 +329,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
|||||||
options={modeOptions}
|
options={modeOptions}
|
||||||
onPick={(modeId) => persist({ ...value, modeId })}
|
onPick={(modeId) => persist({ ...value, modeId })}
|
||||||
icon={<Shield className="size-3 shrink-0" />}
|
icon={<Shield className="size-3 shrink-0" />}
|
||||||
|
iconOnly
|
||||||
/>
|
/>
|
||||||
<CompactPicker
|
<CompactPicker
|
||||||
label="Model"
|
label="Model"
|
||||||
@@ -308,22 +348,26 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
|||||||
icon={<Brain className="size-3 shrink-0" />}
|
icon={<Brain className="size-3 shrink-0" />}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{connected !== undefined && (
|
{/* Status dot + refresh as one right-aligned unit so the refresh button
|
||||||
<span
|
stays on the top line instead of wrapping past the edge-pinned dot. */}
|
||||||
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0 ml-auto', connected ? 'bg-green-500' : 'bg-red-500')}
|
<div className="ml-auto flex items-center gap-1 shrink-0">
|
||||||
title={connected ? 'Connected' : 'Disconnected'}
|
{connected !== undefined && (
|
||||||
/>
|
<span
|
||||||
)}
|
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}
|
||||||
<button
|
title={connected ? 'Connected' : 'Disconnected'}
|
||||||
type="button"
|
/>
|
||||||
onClick={() => void handleRefresh()}
|
)}
|
||||||
disabled={refreshing}
|
<button
|
||||||
className={cn('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', connected === undefined && 'ml-auto')}
|
type="button"
|
||||||
aria-label="Refresh provider list"
|
onClick={() => void handleRefresh()}
|
||||||
title="Refresh providers"
|
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"
|
||||||
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
|
aria-label="Refresh provider list"
|
||||||
</button>
|
title="Refresh providers"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { DropOverlay } from '@/components/DropOverlay';
|
|||||||
import { AgentPicker } from '@/components/AgentPicker';
|
import { AgentPicker } from '@/components/AgentPicker';
|
||||||
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
|
import { AgentCommandsHint } from '@/components/AgentCommandsHint';
|
||||||
import { ContextBar } from '@/components/ContextBar';
|
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 { isSlashCommandToken, parseSlashInput, slashQuery } from '@/lib/slash-command';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { Message } from '@/api/types';
|
import type { Message } from '@/api/types';
|
||||||
@@ -56,6 +56,13 @@ interface Props {
|
|||||||
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
||||||
// disables slash-command dispatch (input is sent as literal text).
|
// disables slash-command dispatch (input is sent as literal text).
|
||||||
onSlashCommand?: (skillName: string, userMessage: string) => void | Promise<void>;
|
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
|
// v1.10.4: send-to-chat reverse path. When chatId is provided, this input
|
||||||
// registers in chatInputsRegistry so the terminal floating menu can list
|
// registers in chatInputsRegistry so the terminal floating menu can list
|
||||||
// it, and subscribes to sendToChat events scoped to this chatId. Receiving
|
// it, and subscribes to sendToChat events scoped to this chatId. Receiving
|
||||||
@@ -71,7 +78,7 @@ interface Props {
|
|||||||
modelContextLimit?: number | null;
|
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 { isMobile } = useViewport();
|
||||||
const [value, setValue] = useState('');
|
const [value, setValue] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -100,6 +107,15 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
for (const s of skills) m.set(s.name, true);
|
for (const s of skills) m.set(s.name, true);
|
||||||
return m;
|
return m;
|
||||||
}, [skills]);
|
}, [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 [fileIndex, setFileIndex] = useState<string[] | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
@@ -561,8 +577,8 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{skills.length > 0 && (
|
{slashItems.length > 0 && (
|
||||||
<AgentCommandsHint commands={skills.map((s) => ({ name: s.name, description: s.description }))} />
|
<AgentCommandsHint commands={slashItems} />
|
||||||
)}
|
)}
|
||||||
{/* Batch 9 toolbar — agent picker + quick-toggle menu. v1.11.5.1
|
{/* 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
|
inlines ContextBar in the same row so the bar lives next to the
|
||||||
@@ -661,11 +677,12 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
|||||||
{slashState && (
|
{slashState && (
|
||||||
<SlashCommandPicker
|
<SlashCommandPicker
|
||||||
query={slashState.query}
|
query={slashState.query}
|
||||||
items={skills}
|
items={slashItems}
|
||||||
|
groups={slashGroups}
|
||||||
inputRef={textareaRef}
|
inputRef={textareaRef}
|
||||||
onSelect={handleSlashSelect}
|
onSelect={handleSlashSelect}
|
||||||
onClose={() => setSlashState(null)}
|
onClose={() => setSlashState(null)}
|
||||||
emptyLabel="No skills available"
|
emptyLabel={slashGroups ? 'No commands available' : 'No skills available'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Code, History, MessageSquare, Plus, Terminal, X } from 'lucide-react';
|
import { Code, Columns2, History, MessageSquare, Plus, RotateCcw, Terminal, X } from 'lucide-react';
|
||||||
import type { Chat, WorkspacePane } from '@/api/types';
|
import type { Chat, WorkspacePane } from '@/api/types';
|
||||||
import { StatusDot } from '@/components/StatusDot';
|
import { StatusDot } from '@/components/StatusDot';
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +26,9 @@ interface Props {
|
|||||||
onCloseOthers: (chatId: string) => void;
|
onCloseOthers: (chatId: string) => void;
|
||||||
onCloseToRight: (chatId: string) => void;
|
onCloseToRight: (chatId: string) => void;
|
||||||
onCloseAll: () => void;
|
onCloseAll: () => void;
|
||||||
onAddPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
onNewTab: () => void;
|
||||||
|
onSplitPane: (kind: 'chat' | 'terminal' | 'coder') => void;
|
||||||
|
onReopenPane?: () => void;
|
||||||
onShowHistory: () => void;
|
onShowHistory: () => void;
|
||||||
onRename: (chatId: string, name: string) => Promise<void>;
|
onRename: (chatId: string, name: string) => Promise<void>;
|
||||||
onRemovePane?: () => void;
|
onRemovePane?: () => void;
|
||||||
@@ -40,7 +42,9 @@ export function ChatTabBar({
|
|||||||
onCloseOthers,
|
onCloseOthers,
|
||||||
onCloseToRight,
|
onCloseToRight,
|
||||||
onCloseAll,
|
onCloseAll,
|
||||||
onAddPane,
|
onNewTab,
|
||||||
|
onSplitPane,
|
||||||
|
onReopenPane,
|
||||||
onShowHistory,
|
onShowHistory,
|
||||||
onRename,
|
onRename,
|
||||||
onRemovePane,
|
onRemovePane,
|
||||||
@@ -131,7 +135,7 @@ export function ChatTabBar({
|
|||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem onSelect={() => onAddPane('chat')}>
|
<ContextMenuItem onSelect={onNewTab}>
|
||||||
New chat
|
New chat
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
<ContextMenuSeparator />
|
<ContextMenuSeparator />
|
||||||
@@ -170,29 +174,49 @@ export function ChatTabBar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
|
<div className="flex items-center ml-auto gap-0.5 px-1 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNewTab}
|
||||||
|
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
|
aria-label="New tab"
|
||||||
|
title="New tab"
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
</button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
aria-label="New pane"
|
aria-label="Split pane"
|
||||||
title="New pane"
|
title="Split pane"
|
||||||
>
|
>
|
||||||
<Plus size={12} />
|
<Columns2 size={12} />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-fit">
|
<DropdownMenuContent align="end" className="w-fit">
|
||||||
<DropdownMenuItem onSelect={() => onAddPane('chat')}>
|
<DropdownMenuItem onSelect={() => onSplitPane('chat')}>
|
||||||
<MessageSquare size={14} /> New BooChat
|
<MessageSquare size={14} /> New BooChat
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => onAddPane('terminal')}>
|
<DropdownMenuItem onSelect={() => onSplitPane('terminal')}>
|
||||||
<Terminal size={14} /> New BooTerm
|
<Terminal size={14} /> New BooTerm
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onSelect={() => onAddPane('coder')}>
|
<DropdownMenuItem onSelect={() => onSplitPane('coder')}>
|
||||||
<Code size={14} /> New BooCode
|
<Code size={14} /> New BooCode
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
{onReopenPane && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReopenPane}
|
||||||
|
className="inline-flex items-center justify-center p-1 rounded text-muted-foreground hover:bg-muted hover:text-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||||
|
aria-label="Reopen closed pane"
|
||||||
|
title="Reopen closed pane"
|
||||||
|
>
|
||||||
|
<RotateCcw size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onShowHistory}
|
onClick={onShowHistory}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api/client';
|
|
||||||
import { ChatInput } from '@/components/ChatInput';
|
import { ChatInput } from '@/components/ChatInput';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,6 +8,10 @@ interface Props {
|
|||||||
agentId?: string | null;
|
agentId?: string | null;
|
||||||
onAgentChange?: (agentId: string | null) => void | Promise<void>;
|
onAgentChange?: (agentId: string | null) => void | Promise<void>;
|
||||||
onSend: (content: string) => void;
|
onSend: (content: string) => void;
|
||||||
|
// 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 }>;
|
createChat: () => Promise<{ id: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +21,7 @@ export function SessionLandingPage({
|
|||||||
agentId,
|
agentId,
|
||||||
onAgentChange,
|
onAgentChange,
|
||||||
onSend,
|
onSend,
|
||||||
|
onSkillInvoke,
|
||||||
createChat,
|
createChat,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [chatId, setChatId] = useState<string | null>(null);
|
const [chatId, setChatId] = useState<string | null>(null);
|
||||||
@@ -45,14 +49,13 @@ export function SessionLandingPage({
|
|||||||
}
|
}
|
||||||
}, [ensureChat, onSend]);
|
}, [ensureChat, onSend]);
|
||||||
|
|
||||||
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => {
|
// Route to the parent, which creates the chat, assigns it to the pane (so the
|
||||||
try {
|
// pane transitions to ChatPane and subscribes to the stream), then invokes the
|
||||||
const cid = await ensureChat();
|
// skill — mirroring the text-send transition. Doing the skill invoke locally
|
||||||
await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null);
|
// (without the pane assignment) left the landing pane stuck/blank.
|
||||||
} catch (err) {
|
const handleSlashCommand = useCallback((skillName: string, userMessage: string) => {
|
||||||
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
|
onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
|
||||||
}
|
}, [onSkillInvoke]);
|
||||||
}, [ensureChat]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
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 { createPortal } from 'react-dom';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -8,9 +8,19 @@ export interface SlashCommandItem {
|
|||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SlashCommandGroup {
|
||||||
|
label: string;
|
||||||
|
items: SlashCommandItem[];
|
||||||
|
icon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
query: string;
|
query: string;
|
||||||
items: SlashCommandItem[];
|
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>;
|
inputRef: RefObject<HTMLElement | null>;
|
||||||
onSelect: (name: string) => void;
|
onSelect: (name: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -28,6 +38,7 @@ function filterByPrefix(items: SlashCommandItem[], query: string): SlashCommandI
|
|||||||
export function SlashCommandPicker({
|
export function SlashCommandPicker({
|
||||||
query,
|
query,
|
||||||
items,
|
items,
|
||||||
|
groups,
|
||||||
inputRef,
|
inputRef,
|
||||||
onSelect,
|
onSelect,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -35,7 +46,21 @@ export function SlashCommandPicker({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [highlightIndex, setHighlightIndex] = useState(0);
|
const [highlightIndex, setHighlightIndex] = useState(0);
|
||||||
const popoverRef = useRef<HTMLDivElement>(null);
|
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>(
|
const [rect, setRect] = useState<DOMRect | null>(
|
||||||
() => inputRef.current?.getBoundingClientRect() ?? null,
|
() => inputRef.current?.getBoundingClientRect() ?? null,
|
||||||
@@ -130,6 +155,36 @@ export function SlashCommandPicker({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [rect, vvTick]);
|
}, [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 ? (
|
const popover = filtered.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
ref={popoverRef}
|
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"
|
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}
|
style={style}
|
||||||
>
|
>
|
||||||
{filtered.map((item, i) => (
|
{filteredGroups
|
||||||
<div
|
? filteredGroups.map((g) => (
|
||||||
key={item.name}
|
<div key={g.label}>
|
||||||
role="option"
|
<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">
|
||||||
aria-selected={i === highlightIndex}
|
{g.icon}
|
||||||
data-highlighted={i === highlightIndex}
|
{g.label}
|
||||||
className={cn(
|
</div>
|
||||||
'w-full text-left px-2.5 py-2 cursor-pointer block',
|
{g.items.map((item) => renderItem(item, (runningIndex += 1)))}
|
||||||
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>
|
||||||
)}
|
))
|
||||||
</div>
|
: filtered.map((item, i) => renderItem(item, i))}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export function Workspace({
|
|||||||
showLandingPage,
|
showLandingPage,
|
||||||
addSplitPane,
|
addSplitPane,
|
||||||
removePane,
|
removePane,
|
||||||
|
reopenPane,
|
||||||
|
hasClosedPanes,
|
||||||
isPaneChatPending,
|
isPaneChatPending,
|
||||||
handlePaneDragStart,
|
handlePaneDragStart,
|
||||||
handlePaneDragOver,
|
handlePaneDragOver,
|
||||||
@@ -82,6 +84,7 @@ export function Workspace({
|
|||||||
deleteChat,
|
deleteChat,
|
||||||
renameChat,
|
renameChat,
|
||||||
handleLandingSend,
|
handleLandingSend,
|
||||||
|
handleLandingSkill,
|
||||||
} = chatsHook;
|
} = chatsHook;
|
||||||
|
|
||||||
const { isMobile } = useViewport();
|
const { isMobile } = useViewport();
|
||||||
@@ -206,10 +209,9 @@ export function Workspace({
|
|||||||
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
onCloseOthers={(chatId) => closeOtherTabs(idx, chatId)}
|
||||||
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
|
onCloseToRight={(chatId) => closeTabsToRight(idx, chatId)}
|
||||||
onCloseAll={() => closeAllTabs(idx)}
|
onCloseAll={() => closeAllTabs(idx)}
|
||||||
onAddPane={(kind) => {
|
onNewTab={() => void createChat(idx)}
|
||||||
if (kind === 'chat') void createChat(idx);
|
onSplitPane={(kind) => onAddPane(kind)}
|
||||||
else onAddPane(kind);
|
onReopenPane={hasClosedPanes ? reopenPane : undefined}
|
||||||
}}
|
|
||||||
onShowHistory={() => showLandingPage(idx)}
|
onShowHistory={() => showLandingPage(idx)}
|
||||||
onRename={renameChat}
|
onRename={renameChat}
|
||||||
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
onRemovePane={panes.length > 1 ? () => removePane(idx) : undefined}
|
||||||
@@ -387,6 +389,7 @@ export function Workspace({
|
|||||||
onAgentChange={onAgentChange}
|
onAgentChange={onAgentChange}
|
||||||
createChat={() => api.chats.create(sessionId)}
|
createChat={() => api.chats.create(sessionId)}
|
||||||
onSend={(content) => void handleLandingSend(idx, content)}
|
onSend={(content) => void handleLandingSend(idx, content)}
|
||||||
|
onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
139
apps/web/src/components/coder/AddProviderModal.tsx
Normal file
139
apps/web/src/components/coder/AddProviderModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
apps/web/src/components/coder/ProvidersSettings.tsx
Normal file
218
apps/web/src/components/coder/ProvidersSettings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,10 +4,11 @@
|
|||||||
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
|
// WS: /api/coder/ws/sessions/:id (Vite dev proxies to :9502).
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Code, Check, X, RefreshCw } from 'lucide-react';
|
import { Code, Check, X, RefreshCw, Terminal, Puzzle, Sparkles } from 'lucide-react';
|
||||||
import { AgentComposerBar } from '@/components/AgentComposerBar';
|
import { AgentComposerBar } from '@/components/AgentComposerBar';
|
||||||
import { PermissionCard } from '@/components/PermissionCard';
|
import { PermissionCard } from '@/components/PermissionCard';
|
||||||
import { ChatInput } from '@/components/ChatInput';
|
import { ChatInput } from '@/components/ChatInput';
|
||||||
|
import type { SlashCommandGroup } from '@/components/SlashCommandPicker';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
|
import type { AgentSessionConfig, PermissionPrompt, AgentCommand } from '@/api/types';
|
||||||
import { useSkills } from '@/hooks/useSkills';
|
import { useSkills } from '@/hooks/useSkills';
|
||||||
@@ -510,6 +511,50 @@ export function CoderPane({
|
|||||||
[displayedCommands],
|
[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, {
|
const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
|
||||||
onConnectedChange,
|
onConnectedChange,
|
||||||
onPermissionRequested: (prompt) => {
|
onPermissionRequested: (prompt) => {
|
||||||
@@ -736,19 +781,35 @@ export function CoderPane({
|
|||||||
|
|
||||||
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
|
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
|
||||||
if (!chatId) return;
|
if (!chatId) return;
|
||||||
if (agentConfig.provider === 'boocode' && skillsByName.has(skillName)) {
|
// Only BooCoder skills route here; an agent's own commands (not skills) fall
|
||||||
setSending(true);
|
// through to a literal send in ChatInput. Skills run under the active
|
||||||
setPermissionPrompt(null);
|
// provider: boocode → native inference; external → body injected into a task.
|
||||||
setLiveTaskCommands([]);
|
if (!skillsByName.has(skillName)) return;
|
||||||
try {
|
setSending(true);
|
||||||
await api.coder.skillInvoke(sessionId, paneId, skillName, userMessage.length > 0 ? userMessage : null);
|
setPermissionPrompt(null);
|
||||||
} catch (err) {
|
setLiveTaskCommands([]);
|
||||||
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
|
try {
|
||||||
} finally {
|
const data = await api.coder.skillInvoke(
|
||||||
setSending(false);
|
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.provider, skillsByName]);
|
}, [chatId, sessionId, paneId, agentConfig, skillsByName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-background">
|
<div className="flex flex-col h-full bg-background">
|
||||||
@@ -810,6 +871,7 @@ export function CoderPane({
|
|||||||
projectId={projectPath ?? ''}
|
projectId={projectPath ?? ''}
|
||||||
onSend={handleChatInputSend}
|
onSend={handleChatInputSend}
|
||||||
onSlashCommand={handleChatInputSlash}
|
onSlashCommand={handleChatInputSlash}
|
||||||
|
slashGroups={slashGroups}
|
||||||
chatId={chatId ?? undefined}
|
chatId={chatId ?? undefined}
|
||||||
chatLabel="BooCode"
|
chatLabel="BooCode"
|
||||||
messages={messages as unknown as import('@/api/types').Message[]}
|
messages={messages as unknown as import('@/api/types').Message[]}
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { ModelPicker } from '@/components/ModelPicker';
|
import { ModelPicker } from '@/components/ModelPicker';
|
||||||
import { ThemePicker } from '@/components/ThemePicker';
|
import { ThemePicker } from '@/components/ThemePicker';
|
||||||
|
import { ProvidersSettings } from '@/components/coder/ProvidersSettings';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type Section = 'session' | 'project' | 'theme';
|
type Section = 'session' | 'project' | 'theme' | 'providers';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
session: Session;
|
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 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-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">
|
<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
|
<button
|
||||||
key={s}
|
key={s}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -116,6 +117,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
|
|||||||
{activeSection === 'session' && <SessionSection session={session} project={project} />}
|
{activeSection === 'session' && <SessionSection session={session} project={project} />}
|
||||||
{activeSection === 'project' && <ProjectSection project={project} />}
|
{activeSection === 'project' && <ProjectSection project={project} />}
|
||||||
{activeSection === 'theme' && <ThemePicker />}
|
{activeSection === 'theme' && <ThemePicker />}
|
||||||
|
{activeSection === 'providers' && <ProvidersSettings />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
83
apps/web/src/data/acp-provider-catalog.ts
Normal file
83
apps/web/src/data/acp-provider-catalog.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ export interface UseSessionChatsResult {
|
|||||||
deleteChat: (chatId: string) => Promise<void>;
|
deleteChat: (chatId: string) => Promise<void>;
|
||||||
renameChat: (chatId: string, name: string) => Promise<void>;
|
renameChat: (chatId: string, name: string) => Promise<void>;
|
||||||
handleLandingSend: (paneIdx: number, content: string) => Promise<void>;
|
handleLandingSend: (paneIdx: number, content: string) => Promise<void>;
|
||||||
|
handleLandingSkill: (paneIdx: number, skillName: string, userMessage: string | null) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSessionChats(
|
export function useSessionChats(
|
||||||
@@ -166,6 +167,25 @@ export function useSessionChats(
|
|||||||
}
|
}
|
||||||
}, [sessionId]);
|
}, [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 {
|
return {
|
||||||
chats,
|
chats,
|
||||||
setChats,
|
setChats,
|
||||||
@@ -175,5 +195,6 @@ export function useSessionChats(
|
|||||||
deleteChat,
|
deleteChat,
|
||||||
renameChat,
|
renameChat,
|
||||||
handleLandingSend,
|
handleLandingSend,
|
||||||
|
handleLandingSkill,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,21 @@ function chatPane(chatId: string): WorkspacePane {
|
|||||||
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
return { id: generateId(), kind: 'chat', chatId, chatIds: [chatId], activeChatIdx: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ClosedPaneEntry {
|
||||||
|
kind: WorkspacePane['kind'];
|
||||||
|
chatIds: string[];
|
||||||
|
activeChatIdx: number;
|
||||||
|
}
|
||||||
|
const MAX_CLOSED = 10;
|
||||||
|
const closedPaneStack: ClosedPaneEntry[] = [];
|
||||||
|
|
||||||
|
function pushClosed(pane: WorkspacePane): void {
|
||||||
|
if (pane.kind === 'empty' || pane.kind === 'settings') return;
|
||||||
|
if (pane.chatIds.length === 0) return;
|
||||||
|
closedPaneStack.push({ kind: pane.kind, chatIds: [...pane.chatIds], activeChatIdx: pane.activeChatIdx });
|
||||||
|
if (closedPaneStack.length > MAX_CLOSED) closedPaneStack.shift();
|
||||||
|
}
|
||||||
|
|
||||||
function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
|
function chatNameForPaneKind(kind: 'coder' | 'terminal'): string {
|
||||||
return kind === 'coder' ? 'BooCoder' : 'Terminal';
|
return kind === 'coder' ? 'BooCoder' : 'Terminal';
|
||||||
}
|
}
|
||||||
@@ -50,8 +65,8 @@ export function activePaneChatId(pane: WorkspacePane): string | undefined {
|
|||||||
// v1.9: settings pane factory. No chats, no state beyond identity — the
|
// v1.9: settings pane factory. No chats, no state beyond identity — the
|
||||||
// SettingsPane component renders Session/Project sections from the
|
// SettingsPane component renders Session/Project sections from the
|
||||||
// surrounding session/project.
|
// surrounding session/project.
|
||||||
function settingsPane(): WorkspacePane {
|
function settingsPane(id: string = generateId()): WorkspacePane {
|
||||||
return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 };
|
return { id, kind: 'settings', chatIds: [], activeChatIdx: -1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// v1.14.x-html-artifact-panes: artifact pane factories. Payload travels with
|
// v1.14.x-html-artifact-panes: artifact pane factories. Payload travels with
|
||||||
@@ -135,8 +150,10 @@ export interface UseWorkspacePanesResult {
|
|||||||
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
||||||
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
|
// 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.
|
// falls back to an empty pane to preserve the "always one pane" invariant.
|
||||||
toggleSettingsPane: () => void;
|
toggleSettingsPane: () => string | null;
|
||||||
removePane: (idx: number) => void;
|
removePane: (idx: number) => void;
|
||||||
|
reopenPane: () => void;
|
||||||
|
hasClosedPanes: boolean;
|
||||||
removeChatFromPanes: (chatId: string) => void;
|
removeChatFromPanes: (chatId: string) => void;
|
||||||
initializeFirstChatIfEmpty: (chatId: string) => void;
|
initializeFirstChatIfEmpty: (chatId: string) => void;
|
||||||
validatePanes: (validChatIds: Set<string>) => void;
|
validatePanes: (validChatIds: Set<string>) => void;
|
||||||
@@ -391,6 +408,14 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
const pane = next[paneIdx]!;
|
const pane = next[paneIdx]!;
|
||||||
const nextIds = pane.chatIds.filter((id) => id !== chatId);
|
const nextIds = pane.chatIds.filter((id) => id !== chatId);
|
||||||
if (nextIds.length === 0) {
|
if (nextIds.length === 0) {
|
||||||
|
if (next.length > 1) {
|
||||||
|
// Last tab closed and other panes exist — remove the whole pane
|
||||||
|
// instead of leaving an orphaned empty panel.
|
||||||
|
pushClosed(pane); setHasClosedPanes(true);
|
||||||
|
const spliced = next.filter((_, i) => i !== paneIdx);
|
||||||
|
setActivePaneIdx((ai) => Math.min(ai, spliced.length - 1));
|
||||||
|
return spliced;
|
||||||
|
}
|
||||||
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
next[paneIdx] = { ...pane, kind: 'empty', chatId: undefined, chatIds: [], activeChatIdx: -1 };
|
||||||
} else {
|
} else {
|
||||||
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
|
const nextActiveIdx = Math.min(pane.activeChatIdx, nextIds.length - 1);
|
||||||
@@ -492,14 +517,21 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
return success ? newPaneId : null;
|
return success ? newPaneId : null;
|
||||||
}, [seedPaneChat]);
|
}, [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) => {
|
setPanes((prev) => {
|
||||||
const existingIdx = prev.findIndex((p) => p.kind === 'settings');
|
const existingIdx = prev.findIndex((p) => p.kind === 'settings');
|
||||||
if (existingIdx < 0) {
|
if (existingIdx < 0) {
|
||||||
const next = [...prev, settingsPane()];
|
const next = [...prev, settingsPane(newPaneId)];
|
||||||
setActivePaneIdx(next.length - 1);
|
setActivePaneIdx(next.length - 1);
|
||||||
|
openedId = newPaneId;
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
openedId = null;
|
||||||
if (prev.length <= 1) {
|
if (prev.length <= 1) {
|
||||||
setActivePaneIdx(0);
|
setActivePaneIdx(0);
|
||||||
return [emptyPane()];
|
return [emptyPane()];
|
||||||
@@ -508,6 +540,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
return openedId;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const removePane = useCallback((idx: number) => {
|
const removePane = useCallback((idx: number) => {
|
||||||
@@ -526,6 +559,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
// The endpoint is idempotent (404 on missing session) so a strict-mode
|
// The endpoint is idempotent (404 on missing session) so a strict-mode
|
||||||
// double-invoke of the updater is safe.
|
// double-invoke of the updater is safe.
|
||||||
const removed = prev[idx];
|
const removed = prev[idx];
|
||||||
|
if (removed) { pushClosed(removed); setHasClosedPanes(true); }
|
||||||
if (removed?.kind === 'terminal') {
|
if (removed?.kind === 'terminal') {
|
||||||
api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ });
|
api.terminals.kill(sessionId, removed.id).catch(() => { /* non-fatal */ });
|
||||||
}
|
}
|
||||||
@@ -535,6 +569,26 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
});
|
});
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
|
const [hasClosedPanes, setHasClosedPanes] = useState(closedPaneStack.length > 0);
|
||||||
|
|
||||||
|
const reopenPane = useCallback(() => {
|
||||||
|
const entry = closedPaneStack.pop();
|
||||||
|
setHasClosedPanes(closedPaneStack.length > 0);
|
||||||
|
if (!entry) return;
|
||||||
|
setPanes((prev) => {
|
||||||
|
const restored: WorkspacePane = {
|
||||||
|
id: generateId(),
|
||||||
|
kind: entry.kind,
|
||||||
|
chatId: entry.chatIds[entry.activeChatIdx] ?? entry.chatIds[0],
|
||||||
|
chatIds: entry.chatIds,
|
||||||
|
activeChatIdx: Math.min(entry.activeChatIdx, entry.chatIds.length - 1),
|
||||||
|
};
|
||||||
|
const next = [...prev, restored];
|
||||||
|
setActivePaneIdx(next.length - 1);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Replaces a single empty default pane with a chat pane. Used by the initial
|
// Replaces a single empty default pane with a chat pane. Used by the initial
|
||||||
// chat fetch to land on the most-recent open chat if no saved pane state.
|
// chat fetch to land on the most-recent open chat if no saved pane state.
|
||||||
const initializeFirstChatIfEmpty = useCallback((chatId: string) => {
|
const initializeFirstChatIfEmpty = useCallback((chatId: string) => {
|
||||||
@@ -664,6 +718,8 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
|||||||
addSplitPane,
|
addSplitPane,
|
||||||
toggleSettingsPane,
|
toggleSettingsPane,
|
||||||
removePane,
|
removePane,
|
||||||
|
reopenPane,
|
||||||
|
hasClosedPanes,
|
||||||
removeChatFromPanes,
|
removeChatFromPanes,
|
||||||
initializeFirstChatIfEmpty,
|
initializeFirstChatIfEmpty,
|
||||||
validatePanes,
|
validatePanes,
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ export function parseSlashInput(text: string): { cmdName: string; args: string }
|
|||||||
return { cmdName: match[1]!, args: (match[2] ?? '').trim() };
|
return { cmdName: match[1]!, args: (match[2] ?? '').trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mergeCommandsByName(...lists: SlashCommandItem[][]): SlashCommandItem[] {
|
export function mergeCommandsByName<T extends SlashCommandItem>(...lists: T[][]): T[] {
|
||||||
const byName = new Map<string, SlashCommandItem>();
|
const byName = new Map<string, T>();
|
||||||
for (const list of lists) {
|
for (const list of lists) {
|
||||||
for (const cmd of list) {
|
for (const cmd of list) {
|
||||||
byName.set(cmd.name, cmd);
|
byName.set(cmd.name, cmd);
|
||||||
|
|||||||
@@ -123,6 +123,20 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
};
|
};
|
||||||
}, [sessionId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
return sessionEvents.subscribe((event) => {
|
return sessionEvents.subscribe((event) => {
|
||||||
if (event.type === 'session_renamed' && event.session_id === sessionId) {
|
if (event.type === 'session_renamed' && event.session_id === sessionId) {
|
||||||
@@ -156,10 +170,10 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
|||||||
// Sidebar Settings button broadcasts this when a session is mounted;
|
// Sidebar Settings button broadcasts this when a session is mounted;
|
||||||
// toggleSettingsPane opens on first click, closes on second.
|
// toggleSettingsPane opens on first click, closes on second.
|
||||||
if (event.type === 'open_settings_pane') {
|
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
|
// v1.8: URL ?pane= sync (mobile only). Lifted from Workspace.tsx so
|
||||||
// MobileTabSwitcher's onSwitchPane can push the same URL state and the
|
// MobileTabSwitcher's onSwitchPane can push the same URL state and the
|
||||||
|
|||||||
12
data/coder-providers.example.json
Normal file
12
data/coder-providers.example.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"providers": {
|
||||||
|
"goose": { "enabled": false },
|
||||||
|
"amp-acp": {
|
||||||
|
"extends": "acp",
|
||||||
|
"label": "Amp",
|
||||||
|
"description": "ACP wrapper for Amp",
|
||||||
|
"command": ["amp-acp"],
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"providers": {}
|
|
||||||
}
|
|
||||||
61
data/skills/boocode/systematic-debugging/SKILL.md
Normal file
61
data/skills/boocode/systematic-debugging/SKILL.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
name: systematic-debugging
|
||||||
|
description: Guided root-cause debugging. Use when encountering any bug, test failure, unexpected behavior, or performance problem. Enforces investigation before fixes.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Systematic Debugging
|
||||||
|
|
||||||
|
No fixes without root cause. Symptom fixes mask real bugs and waste time.
|
||||||
|
|
||||||
|
## The Rule
|
||||||
|
|
||||||
|
Complete Phase 1 before proposing ANY fix. If you haven't investigated, you cannot fix.
|
||||||
|
|
||||||
|
## Phase 1: Root Cause Investigation
|
||||||
|
|
||||||
|
1. **Read error messages carefully.** Stack traces, line numbers, error codes. Don't skip past them.
|
||||||
|
2. **Reproduce consistently.** Exact steps, every time. If not reproducible, gather more data instead of guessing.
|
||||||
|
3. **Check recent changes.** Git diff, recent commits, new deps, config changes, env differences.
|
||||||
|
4. **Trace data flow.** Where does the bad value originate? Trace backward through the call stack to the source. Fix at the source, not the symptom.
|
||||||
|
5. **Multi-component systems:** Before fixing, add diagnostic logging at each component boundary (what enters, what exits). Run once to locate the failing layer, THEN investigate that layer.
|
||||||
|
|
||||||
|
## Phase 2: Pattern Analysis
|
||||||
|
|
||||||
|
1. Find working examples of similar code in the same codebase.
|
||||||
|
2. Compare working vs broken — list every difference.
|
||||||
|
3. If implementing a pattern, read the reference implementation completely, not skimmed.
|
||||||
|
4. Understand all dependencies, config, and assumptions.
|
||||||
|
|
||||||
|
## Phase 3: Hypothesis and Testing
|
||||||
|
|
||||||
|
1. State one hypothesis clearly: "X is the root cause because Y."
|
||||||
|
2. Make the smallest possible change to test it. One variable at a time.
|
||||||
|
3. If it didn't work, form a NEW hypothesis. Don't stack more fixes on top.
|
||||||
|
4. After 3 failed fixes: STOP. Question the architecture, not the symptoms.
|
||||||
|
|
||||||
|
## Phase 4: Implementation
|
||||||
|
|
||||||
|
1. Create a failing test case first (simplest reproduction).
|
||||||
|
2. Implement a single fix addressing the root cause.
|
||||||
|
3. Verify: test passes, no regressions, issue actually resolved.
|
||||||
|
4. If the fix doesn't work and you've tried 3+: the problem is architectural. Discuss before attempting more.
|
||||||
|
|
||||||
|
## Red Flags — STOP and return to Phase 1
|
||||||
|
|
||||||
|
- "Quick fix for now, investigate later"
|
||||||
|
- "Just try changing X and see"
|
||||||
|
- "I don't fully understand but this might work"
|
||||||
|
- "One more fix attempt" after 2+ failures
|
||||||
|
- Proposing solutions before tracing data flow
|
||||||
|
- Each fix reveals a new problem in a different place
|
||||||
|
|
||||||
|
## Apply This Skill
|
||||||
|
|
||||||
|
Use these tools to investigate before proposing changes:
|
||||||
|
|
||||||
|
- `view_file` to read error sites and suspect code paths
|
||||||
|
- `grep` to find all callers / references to the failing function
|
||||||
|
- `find_files` to locate related config, test fixtures, schema
|
||||||
|
- `list_dir` to understand the module layout around the bug
|
||||||
|
|
||||||
|
Report your Phase 1 findings (what you observed, what you traced, what you ruled out) before moving to Phase 3.
|
||||||
@@ -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.
|
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 |
|
| 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 |
|
| 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 5–30s 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 5–30s on cache miss | ✅ Shipped (v2.3, Phase 2) | Resolved — `PROVIDER_PROBE_TTL_MS` TTL gate live |
|
||||||
| Unified `packages/types` | Maintainability | Low (dev-only) | Medium–High | Type drift between server, coder, web |
|
| Unified `packages/types` | Maintainability | Low (dev-only) | Medium–High | Type drift between server, coder, web |
|
||||||
| Large file splits | Maintainability | None directly | Medium per file | Harder reviews, merge conflicts |
|
| 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 |
|
| 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
|
## 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 1–5: `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)
|
### 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.
|
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)
|
- Second `GET /api/providers/snapshot` within TTL does not invoke `probeAcpProvider` (mock assert in `provider-snapshot.test.ts`)
|
||||||
- Disabled provider visible in settings, absent from composer
|
- Disabled provider visible in settings (Providers tab), absent from composer
|
||||||
- Explicit refresh repopulates models; warm open is sub-second
|
- 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
|
## 3. Unified `packages/types` for provider snapshot JSON
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -63,6 +63,9 @@ importers:
|
|||||||
'@modelcontextprotocol/sdk':
|
'@modelcontextprotocol/sdk':
|
||||||
specifier: ^1.29.0
|
specifier: ^1.29.0
|
||||||
version: 1.29.0(zod@3.25.76)
|
version: 1.29.0(zod@3.25.76)
|
||||||
|
'@opencode-ai/sdk':
|
||||||
|
specifier: ~1.15.0
|
||||||
|
version: 1.15.12
|
||||||
fastify:
|
fastify:
|
||||||
specifier: ^4.28.1
|
specifier: ^4.28.1
|
||||||
version: 4.29.1
|
version: 4.29.1
|
||||||
@@ -920,6 +923,9 @@ packages:
|
|||||||
'@open-draft/until@2.1.0':
|
'@open-draft/until@2.1.0':
|
||||||
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
|
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
|
||||||
|
|
||||||
|
'@opencode-ai/sdk@1.15.12':
|
||||||
|
resolution: {integrity: sha512-lOaBNX93dkakZe6C42ttX1bkSx3K2c6+Yv+w8Qv02v5rPlu1vCXbmdfYDh9/bw+oq+NKPSaBm9d6kPA19hA5Lg==}
|
||||||
|
|
||||||
'@opentelemetry/api@1.9.1':
|
'@opentelemetry/api@1.9.1':
|
||||||
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
|
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
@@ -4702,6 +4708,10 @@ snapshots:
|
|||||||
|
|
||||||
'@open-draft/until@2.1.0': {}
|
'@open-draft/until@2.1.0': {}
|
||||||
|
|
||||||
|
'@opencode-ai/sdk@1.15.12':
|
||||||
|
dependencies:
|
||||||
|
cross-spawn: 7.0.6
|
||||||
|
|
||||||
'@opentelemetry/api@1.9.1': {}
|
'@opentelemetry/api@1.9.1': {}
|
||||||
|
|
||||||
'@pinojs/redact@0.4.0': {}
|
'@pinojs/redact@0.4.0': {}
|
||||||
|
|||||||
Reference in New Issue
Block a user