Compare commits

...

7 Commits

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

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

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

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

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

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

View File

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

View File

@@ -2,6 +2,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.13-provider-lifecycle-phase5 — 2026-05-29
Closeout of the v2.3 provider-lifecycle batch — the web UI (Phase 5) plus docs (Phase 6). Provider management moved into **Settings → Providers**: a tab listing every registered provider with a status badge (Available / Disabled / Not installed / Error / Loading), an enable/disable toggle, a per-provider refresh, and a plaintext diagnostic; toggling sends the provider's *full* override (preserving a custom ACP entry's command under the wholesale-replace PATCH merge) then refetches the snapshot. The composer's provider picker now filters to `enabled && (status === 'ready' || 'loading')`, so disabled and unavailable providers drop out of the picker and are managed only in settings (native `boocode` always shows). A curated ACP catalog (`apps/web/src/data/acp-provider-catalog.ts`) + `AddProviderModal` register custom providers via `PATCH /api/providers/config` then a subset refresh, and the web client gained `getProvidersConfig` / `patchProvidersConfig` / `refreshProviders` / `getProviderDiagnostic`. Two mobile fixes ship alongside: the Settings pane is now reachable on phones (opening it pushes `?pane=` atomically so the mobile URL-sync effect keeps it active instead of snapping back to the chat pane), and the Add-provider modal caps to the viewport with a single `overscroll-contain` scroll region so the list scrolls instead of dragging the whole modal. This completes the arc begun in `v2.5.4-provider-lifecycle-phase1` (config-backed registry over the built-ins) → `v2.5.5-provider-lifecycle-phase2` (loading/unavailable snapshot lifecycle + tier-2 probe TTL gate) → `v2.5.6-provider-lifecycle-phase3` (generic `resolveLaunchSpec` ACP dispatch) → `v2.5.12-provider-lifecycle-phase4` (config GET/PATCH, subset refresh, diagnostic HTTP API). Docs landed in `BOOCODER.md` (config file, refresh contract, enable/disable, custom ACP, the honest subset-refresh known limitation) and `docs/DEFERRED-WORK.md` §2 is marked addressed; the remaining Tier-2 follow-ups (WS `provider_snapshot_updated` frame, `available_agents.enabled` column, shared types package, MCP provider tools) stay deferred.
## v2.5.12-provider-lifecycle-phase4 — 2026-05-29
Phase 4 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §6): the HTTP API to read, patch, refresh, and diagnose providers. `routes/providers.ts` gains `GET /api/providers/config` (the raw loaded `CoderProvidersFile`), `PATCH /api/providers/config` (a partial providers map — an id's override object is replaced wholesale, a `null` value deletes it), an optional `{ providers?: string[] }` body on `POST /api/providers/refresh` (the `refreshed` count reflects the requested subset; the force probe itself still covers all installed providers, since per-provider force is a snapshot-internal change left to a later phase), and `GET /api/providers/:id/diagnostic` returning JSON `{ diagnostic: string }` — a read-only report (resolved def, install_path, last_probed_at, enabled, `which` availability, last cached probe error) with no probe spawn. PATCH correctness is the whole story: the order is validate→save→reload→clear, a malformed body or an invalid merged config returns 422 without writing the file, and a `save()` failure returns 500 without reloading the registry or clearing the snapshot cache, so on-disk and in-memory state can never diverge. New pure `mergeProviderConfigPatch` + `ProviderConfigPatchSchema` in `provider-config.ts`, a read-only `peekSnapshotEntry` cache accessor (source of the diagnostic's last-error — no probe/cache logic change), and a new `provider-diagnostic.ts` formatter. The web client gains `api.coder.getProvidersConfig` / `patchProvidersConfig` / `refreshProviders(providers?)` / `getProviderDiagnostic`, with mirrored `ProviderOverride` / `CoderProvidersFile` / `ProviderConfigPatch` types; the existing `/api/coder/*` proxy blanket-forwards the new routes with no change. +28 tests (134 coder total: pure merge/validate, the diagnostic formatter, and `app.inject` route tests proving the 422-no-write and save-fail-no-divergence guards). The diagnostic returns JSON rather than the §8 plaintext so it flows through the JSON `request` client helper (reconciling design §6.4's `{ diagnostic }` with §8's string report). No UI (Phase 5). Builds on `v2.5.6-provider-lifecycle-phase3`.
## v2.5.11-claude-skill-discovery — 2026-05-29
Surface Claude Code's real enabled commands + plugin skills in the coder slash menu, with icons separating commands from plugin skills. New `claude-command-discovery.ts` reads (user-global scope) `~/.claude/commands/*.md` plus every enabled plugin in `~/.claude/settings.json:enabledPlugins` — each plugin's user-scope install path contributes `skills/<name>/SKILL.md` (kind `skill`) and `commands/*.md` (kind `command`), parsed from frontmatter, bare names, deduped. The snapshot's claude branch discovers these **live** (claude is PTY, no ACP probe; the snapshot cache rate-limits the fs reads). The `/` menu now renders up to three icon'd groups: **`<agent> commands`** (Terminal), **`<agent> skills`** (Puzzle — claude's plugin skills / opencode is all commands), and **BooCoder skills** (Sparkles), via a new optional `icon` on `SlashCommandGroup`. `AgentCommand` gains a `kind` field, added identically to the coder and web copies (the `provider-types-parity` test enforces it); `mergeCommandsByName` is now generic so it preserves the tag. Invocation is unchanged — picking a claude command/skill sends `/name` to claude (PTY), which executes it. Project-local plugins + `<cwd>/.claude/commands` deferred. BooChat unaffected (flat skills). Smoke-test the claude skill slash-execution on the host.
## v2.5.10-opencode-live-commands — 2026-05-29
Surface opencode's real (live ACP) command set in the coder slash menu without needing a dispatch. Two fixes: (1) the cold ACP probe (`acp-probe.ts`) captured `available_commands` but read `probedCommands` synchronously right after `newSession` — racing opencode's async `available_commands_update` notification, so it captured **zero** and only the 7-item static manifest showed. The probe now waits briefly (poll up to 3s for the first batch + a 300ms settle, capped under the 30s probe timeout) so the commands are actually captured. (2) Captured commands are persisted to a new `available_agents.commands` JSONB column and served (merged with the manifest) on the tier-2-probe-skip path, so the agent's discovered commands survive once the model list is warm and show without a dispatch. Boot warms this via the `force: true` startup snapshot. apps/coder only (probe + schema + snapshot). Caveat: depends on opencode emitting `available_commands_update` on session creation rather than only after a prompt — to be confirmed on the host. Claude (PTY) disk/plugin discovery deferred.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,8 +50,8 @@ export function activePaneChatId(pane: WorkspacePane): string | undefined {
// v1.9: settings pane factory. No chats, no state beyond identity — the
// SettingsPane component renders Session/Project sections from the
// surrounding session/project.
function settingsPane(): WorkspacePane {
return { id: generateId(), kind: 'settings', chatIds: [], activeChatIdx: -1 };
function settingsPane(id: string = generateId()): WorkspacePane {
return { id, kind: 'settings', chatIds: [], activeChatIdx: -1 };
}
// v1.14.x-html-artifact-panes: artifact pane factories. Payload travels with
@@ -135,7 +135,7 @@ export interface UseWorkspacePanesResult {
// Open-on-first-click, close-on-second-click. Singleton — settings panes
// don't count toward MAX_PANES. Closing the only remaining pane (edge case)
// falls back to an empty pane to preserve the "always one pane" invariant.
toggleSettingsPane: () => void;
toggleSettingsPane: () => string | null;
removePane: (idx: number) => void;
removeChatFromPanes: (chatId: string) => void;
initializeFirstChatIfEmpty: (chatId: string) => void;
@@ -492,14 +492,21 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
return success ? newPaneId : null;
}, [seedPaneChat]);
const toggleSettingsPane = useCallback(() => {
// Returns the new settings pane id when one is OPENED (so mobile callers can
// push ?pane= atomically — see addPaneAndSwitch), or null when it was closed.
// Id generated outside the updater so a strict-mode double-invoke agrees.
const toggleSettingsPane = useCallback((): string | null => {
const newPaneId = generateId();
let openedId: string | null = null;
setPanes((prev) => {
const existingIdx = prev.findIndex((p) => p.kind === 'settings');
if (existingIdx < 0) {
const next = [...prev, settingsPane()];
const next = [...prev, settingsPane(newPaneId)];
setActivePaneIdx(next.length - 1);
openedId = newPaneId;
return next;
}
openedId = null;
if (prev.length <= 1) {
setActivePaneIdx(0);
return [emptyPane()];
@@ -508,6 +515,7 @@ export function useWorkspacePanes(sessionId: string): UseWorkspacePanesResult {
setActivePaneIdx((ai) => Math.min(ai, next.length - 1));
return next;
});
return openedId;
}, []);
const removePane = useCallback((idx: number) => {

View File

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

View File

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

View File

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