Compare commits
9 Commits
v2.5.12-pr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a97293b5d9 | |||
| 63adb218e6 | |||
| d0334ca544 | |||
| 024ffc0b92 | |||
| 691eef1b30 | |||
| e92c51578d | |||
| 6d03690a65 | |||
| 21384cce5b | |||
| 920f8b75a6 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -16,5 +16,5 @@ data/*
|
||||
!data/AGENTS.md
|
||||
!data/skills/
|
||||
!data/mcp.json
|
||||
!data/coder-providers.json
|
||||
!data/coder-providers.example.json
|
||||
codecontext/fork.tar.gz
|
||||
|
||||
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.
|
||||
- Before claiming a feature works, run the actual command and show the output. "Should work" is not verification. Acceptable evidence: test output (`pnpm test`), build output (`pnpm build`), curl response, docker logs, `\d tablename` output. If you can't run it, say so explicitly — don't assert success without evidence.
|
||||
- When reporting counts (tools, tests, files, routes, lines), derive the number from a command (`grep -c`, `wc -l`, test runner output) — not from memory or approximation.
|
||||
|
||||
## Provider lifecycle (v2.3)
|
||||
|
||||
BooCoder's coding agents are a **config-backed registry**: built-ins live in `provider-registry.ts`, and `data/coder-providers.json` layers overrides + custom entries on top. Registration ≠ installation — the config lists what you *want*; a probe reports what's *ready*.
|
||||
|
||||
### Config file: `data/coder-providers.json`
|
||||
|
||||
Resolved from `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`; dev/host path `/opt/boocode/data/coder-providers.json`). It is **gitignored** — it's live runtime config that the coder reads *and writes* (UI toggles `PATCH` it), so tracking it would churn `git status`. The tracked reference is `data/coder-providers.example.json`; copy it to `coder-providers.json` to seed overrides. A missing file, invalid JSON, or a schema mismatch all fall back to built-ins-only — loading never throws at startup.
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"goose": { "enabled": false },
|
||||
"amp-acp": {
|
||||
"extends": "acp",
|
||||
"label": "Amp",
|
||||
"description": "ACP wrapper for Amp",
|
||||
"command": ["amp-acp"],
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per-provider override fields (all optional):
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `extends` | `"acp"` — required for a NEW (custom) provider; built-in overrides omit it |
|
||||
| `label` | Display name (required for custom) |
|
||||
| `description` | Sub-label shown in the picker / settings |
|
||||
| `command` | `[binary, ...args]` to spawn (required for custom; overrides a built-in's default argv) |
|
||||
| `env` | Extra env vars merged into the spawn |
|
||||
| `enabled` | Default `true`; `false` hides it from the composer |
|
||||
| `order` | UI sort key |
|
||||
| `models` / `additionalModels` | Replace / merge onto the discovered model list |
|
||||
|
||||
A PATCH to one provider id **replaces that id's override object wholesale** (per-id shallow merge), so to flip a single field keep the rest; a `null` value for an id deletes its override (reverts to the built-in default).
|
||||
|
||||
### Refresh contract
|
||||
|
||||
The snapshot is cached and a provider's cold ACP probe (tier-2) is **skipped** while `available_agents.last_probed_at` is younger than `PROVIDER_PROBE_TTL_MS` (default `86400000` = 24h). Opening the composer is therefore fast and does not re-probe. To force a cold re-probe (after installing a CLI or editing models): **`POST /api/providers/refresh`** (the Refresh button in the Providers settings tab), which clears the cache and re-probes.
|
||||
|
||||
### Enable / disable
|
||||
|
||||
Two ways:
|
||||
- **Settings → Providers tab** — open the sidebar → **Settings** → **Providers**: toggle a provider on/off, refresh it, or open its diagnostic. (Earlier builds exposed a gear in the composer; that control was moved into Settings.)
|
||||
- **Edit the config** (`"enabled": false`) then `POST /api/providers/refresh`.
|
||||
|
||||
A **disabled** provider leaves the composer's provider picker but stays listed in the Providers tab (status "Disabled") so you can re-enable it. **Native `boocode` is always-on** — an `enabled:false` on it is ignored (with a warn log) and it is never rendered as toggleable.
|
||||
|
||||
### Adding a custom ACP provider
|
||||
|
||||
- **Catalog modal**: Providers tab → **Add provider** → pick an entry → it PATCHes the config (`extends:'acp'` + label + command, enabled) and refreshes that provider.
|
||||
- **Hand-edit** `data/coder-providers.json`: add an id with `extends:'acp'`, `label`, and `command`, then `POST /api/providers/refresh`.
|
||||
|
||||
Either way, **adding to config does NOT install the binary.** Until the CLI is on `PATH` the provider shows **"Not installed"** (status `unavailable`) and does not appear in the composer picker.
|
||||
|
||||
### Known limitation — subset refresh
|
||||
|
||||
`POST /api/providers/refresh` accepts an optional `{ "providers": ["id", ...] }` body and returns a `refreshed` count scoped to that subset — **but the underlying cold re-probe currently covers ALL installed providers**, not just the requested subset. True per-provider force is a future change (it needs a snapshot-internal parameter). This is intentional for now, not a bug: a subset refresh still re-probes everything; only the reported count is scoped.
|
||||
|
||||
### Deploy + smoke
|
||||
|
||||
Two deploy targets:
|
||||
- **Routes (host service):** `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`
|
||||
- **Web UI (container):** `docker compose up --build -d boocode`
|
||||
|
||||
Green gate (verified across phases 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)
|
||||
```
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -2,6 +2,18 @@
|
||||
|
||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||
|
||||
## v2.5.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`.
|
||||
|
||||
16
CLAUDE.md
16
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.
|
||||
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
|
||||
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
|
||||
- **`services/provider-registry.ts`** — Static registry of provider metadata (label, transport, model source). `PROVIDERS` array, `PROVIDERS_BY_NAME` map. 5 providers: boocode (native), opencode (acp), goose (pty), claude (pty), qwen (pty).
|
||||
- **`services/agent-probe.ts`** — Startup probe using direct `exec()` (not SSH). Discovers installed agents on host, their versions, ACP support, and models. Qwen models read from `~/.qwen/settings.json`. Claude models are static from the registry. Results persisted to `available_agents` table.
|
||||
- **`routes/providers.ts`** — `GET /api/providers` returns installed providers with models. Transport field reflects actual capability (checks `supports_acp` from DB, not just registry preference).
|
||||
- **`apps/coder/src/services/provider-registry.ts`** (BooCoder, NOT apps/server) — Static registry of provider metadata (label, transport, model source). `PROVIDERS` array, `PROVIDERS_BY_NAME` map. 5 providers: boocode (native), opencode (acp), goose (pty), claude (pty), qwen (pty).
|
||||
- **`apps/coder/src/services/agent-probe.ts`** (BooCoder) — Startup probe using direct `exec()` (not SSH). Discovers installed agents on host, their versions, ACP support, and models. Qwen models read from `~/.qwen/settings.json`. Claude models are static from the registry. Results persisted to `available_agents` table.
|
||||
- **`apps/coder/src/routes/providers.ts`** (BooCoder) — `GET /api/providers` returns installed providers with models. Transport field reflects actual capability (checks `supports_acp` from DB, not just registry preference). The apps/server side of this flow is the "Provider picker dispatch" bullet below.
|
||||
- **Provider picker dispatch**: when `provider !== 'boocode'`, the message route creates a `tasks` row (with `session_id` set) instead of calling `inference.enqueue`. The dispatcher picks it up and dispatches via ACP or PTY using the agent's `install_path`.
|
||||
|
||||
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
|
||||
@@ -80,12 +80,16 @@ Route registration: all routes registered in `index.ts` via `register*Routes(app
|
||||
- Write-capable coding agent. Runs as a **systemd service on the host** (`boocoder.service`), NOT in Docker. Fastify server at port 9502, connects to postgres at `127.0.0.1:5500`.
|
||||
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. apps/server must build FIRST.
|
||||
- Build + deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`.
|
||||
- After `pnpm -C apps/coder build` the host `boocoder.service` keeps running the OLD process until `sudo systemctl restart boocoder` — a stale process shows **new routes 404 with `{error:'not found'}` while old routes still 200** (the `/api` not-found handler returns that shape). Restart, don't re-debug.
|
||||
- Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Follows Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`.
|
||||
- systemd hardening: only `NoNewPrivileges=true` is safe. `ProtectSystem`, `ProtectHome`, `PrivateTmp` all break agent dispatch (agents need full filesystem access to read configs, write to worktrees).
|
||||
- `apps/server/tsconfig.json` has `declaration: true` so `.d.ts` files exist for workspace consumers.
|
||||
- Write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`) queue in `pending_changes` table. Nothing hits disk until `apply_pending` is called. `write_guard.ts` validates paths (resolve + prefix-check, no realpath since files may not exist for creates).
|
||||
- Frontend: NOT a separate SPA. BooCoder is a `'coder'` pane type within BooChat's SPA (`apps/web/`). `CoderPane.tsx` in `apps/web/src/components/panes/`. API requests go through `/api/coder/*` proxy (Vite dev + Fastify production) which rewrites to the boocoder host service (`BOOCODER_URL` env var, default `http://100.114.205.53:9502`). WS connects directly to `:9502`.
|
||||
- `apps/coder/web/` is a STANDALONE fallback SPA served at `:9502` directly. The PRIMARY BooCoder frontend is the `CoderPane` in BooChat's SPA (`apps/web/src/components/panes/CoderPane.tsx`), accessible via the "Coder" pane in the workspace at `code.indifferentketchup.com`. Both exist; the pane is what Sam uses.
|
||||
- **Provider snapshot lifecycle** (`apps/coder/src/services/`): `provider-config.ts` (Zod config, never-throws on bad input) → `provider-config-registry.ts` (`buildResolvedRegistry`, singleton) → `provider-snapshot.ts` (two-tier probe: tier-1 fast presence, tier-2 cold ACP probe skipped unless force / stale `PROVIDER_PROBE_TTL_MS` 24h / dbEmpty; cached). Verify live: `curl http://100.114.205.53:9502/api/providers/snapshot` — returns providers + models + commands, the exact shape `AgentComposerBar` renders.
|
||||
- `PATCH /api/providers/config` replaces a provider id's override object **wholesale** (per-id shallow merge) — to flip one field send `{...existing, enabled}`, or a custom ACP entry's `command`/`label` is wiped and it drops out of the resolved registry. `data/coder-providers.json` is **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.
|
||||
- External agents dispatch **one-shot** (`opencode acp` / `goose acp` / `qwen --acp`) and report no context-window/token usage; only native `boocode` (llama-swap engine) tracks ctx. OpenCode-as-HTTP-server (warm process + `@opencode-ai/sdk`, the source of a real context bar) is the **planned, unshipped** `openspec/changes/v2-6-persistent-agent-sessions` batch; Paseo's per-provider native clients (design §12) were deliberately not ported.
|
||||
|
||||
### Frontend (`apps/web/src/`)
|
||||
|
||||
@@ -145,6 +149,7 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
||||
- Tag naming: `vMAJOR.MINOR.PATCH-slug` (e.g. `v1.13.13-ws-publish`). Monotonic per minor — the slug describes the batch's content so the tag name alone is enough to recall what shipped. No letter suffixes (`-a`/`-b`), no pseudo-ranges (`v1.11.x`), no slug-only sub-versions sharing a number (`v1.13.15-tools` + `-openspec` + `-agentlint` — split into sequential patches instead).
|
||||
- `CHANGELOG.md` is the per-tag release log, most-recent on top. When a new tag is created, add a `## <tag> — <YYYY-MM-DD>` section with a 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).
|
||||
- The `boocode` container is `build: .` — it builds web+server from the **working tree**, so uncommitted changes deploy. Web edits are live on the Vite dev server (HMR) but NOT on production (`:9500` / code.indifferentketchup.com) until `docker compose up --build -d boocode`.
|
||||
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
|
||||
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
||||
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. `psql` is not on the host PATH — for an interactive query use `docker exec boocode_db psql -U boocode -d boochat -c "..."`. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
|
||||
@@ -172,10 +177,12 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
||||
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
|
||||
- **Adding a new WS frame type** requires updating BOTH the server's `InferenceFrame` (loose `type:` union + optional fields in `services/inference/turn.ts`) AND the web `WsFrame` (strict discriminated union in `apps/web/src/api/types.ts`). Server publish is permissive; the frontend type is the wire-format gate. The `'usage'` frame added in v1.12.2 needed both sides; missing the web side silently drops the frame at JSON-parse.
|
||||
- shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive.
|
||||
- `ui/` primitives present: button, card, context-menu, dialog, dropdown-menu, input, label, radio-group, sonner, textarea. No switch/sheet/drawer/badge/checkbox — use a `<button role="switch" aria-checked>` toggle (a hand-rolled `Switch` already lives in `SettingsPane.tsx`) and a Dialog-based panel for "drawers".
|
||||
- `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names.
|
||||
- Two UI event buses: `hooks/sessionEvents.ts` for DB-state events (chat_created, session_updated); `lib/events.ts` for ephemeral UI (`sendToTerminal`, `terminalsRegistry`). Don't merge — different subscriber lifecycles.
|
||||
- `vite.config.ts` proxy entries are order-sensitive: more-specific prefixes (`/api/term`, `/ws/term`) must come BEFORE `/api`.
|
||||
- Mobile pane URL sync (`Session.tsx`): the `?pane=<id>` effect resets `activePaneIdx` whenever `panes` changes. New-pane creation on mobile must push `?pane=` atomically — `addPaneAndSwitch` is the wrapper that does this. `addSplitPane` returns the new pane id for callers.
|
||||
- A scrollable list inside a Dialog on mobile: cap `DialogContent` (`max-h-[85vh]` + `grid-rows-[auto_minmax(0,1fr)_auto]`) and make the list the single scroll region with `overscroll-contain` — otherwise touch-scroll drags the whole fixed modal / chains to the page.
|
||||
- xterm.js v5 uses canvas rendering — browser doesn't see xterm's selection; the native right-click menu has no working Copy for terminal text. App keybindings (`Cmd/Ctrl-C`, `Cmd/Ctrl-Shift-C`) are the path.
|
||||
- **New tools** live in their own `services/<name>.ts` file (see `web_search.ts`, `web_fetch.ts`) — exports a pure `executeFoo(input, ...deps)` for direct test access plus a `ToolDef` wrapper that `loadConfig()`s its real dependencies. Register the ToolDef in `tools.ts` `ALL_TOOLS` (and `READ_ONLY_TOOL_NAMES` if applicable). Inject `fetcher: typeof fetch = fetch` rather than `vi.spyOn(globalThis, 'fetch')` — cleanup is simpler and the production call site stays unchanged.
|
||||
- **Sentinels** are `role='system'` rows with structured `metadata.kind` (`cap_hit`, `doom_loop`). UI-only — `buildMessagesPayload` strips them via `isAnySentinel` so the LLM never sees them. A new kind requires arms in `MessageMetadata` in BOTH `apps/server/src/types/api.ts` AND `apps/web/src/api/types.ts`, plus a render branch in `apps/web/src/components/MessageBubble.tsx`.
|
||||
@@ -191,6 +198,9 @@ BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `bo
|
||||
- **CoderPane uses ChatInput** (`components/panes/CoderPane.tsx`): shares the same `ChatInput` component as BooChat for full parity — attachments, paste-to-chip, auto-grow textarea, queued messages during send. CoderPane's `sendOneMessage` is the send callback; queued messages drain via `useEffect` when `sending` goes false.
|
||||
- **Adding a new `SessionEvent` type**: add the interface, add it to the `SessionEvent` union, add a `case` in `useSidebar.ts` `applyEvent` switch (no-op `return prev` is fine), and subscribe in any hook that needs it (e.g. `useSessionStream` for `refetch_messages`).
|
||||
- **BooCoder provider registry** (`apps/coder/src/services/provider-registry.ts`): static list of provider defs (boocode, opencode, goose, claude, qwen). `PROBED_AGENT_NAMES` derives from it. Adding/removing providers means editing this file, not the frontend.
|
||||
- **AgentComposerBar filters `e.installed`**: provider snapshot entries with `installed:false` (loading/unavailable) are dropped from the dropdown. `getProviderSnapshot` must await the full build — returning synchronous `loading` placeholders makes every provider vanish (the v2.5.7 "no providers showing up" regression); surfacing loading states needs a client poll.
|
||||
- **Coder↔web provider-type parity** (`apps/coder/src/services/provider-types.ts` ↔ `apps/web/src/api/types.ts`): enforced by runtime `provider-types-parity.test.ts` (compile-time cross-import is blocked by TS6307 on web's composite tsconfig). Mirror of the ws-frames parity pattern — edit both copies together or the test fails.
|
||||
- **ACP command discovery is async**: `acp-probe.ts` must poll after `newSession` for `available_commands_update` (commands arrive in a later notification; reading synchronously captures 0). PTY providers (claude) instead discover from disk via `claude-command-discovery.ts` (`~/.claude/commands` + `enabledPlugins` `skills/`+`commands/`, bare names, deduped). `AgentCommand.kind` tags `'command'` vs `'skill'`; `CoderPane`'s `slashGroups` splits them into icon'd groups. `SlashCommandPicker`'s `groups?` prop is opt-in — BooChat passes flat `items` (unchanged).
|
||||
- **Pane header architecture (mobile vs desktop)**: Desktop coder pane header (BooCode label + [+] [×]) lives in `Workspace.tsx` gated by `isCoder && !isMobile`. Mobile coder controls (● ×) live in `Session.tsx` header row next to `MobileTabSwitcher`/`NewPaneMenu`. `AgentComposerBar` (provider/mode/model pickers) renders inside `CoderPane.tsx` on both. The ● status dot is passed via `connected` prop from CoderPane to AgentComposerBar.
|
||||
- **MessageBubble shared between BooChat and BooCoder** (`components/MessageBubble.tsx`): accepts optional `actions?: MessageActions` callbacks (onRegenerate, onResend, onFork, onDelete) and `hideActions?: ('fork'|'delete'|'openInPane')[]`. Defaults use BooChat API; CoderPane overrides via `CoderMessageList` props. `CoderTextBubble` was removed. **`CoderMessageList` passes `CoderMessageWire as unknown as Message`** — the coder wire shape lacks `metadata`/`kind`/`summary`, so those fields are `undefined` (not `null`) on coder messages. Null-guards on any `Message` field MUST use loose `!= null`, not strict `!== null` (`undefined !== null` is `true` → `.kind` throws → blank-screen crash). The `as unknown as` cast hides this from tsc; build + typecheck pass while runtime crashes.
|
||||
- **llama-sidecar** (`/opt/forks/llama-sidecar/`): Go daemon for per-agent llama-server process pool. Cross-compile: `GOOS=windows GOARCH=amd64 /snap/go/current/bin/go build -o bin/llama-sidecar.exe ./cmd/llama-sidecar`. Gitea: `indifferentketchup/llama-sidecar`. Windows child process gotchas: use `context.Background()` for child lifetime (not request ctx), `os.Open(os.DevNull)` for stdin, `os.Pipe()` for stdout with drain goroutine, `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` creation flags. SSH to sam-desktop: `ssh samki@100.101.41.16`; use `schtasks` for persistent process spawning (SSH `start /B` doesn't survive session close).
|
||||
|
||||
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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,25 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import { dirname, isAbsolute, resolve, sep } from 'node:path';
|
||||
|
||||
/**
|
||||
* Resolve an ACP-supplied path against the agent worktree and reject anything
|
||||
* that escapes it. Mirrors `write_guard.ts`'s check: `resolve()` to normalize
|
||||
* `../` segments, then a **separator-bounded** prefix test — a bare
|
||||
* `startsWith(root)` wrongly admits a sibling dir like `<root>-evil/...`.
|
||||
*
|
||||
* No realpath (consistent with `write_guard.ts`: the target may not exist yet on
|
||||
* write). This is a containment guard for the ACP fs bridge, not a hard trust
|
||||
* boundary — the agent process already runs with host FS access; symlink-swap
|
||||
* hardening (`O_NOFOLLOW`/realpath) is out of scope here.
|
||||
*/
|
||||
function resolveInWorktree(worktreePath: string, filePath: string): string {
|
||||
const root = resolve(worktreePath);
|
||||
const absolute = isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath);
|
||||
if (absolute !== root && !absolute.startsWith(root + sep)) {
|
||||
throw new Error(`path escapes worktree: ${filePath}`);
|
||||
}
|
||||
return absolute;
|
||||
}
|
||||
|
||||
/** Resolve an ACP path against the agent worktree and read a slice of lines. */
|
||||
export async function readWorktreeTextFile(
|
||||
@@ -8,10 +28,7 @@ export async function readWorktreeTextFile(
|
||||
line?: number | null,
|
||||
limit?: number | null,
|
||||
): Promise<string> {
|
||||
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
|
||||
if (!absolute.startsWith(resolve(worktreePath))) {
|
||||
throw new Error(`path escapes worktree: ${filePath}`);
|
||||
}
|
||||
const absolute = resolveInWorktree(worktreePath, filePath);
|
||||
const raw = await fs.readFile(absolute, 'utf8');
|
||||
if (!line && !limit) return raw;
|
||||
const lines = raw.split(/\r?\n/);
|
||||
@@ -26,10 +43,7 @@ export async function writeWorktreeTextFile(
|
||||
filePath: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const absolute = isAbsolute(filePath) ? filePath : resolve(worktreePath, filePath);
|
||||
if (!absolute.startsWith(resolve(worktreePath))) {
|
||||
throw new Error(`path escapes worktree: ${filePath}`);
|
||||
}
|
||||
const absolute = resolveInWorktree(worktreePath, filePath);
|
||||
await fs.mkdir(dirname(absolute), { recursive: true });
|
||||
await fs.writeFile(absolute, content, 'utf8');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, ChevronDown, RefreshCw, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
|
||||
import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
|
||||
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
|
||||
import { api } from '@/api/client';
|
||||
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
||||
@@ -176,8 +176,12 @@ interface Props {
|
||||
|
||||
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) {
|
||||
const allEntries = useProviderSnapshot(projectPath);
|
||||
// 5.5 — the composer picker only offers ENABLED providers that are ready (or
|
||||
// still loading). Disabled (enabled:false) and unavailable/error providers are
|
||||
// hidden here and managed in Settings → Providers. Native boocode is always
|
||||
// enabled+ready, so it always appears.
|
||||
const entries = useMemo(
|
||||
() => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null,
|
||||
() => allEntries?.filter((e) => e.enabled && (e.status === 'ready' || e.status === 'loading')) ?? null,
|
||||
[allEntries],
|
||||
);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -200,6 +204,35 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
onChange(resolveConfig(entry, prefs));
|
||||
}, [entries, onChange, value.provider]);
|
||||
|
||||
// If the active provider is disabled in the settings drawer it drops out of
|
||||
// `entries` (the 5.5 filter) — fall back to boocode so the composer never
|
||||
// strands on an unselectable provider with empty model/mode pickers.
|
||||
useEffect(() => {
|
||||
if (!entries?.length) return;
|
||||
if (entries.some((e) => e.name === value.provider)) return;
|
||||
const fallback = entries.find((e) => e.name === 'boocode') ?? entries[0];
|
||||
if (!fallback) return;
|
||||
onChange(resolveConfig(fallback, loadPrefs()));
|
||||
}, [entries, value.provider, onChange]);
|
||||
|
||||
// 5.6 — loading poll: while any entry is loading (Phase 2's sync cache-miss
|
||||
// return), refetch until terminal. Capped; no provider_snapshot_updated WS
|
||||
// frame (deferred Tier-2). Dormant today since the snapshot awaits the build.
|
||||
const pollsRef = useRef(0);
|
||||
useEffect(() => {
|
||||
const anyLoading = allEntries?.some((e) => e.status === 'loading') ?? false;
|
||||
if (!anyLoading) {
|
||||
pollsRef.current = 0;
|
||||
return;
|
||||
}
|
||||
if (pollsRef.current >= 10) return;
|
||||
const t = setTimeout(() => {
|
||||
pollsRef.current += 1;
|
||||
void refreshProviderSnapshot(projectPath);
|
||||
}, 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [allEntries, projectPath]);
|
||||
|
||||
const currentEntry = useMemo(
|
||||
() => entries?.find((e) => e.name === value.provider),
|
||||
[entries, value.provider],
|
||||
@@ -283,7 +316,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
value={value.provider}
|
||||
options={providerOptions}
|
||||
onPick={pickProvider}
|
||||
icon={providerIcon(value.provider)}
|
||||
icon={
|
||||
currentEntry?.status === 'loading'
|
||||
? <Loader2 size={13} className="shrink-0 animate-spin" />
|
||||
: providerIcon(value.provider)
|
||||
}
|
||||
/>
|
||||
<CompactPicker
|
||||
label="Mode"
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -15,9 +15,10 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { ModelPicker } from '@/components/ModelPicker';
|
||||
import { ThemePicker } from '@/components/ThemePicker';
|
||||
import { ProvidersSettings } from '@/components/coder/ProvidersSettings';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Section = 'session' | 'project' | 'theme';
|
||||
type Section = 'session' | 'project' | 'theme' | 'providers';
|
||||
|
||||
interface Props {
|
||||
session: Session;
|
||||
@@ -73,7 +74,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/20 px-3 py-1.5 shrink-0">
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
{(['session', 'project', 'theme'] as const).map((s) => (
|
||||
{(['session', 'project', 'theme', 'providers'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
@@ -116,6 +117,7 @@ export function SettingsPane({ session, project, maximized, onToggleMaximize, on
|
||||
{activeSection === 'session' && <SessionSection session={session} project={project} />}
|
||||
{activeSection === 'project' && <ProjectSection project={project} />}
|
||||
{activeSection === 'theme' && <ThemePicker />}
|
||||
{activeSection === 'providers' && <ProvidersSettings />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -50,8 +50,8 @@ export function activePaneChatId(pane: WorkspacePane): string | undefined {
|
||||
// v1.9: settings pane factory. No chats, no state beyond identity — the
|
||||
// SettingsPane component renders Session/Project sections from the
|
||||
// surrounding session/project.
|
||||
function settingsPane(): WorkspacePane {
|
||||
return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 };
|
||||
function settingsPane(id: string = generateId()): WorkspacePane {
|
||||
return { id, kind: 'settings', chatIds: [], activeChatIdx: -1 };
|
||||
}
|
||||
|
||||
// v1.14.x-html-artifact-panes: artifact pane factories. Payload travels with
|
||||
@@ -135,7 +135,7 @@ export interface UseWorkspacePanesResult {
|
||||
// Open-on-first-click, close-on-second-click. Singleton — settings panes
|
||||
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
|
||||
// falls back to an empty pane to preserve the "always one pane" invariant.
|
||||
toggleSettingsPane: () => void;
|
||||
toggleSettingsPane: () => string | null;
|
||||
removePane: (idx: number) => void;
|
||||
removeChatFromPanes: (chatId: string) => void;
|
||||
initializeFirstChatIfEmpty: (chatId: string) => void;
|
||||
@@ -492,14 +492,21 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
return success ? newPaneId : null;
|
||||
}, [seedPaneChat]);
|
||||
|
||||
const toggleSettingsPane = useCallback(() => {
|
||||
// Returns the new settings pane id when one is OPENED (so mobile callers can
|
||||
// push ?pane= atomically — see addPaneAndSwitch), or null when it was closed.
|
||||
// Id generated outside the updater so a strict-mode double-invoke agrees.
|
||||
const toggleSettingsPane = useCallback((): string | null => {
|
||||
const newPaneId = generateId();
|
||||
let openedId: string | null = null;
|
||||
setPanes((prev) => {
|
||||
const existingIdx = prev.findIndex((p) => p.kind === 'settings');
|
||||
if (existingIdx < 0) {
|
||||
const next = [...prev, settingsPane()];
|
||||
const next = [...prev, settingsPane(newPaneId)];
|
||||
setActivePaneIdx(next.length - 1);
|
||||
openedId = newPaneId;
|
||||
return next;
|
||||
}
|
||||
openedId = null;
|
||||
if (prev.length <= 1) {
|
||||
setActivePaneIdx(0);
|
||||
return [emptyPane()];
|
||||
@@ -508,6 +515,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
|
||||
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
|
||||
return next;
|
||||
});
|
||||
return openedId;
|
||||
}, []);
|
||||
|
||||
const removePane = useCallback((idx: number) => {
|
||||
|
||||
@@ -123,6 +123,20 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
// v2.3: opening the settings pane on mobile must push ?pane= atomically, or
|
||||
// the URL-sync effect below snaps activePaneIdx back to the chat pane and the
|
||||
// settings pane never shows (same fix as addPaneAndSwitch). toggleSettingsPane
|
||||
// returns the new pane id when it opens (null when it closes → drop ?pane= so
|
||||
// the effect falls back to pane 0). Desktop has no URL pane state — no-op.
|
||||
const toggleSettingsAndSync = useCallback(() => {
|
||||
const openedId = panesHook.toggleSettingsPane();
|
||||
if (!isMobile) return;
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (openedId) params.set('pane', openedId);
|
||||
else params.delete('pane');
|
||||
navigate(`${location.pathname}?${params.toString()}`);
|
||||
}, [panesHook, isMobile, navigate, location.pathname, location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((event) => {
|
||||
if (event.type === 'session_renamed' && event.session_id === sessionId) {
|
||||
@@ -156,10 +170,10 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
// Sidebar Settings button broadcasts this when a session is mounted;
|
||||
// toggleSettingsPane opens on first click, closes on second.
|
||||
if (event.type === 'open_settings_pane') {
|
||||
panesHook.toggleSettingsPane();
|
||||
toggleSettingsAndSync();
|
||||
}
|
||||
});
|
||||
}, [sessionId, editingName, navigate, project, panesHook]);
|
||||
}, [sessionId, editingName, navigate, project, toggleSettingsAndSync]);
|
||||
|
||||
// v1.8: URL ?pane= sync (mobile only). Lifted from Workspace.tsx so
|
||||
// MobileTabSwitcher's onSwitchPane can push the same URL state and the
|
||||
|
||||
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": {}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This document describes work intentionally **not** shipped in the 2026-05-26 stale/simplify batch. Each item needs a product or architecture decision before implementation. See also [`STALE-DEPRECATED.md`](./STALE-DEPRECATED.md) for what was resolved in that batch.
|
||||
|
||||
Last updated: 2026-05-26
|
||||
Last updated: 2026-05-29
|
||||
|
||||
---
|
||||
|
||||
@@ -11,7 +11,7 @@ Last updated: 2026-05-26
|
||||
| Item | Category | User impact | Effort | Risk if left alone |
|
||||
|------|----------|-------------|--------|-------------------|
|
||||
| Task cancel → abort ACP/PTY child | Correctness / UX | High — Stop does not kill external agents | Medium | Zombie processes, stuck `running` tasks, orphaned worktrees |
|
||||
| Skip ACP cold probe when DB fresh | Performance | Medium — composer open can stall 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 |
|
||||
| Large file splits | Maintainability | None directly | Medium per file | Harder reviews, merge conflicts |
|
||||
| Retire `apps/coder/web/` fallback SPA | Scope / ops | Low — Sam uses CoderPane | Medium | Dual UI maintenance, divergent API client |
|
||||
@@ -111,7 +111,7 @@ There is also **no frontend** calling task cancel today (`grep` across `apps/web
|
||||
|
||||
## 2. Skip ACP cold probe when DB models are fresh
|
||||
|
||||
**Status:** Planned — [`openspec/changes/v2-3-provider-lifecycle/`](../openspec/changes/v2-3-provider-lifecycle/proposal.md). **Not shipped** (no `v2.3` tag; all tasks unchecked).
|
||||
**Status:** ✅ **ADDRESSED** in v2.3 (phases 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)
|
||||
|
||||
@@ -140,12 +140,21 @@ See [`design.md`](../openspec/changes/v2-3-provider-lifecycle/design.md):
|
||||
|
||||
v2.2 shipped the snapshot wire shape and ACP dispatch stack. Lifecycle semantics (config registry, enable/disable, probe TTL, settings UI) were scoped as the follow-on **v2.3** batch to avoid mixing two large behavior changes in one tag.
|
||||
|
||||
### Acceptance criteria (when v2.3 ships)
|
||||
### Acceptance criteria — met
|
||||
|
||||
- Second `GET /api/providers/snapshot` within TTL does not invoke `probeAcpProvider` (mock assert in tests)
|
||||
- Disabled provider visible in settings, absent from composer
|
||||
- Second `GET /api/providers/snapshot` within TTL does not invoke `probeAcpProvider` (mock assert in `provider-snapshot.test.ts`)
|
||||
- Disabled provider visible in settings (Providers tab), absent from composer
|
||||
- Explicit refresh repopulates models; warm open is sub-second
|
||||
|
||||
### Still deferred (Tier-2 follow-ups, not shipped in v2.3)
|
||||
|
||||
These were explicitly scoped out of v2.3 (see `design.md` §11) and remain open:
|
||||
|
||||
- **`provider_snapshot_updated` WS frame** — the loading state uses a capped client poll / one-shot refetch instead of a server-pushed frame (design §4.4, §11; tasks O.1).
|
||||
- **`available_agents.enabled` DB column** — `enabled` is read from the in-memory resolved registry only; no DB mirror, so settings state after a coder restart re-derives from the JSON config rather than the DB (design §3.3; tasks O.2).
|
||||
- **Single-source-of-truth shared types package** — the provider snapshot types are duplicated across `apps/coder/.../provider-types.ts` and `apps/web/src/api/types.ts`, guarded by the text-identity `provider-types-parity.test.ts` rather than a shared package (see §3 below).
|
||||
- **MCP `list_providers` / `inspect_provider` tools** — provider introspection over MCP is not wired (design §11).
|
||||
|
||||
---
|
||||
|
||||
## 3. Unified `packages/types` for provider snapshot JSON
|
||||
|
||||
Reference in New Issue
Block a user