Compare commits

...

5 Commits

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:47:48 +00:00
26 changed files with 852 additions and 167 deletions

1
.gitignore vendored
View File

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

View File

@@ -2,6 +2,26 @@
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.9-agent-slash-commands — 2026-05-29
Segmented per-agent slash menu in the coder pane, plus cross-agent skills. The `/` menu now shows two labeled groups — **the active agent's commands first** (opencode/claude/qwen manifest + live ACP `available_commands`), **BooCoder skills second** — instead of always showing BooCoder's skills regardless of provider. `SlashCommandPicker` gains an opt-in `groups` prop (the flat `items` path is unchanged, so **BooChat's menu is byte-identical** — parity verified: no BooChat caller passes the grouped prop, and the skills lookup / invocation routing are untouched); `ChatInput` takes `slashGroups`; `CoderPane` builds the groups from the selected provider's commands + skills. Skills now **run under the selected agent**: the coder `skill_invoke` route accepts a `provider` and, when external, injects the server-side skill body into a dispatched task (instead of native inference) — so a skill like brainstorming executes through opencode/claude with the body kept server-side, mirroring the messages-route external dispatch. Also folds in the earlier initial-chat fix: invoking a skill on the landing chat now runs the same create-chat → assign-to-pane → invoke transition as a text send (`handleLandingSkill`) rather than invoking invisibly without a pane transition (the blank-screen repro). Web tsc + coder build clean.
## v2.5.8-mobile-composer-row — 2026-05-29
Mobile fix for the `AgentComposerBar`: the refresh button was wrapping to a second line. Root cause was layout order, not width — the status dot carried `ml-auto` (pinned to the far-right edge) and the refresh button followed it in DOM order, so it overflowed and wrapped. The dot + refresh are now one right-aligned (`ml-auto`) unit, keeping the refresh on the top line. Additionally, `CompactPicker` gained an `iconOnly` option and the Mode (permission) picker now renders icon-only on mobile (shield + chevron, no "Bypass"/"Plan" text label; `aria-label`/`title` and the tap-to-open list still convey the value) to free row width. Desktop is unchanged (full labels). Web-only change.
## v2.5.7-claude-models-and-picker-fix — 2026-05-29
Two provider-layer changes. **(1) Fix the empty provider picker** — a regression from `v2.5.5` (Phase 2): on a cache miss `getProviderSnapshot` returned synchronous `installed:false` `loading` entries, which `AgentComposerBar` filters out (`e.installed && e.status !== 'error'`); with the client-side poll deferred to Phase 5, a single fetch landed on `loading` forever and no providers appeared. `getProviderSnapshot` now awaits the build and returns terminal entries (the sync `loading` return is deferred until Phase 5 ships the poll); builds stay fast via the tier-2 cold-probe skip. **(2) Claude models** — the list was a hardcoded 2-entry static list (Opus 4 / Sonnet 4, May 2025), and the v2.3 config schema's `models`/`additionalModels` were parsed but never wired. `buildResolvedRegistry` now carries config `models` (replace) + `additionalModels` (merge) onto `ResolvedProviderDef`, and `provider-snapshot` applies them to every ready model list — so `/data/coder-providers.json` can add or replace any provider's models with no code change. Claude `staticModels` bumped to `opus`/`sonnet`/`haiku` latest-aliases plus pinned `claude-opus-4-8` / `claude-sonnet-4-6` / `claude-haiku-4-5-20251001` (passed verbatim to `claude --model`; the CLI accepts both aliases and pinned full names). +2 unit tests (109 total). Builds on `v2.5.6-provider-lifecycle-phase3`.
## v2.5.6-provider-lifecycle-phase3 — 2026-05-29
Phase 3 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §5): generic ACP dispatch. `acp-spawn.ts` gains `resolveLaunchSpec(resolved, installPath)` — it consults the resolved registry's `launchCommand` (a config override or a custom-ACP entry's command) first, falling back to the kept `resolveAcpSpawnArgs` switch for built-ins. `acp-dispatch.ts` now spawns `spec.binary`/`spec.args` with `env: { ...process.env, ...spec.env }` instead of the hardcoded per-name argv, and `dispatcher.ts` loads the resolved def by `task.agent` and passes it through. This lets config-defined custom ACP providers dispatch with no new switch case. Built-in dispatch (claude/opencode/goose/qwen) is **byte-identical** to pre-v2.3 — proven by a regression test asserting opencode→`['acp']`, goose→`['acp']`, qwen→`['--acp']`, binary=`installPath ?? id`, and empty config env → plain `process.env`. One deliberate deviation from the spec's literal `!installPath → null`: the `installPath ?? id` fallback is preserved so a missing install path still spawns the bare agent name as before. `setSessionMode`/permission/streaming and the dispatcher poll/NOTIFY/running-guard are untouched. 7 new `acp-spawn.test.ts` cases. No routes/UI (Phase 4+). Builds on `v2.5.5-provider-lifecycle-phase2`.
## v2.5.5-provider-lifecycle-phase2 — 2026-05-29
Phase 2 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §4). `provider-snapshot.ts` stops returning `null` for uninstalled/disabled providers — it now emits one entry per registered provider with a lifecycle status (`loading | ready | unavailable | error`), an `enabled` flag, and a two-tier probe. Tier-1 is a fast `which`-style availability check (`command-availability.ts`, `execFile`/no-shell); tier-2 — the 530s cold ACP probe — is now SKIPPED unless forced (`POST /refresh`), the `available_agents.last_probed_at` row is older than `PROVIDER_PROBE_TTL_MS` (24h default), or the DB model list is empty, which kills snapshot latency on warm reads. A cache miss returns `status:'loading'` synchronously while the build settles in the background (client polling is deferred to Phase 5). `ProviderSnapshotStatus`/`ProviderSnapshotEntry` regained `loading`/`unavailable` and gained `enabled`, `description?`, `fetchedAt?` in both the coder and web copies, guarded by a runtime parity test (`provider-types-parity.test.ts`, mirroring the `ws-frames.test.ts` convention) that fails on any field drift — a compile-time cross-project assignability check was attempted first but blocked by TS6307 (web is a composite tsconfig project). Also tracks the previously-gitignored `data/coder-providers.json` seed via a `.gitignore` exception, completing the Phase 1 config file. No dispatch/route/UI changes (Phase 3+); AgentComposerBar filtering unchanged. Builds on `v2.5.4-provider-lifecycle-phase1`.
## v2.5.4-provider-lifecycle-phase1 — 2026-05-29
Phase 1 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §23): a config-backed provider layer merged over the hardcoded built-ins, with no runtime change when no config file exists. Adds `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`); `provider-config.ts` (Zod `ProviderOverride`/`CoderProvidersFile` schemas + a loader that never throws at startup — a missing file, invalid JSON, or schema mismatch all fall back to built-ins-only — plus `save` for the Phase 4 PATCH route); and `provider-config-registry.ts` (`ResolvedProviderDef` + `buildResolvedRegistry` merge: built-in overrides, custom `extends:'acp'` entries requiring label+command, `boocode` always enabled, plus a module singleton). `agent-probe.ts` now iterates the resolved registry instead of the hardcoded list — custom ACP entries resolve their binary from `command[0]` via `execFile` (no shell), disabled providers skip probing without losing their row, and `enabled` is read from memory only (no DB column this phase). Six unit tests, including a regression proving an empty config yields exactly the built-ins. No snapshot/dispatch/route/UI changes (Phase 2+). The `data/coder-providers.json` seed exists on disk but is gitignored (`data/*`). Lands on top of `v2.5.3-remove-cursor-copilot`.

View File

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

View File

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

View File

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

View File

@@ -61,6 +61,22 @@ describe('buildResolvedRegistry', () => {
warn.mockRestore();
});
it('carries config models + additionalModels onto built-in and custom defs', () => {
const reg = buildResolvedRegistry(PROVIDERS, {
providers: {
claude: { models: [{ id: 'claude-opus-4-8', label: 'Opus 4.8' }] },
'amp-acp': {
extends: 'acp',
label: 'Amp',
command: ['amp-acp'],
additionalModels: [{ id: 'amp-1', label: 'Amp 1' }],
},
},
});
expect(reg.get('claude')!.configModels).toEqual([{ id: 'claude-opus-4-8', label: 'Opus 4.8' }]);
expect(reg.get('amp-acp')!.configAdditionalModels).toEqual([{ id: 'amp-1', label: 'Amp 1' }]);
});
it('REGRESSION: empty config returns exactly the built-ins, all enabled', () => {
const reg = buildResolvedRegistry(PROVIDERS, { providers: {} });
expect(reg.size).toBe(PROVIDERS.length);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import type { WsFrame } from '@boocode/server/ws-frames';
import type { Config } from '../config.js';
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
import { dispatchViaAcp } from './acp-dispatch.js';
import { getResolvedRegistry } from './provider-config-registry.js';
import { dispatchViaPty } from './pty-dispatch.js';
import { clearTaskCommands, setTaskCommands } from './agent-commands-cache.js';
import { getManifestCommands } from './provider-commands.js';
@@ -340,6 +341,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
if (supportsAcp) {
const result = await dispatchViaAcp({
agent,
resolved: getResolvedRegistry().get(agent),
task: task.input,
worktreePath,
installPath: installPath ?? undefined,

View File

@@ -22,6 +22,10 @@ export interface ResolvedProviderDef extends ProviderDef {
env: Record<string, string> | undefined;
configLabel?: string;
configDescription?: string;
/** Config `models` — REPLACES the discovered/static model list when present. */
configModels?: Array<{ id: string; label: string }>;
/** Config `additionalModels` — MERGED on top of the resolved model list. */
configAdditionalModels?: Array<{ id: string; label: string }>;
}
/**
@@ -61,6 +65,8 @@ export function buildResolvedRegistry(
env: ov?.env,
configLabel: ov?.label,
configDescription: ov?.description,
configModels: ov?.models,
configAdditionalModels: ov?.additionalModels,
});
}
@@ -87,6 +93,8 @@ export function buildResolvedRegistry(
env: ov.env,
configLabel: ov.label,
configDescription: ov.description,
configModels: ov.models,
configAdditionalModels: ov.additionalModels,
});
}

View File

@@ -41,9 +41,18 @@ export const PROVIDERS: ProviderDef[] = [
label: 'Claude Code',
transport: 'pty',
modelSource: 'static',
// Passed verbatim to `claude --model <id>` (PTY dispatch). The CLI accepts a
// latest-alias ('opus'/'sonnet'/'haiku') or a pinned full name
// ('claude-opus-4-8'). Aliases never go stale; pinned IDs let you select an
// exact version. Extend/replace per-install via data/coder-providers.json
// (models / additionalModels) without a code change.
staticModels: [
{ id: 'claude-opus-4-20250514', label: 'Opus 4' },
{ id: 'claude-sonnet-4-20250514', label: 'Sonnet 4' },
{ id: 'opus', label: 'Opus (latest)' },
{ id: 'claude-opus-4-8', label: 'Opus 4.8' },
{ id: 'sonnet', label: 'Sonnet (latest)' },
{ id: 'claude-sonnet-4-6', label: 'Sonnet 4.6' },
{ id: 'haiku', label: 'Haiku (latest)' },
{ id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' },
],
},
{

View File

@@ -5,7 +5,6 @@ import { homedir } from 'node:os';
import type { FastifyBaseLogger } from 'fastify';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import { PROVIDERS, type ProviderDef } from './provider-registry.js';
import {
getManifestDefaultModeId,
getManifestModes,
@@ -15,6 +14,8 @@ import { probeAcpProvider } from './acp-probe.js';
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js';
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';
interface AgentRow {
name: string;
@@ -23,6 +24,7 @@ interface AgentRow {
models: ProviderModel[] | null;
label: string | null;
transport: string | null;
last_probed_at: string | Date | null;
}
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
@@ -68,110 +70,150 @@ export function mergeModels(...lists: ProviderModel[][]): ProviderModel[] {
}
async function buildProviderEntry(
provider: ProviderDef,
resolved: ResolvedProviderDef,
agentRow: AgentRow | undefined,
llamaModels: ProviderModel[],
cwd: string,
): Promise<ProviderSnapshotEntry | null> {
const isNative = provider.name === 'boocode';
const installed = isNative || !!agentRow;
if (!installed) return null;
ttlMs: number,
force: boolean,
): Promise<ProviderSnapshotEntry> {
const name = resolved.id;
const isNative = resolved.transport === 'native';
const fallbackModes = getManifestModes(name);
const defaultModeId = getManifestDefaultModeId(name);
const manifestCommands = getManifestCommands(name);
const label = agentRow?.label ?? resolved.configLabel ?? resolved.label;
const descr = resolved.configDescription ? { description: resolved.configDescription } : {};
let transport = provider.transport;
if (agentRow && provider.transport === 'acp' && !agentRow.supports_acp) {
// v2.3: config `models` REPLACES the discovered/static list; `additionalModels`
// MERGES on top. Applied to every ready/installed model list below.
const withConfigModels = (m: ProviderModel[]): ProviderModel[] => {
let out = resolved.configModels && resolved.configModels.length > 0 ? resolved.configModels : m;
if (resolved.configAdditionalModels && resolved.configAdditionalModels.length > 0) {
out = mergeModels(out, resolved.configAdditionalModels);
}
return out;
};
// ACP built-ins fall back to PTY transport when the installed binary lacks ACP.
let transport = resolved.transport;
if (agentRow && resolved.transport === 'acp' && !agentRow.supports_acp) {
transport = 'pty';
}
const fallbackModes = getManifestModes(provider.name);
const defaultModeId = getManifestDefaultModeId(provider.name);
if (isNative) {
// 1. Disabled → unavailable, no probe.
if (!resolved.enabled) {
return {
name: provider.name,
label: provider.label,
transport,
status: 'ready',
installed: true,
models: llamaModels,
modes: [],
defaultModeId: null,
commands: getManifestCommands(provider.name),
name, label, ...descr, transport, status: 'unavailable',
enabled: false, installed: false, models: [], modes: fallbackModes,
defaultModeId, commands: manifestCommands,
};
}
// 2. Native boocode → always ready (llama-swap models).
if (isNative) {
return {
name, label: resolved.label, transport, status: 'ready',
enabled: true, installed: true, models: withConfigModels(llamaModels), modes: [],
defaultModeId: null, commands: manifestCommands,
};
}
// 3. Tier-1 fast availability: installed iff a probed install_path exists or
// the launch binary is on PATH. No spawn beyond a `which` for custom entries.
const fast =
agentRow?.install_path != null ||
(resolved.launchCommand ? await isCommandAvailable(resolved.launchCommand[0]) : false);
if (!fast) {
return {
name, label, ...descr, transport, status: 'unavailable',
enabled: true, installed: false, models: [], modes: fallbackModes,
defaultModeId, commands: manifestCommands,
};
}
// Baseline model precedence (used by claude + non-probe fallbacks).
let models: ProviderModel[] = [];
if (provider.modelSource === 'llama-swap' && provider.mergeLlamaSwap) {
if (resolved.modelSource === 'llama-swap' && resolved.mergeLlamaSwap) {
models = llamaModels;
} else if (agentRow?.models?.length) {
models = agentRow.models;
} else if (provider.staticModels) {
models = provider.staticModels.map((m) => ({ id: m.id, label: m.label }));
} else if (resolved.staticModels) {
models = resolved.staticModels.map((m) => ({ id: m.id, label: m.label }));
}
if (provider.name === 'claude') {
models = attachClaudeThinking(models);
// claude: static models + thinking options, no ACP probe (unchanged from v2.2).
if (name === 'claude') {
return {
name: provider.name,
label: agentRow?.label ?? provider.label,
transport,
status: 'ready',
installed: true,
models,
modes: fallbackModes,
defaultModeId,
commands: getManifestCommands(provider.name),
name, label, transport, status: 'ready', enabled: true, installed: true,
models: attachClaudeThinking(withConfigModels(models)), modes: fallbackModes, defaultModeId,
commands: manifestCommands,
};
}
if (transport === 'acp' && agentRow?.install_path && agentRow.supports_acp) {
const probe = await probeAcpProvider(provider.name, agentRow.install_path, cwd);
if (probe.models.length > 0) {
models = probe.models;
} else if (provider.modelSource === 'llama-swap') {
models = llamaModels;
const canProbeAcp =
transport === 'acp' &&
((agentRow?.install_path != null && agentRow.supports_acp) ||
(resolved.isCustomAcp && resolved.launchCommand != null));
if (canProbeAcp) {
// Tier-2 gate (§4.3): cold ACP probe only on force, staleness, or empty DB
// models. Otherwise serve DB models + manifest modes/commands — no spawn.
const lastProbedMs =
agentRow?.last_probed_at != null ? new Date(agentRow.last_probed_at).getTime() : NaN;
const stale = Number.isNaN(lastProbedMs) || Date.now() - lastProbedMs > ttlMs;
const dbEmpty = !(agentRow?.models && agentRow.models.length > 0);
const runTier2 = force || stale || dbEmpty;
if (!runTier2) {
let skipModels = agentRow?.models ?? [];
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
skipModels = mergeModels(skipModels, prefixLlamaSwapModels(llamaModels));
} else if (resolved.modelSource === 'llama-swap' && skipModels.length === 0) {
skipModels = llamaModels;
}
return {
name, label, transport, status: 'ready', enabled: true, installed: true,
models: withConfigModels(skipModels), modes: fallbackModes, defaultModeId, commands: manifestCommands,
};
}
if (provider.name === 'qwen') {
const settingsModels = await readQwenSettingsModels();
models = mergeModels(models, settingsModels);
}
const probeTarget =
resolved.isCustomAcp && resolved.launchCommand
? resolved.launchCommand[0]
: agentRow!.install_path!;
const probe = await probeAcpProvider(name, probeTarget, cwd);
if (provider.mergeLlamaSwap && provider.modelSource !== 'llama-swap') {
const nativeModels = probe.models.length > 0 ? probe.models : models;
models = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
let probeModels = probe.models.length > 0 ? probe.models : models;
if (name === 'qwen') {
probeModels = mergeModels(probeModels, await readQwenSettingsModels());
}
if (resolved.mergeLlamaSwap && resolved.modelSource !== 'llama-swap') {
const nativeModels = probe.models.length > 0 ? probe.models : probeModels;
probeModels = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels));
}
return {
name: provider.name,
label: agentRow.label ?? provider.label,
transport,
name, label, transport,
status: probe.ok ? 'ready' : 'error',
installed: true,
models,
enabled: true, installed: true,
models: withConfigModels(probeModels),
modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
defaultModeId: probe.defaultModeId ?? defaultModeId,
commands: mergeCommands(getManifestCommands(provider.name), probe.commands),
error: probe.error,
commands: mergeCommands(manifestCommands, probe.commands),
...(probe.error ? { error: probe.error } : {}),
fetchedAt: new Date().toISOString(),
};
}
// PTY-only providers (qwen fallback when ACP unavailable)
if (provider.name === 'qwen') {
if (models.length === 0) {
models = await readQwenSettingsModels();
}
// PTY-only fallback (e.g. qwen without ACP) — installed + ready.
if (name === 'qwen' && models.length === 0) {
models = await readQwenSettingsModels();
}
return {
name: provider.name,
label: agentRow?.label ?? provider.label,
transport,
status: 'ready',
installed: true,
models,
modes: fallbackModes,
defaultModeId,
commands: getManifestCommands(provider.name),
name, label, transport, status: 'ready', enabled: true, installed: true,
models: withConfigModels(models), modes: fallbackModes, defaultModeId, commands: manifestCommands,
};
}
@@ -200,16 +242,16 @@ export async function getProviderSnapshot(
const build = async (): Promise<ProviderSnapshotEntry[]> => {
const llamaModels = await fetchLlamaSwapModels(config);
const agents = await sql<AgentRow[]>`
SELECT name, install_path, supports_acp, models, label, transport FROM available_agents
SELECT name, install_path, supports_acp, models, label, transport, last_probed_at FROM available_agents
`;
const agentMap = new Map(agents.map((a) => [a.name, a]));
const ttlMs = config.PROVIDER_PROBE_TTL_MS;
const built = await Promise.all(
PROVIDERS.map((provider) =>
buildProviderEntry(provider, agentMap.get(provider.name), llamaModels, resolvedCwd),
const entries = await Promise.all(
[...getResolvedRegistry().values()].map((resolved) =>
buildProviderEntry(resolved, agentMap.get(resolved.id), llamaModels, resolvedCwd, ttlMs, force),
),
);
const entries = built.filter((entry): entry is ProviderSnapshotEntry => entry !== null);
snapshotCache.set(cacheKey, { at: Date.now(), entries });
return entries;
@@ -219,6 +261,13 @@ export async function getProviderSnapshot(
snapshotInflight.delete(cacheKey);
});
snapshotInflight.set(cacheKey, promise);
// Await the build (force or cache-miss) and return terminal entries. The sync
// `loading` return (design §4.4) is DEFERRED until Phase 5 ships the client
// poll that resolves it: without that poll, a single fetch lands on
// installed:false `loading` entries, which AgentComposerBar filters out
// (`e.installed && ...`) → empty picker. Builds stay fast via the tier-2 skip
// once available_agents.models is warm.
return promise;
}

View File

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

View File

@@ -332,18 +332,32 @@ export const api = {
request<CoderMessageWire[]>(
`/api/coder/sessions/${sessionId}/messages${chatId ? `?chat_id=${encodeURIComponent(chatId)}` : ''}`,
),
skillInvoke: (sessionId: string, paneId: string, skillName: string, userMessage: string | null) =>
skillInvoke: (
sessionId: string,
paneId: string,
skillName: string,
userMessage: string | null,
// v2.5.9: when the active provider is external, the skill runs under that
// agent (body injected into a dispatched task) → response carries task_id.
config?: { provider?: string; model?: string; mode_id?: string; thinking_option_id?: string },
) =>
request<{
user_message_id: string;
assistant_message_id: string;
synth_assistant_id: string;
tool_message_id: string;
assistant_message_id?: string;
synth_assistant_id?: string;
tool_message_id?: string;
task_id?: string;
dispatched?: boolean;
}>(`/api/coder/sessions/${sessionId}/skill_invoke`, {
method: 'POST',
body: JSON.stringify({
pane_id: paneId,
skill_name: skillName,
user_message: userMessage,
...(config?.provider ? { provider: config.provider } : {}),
...(config?.model ? { model: config.model } : {}),
...(config?.mode_id ? { mode_id: config.mode_id } : {}),
...(config?.thinking_option_id ? { thinking_option_id: config.thinking_option_id } : {}),
}),
}),
// Queue a new-file create from the RightRail browser → BooCoder

View File

@@ -232,19 +232,25 @@ export interface ThinkingOption {
isDefault?: boolean;
}
export type ProviderSnapshotStatus = 'ready' | 'error';
// v2.3 phase 2: 'loading' + 'unavailable' restored alongside 'ready' | 'error'.
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
// KEEP IN SYNC with apps/coder/src/services/provider-types.ts ProviderSnapshotEntry
// — parity is enforced by coder __tests__/provider-types-parity.test.ts (field drift fails it).
export interface ProviderSnapshotEntry {
name: string;
label: string;
description?: string;
transport: string;
status: ProviderSnapshotStatus;
enabled: boolean;
installed: boolean;
models: ProviderModel[];
modes: ProviderMode[];
defaultModeId: string | null;
commands: AgentCommand[];
error?: string;
fetchedAt?: string;
}
export interface AgentSessionConfig {

View File

@@ -92,9 +92,11 @@ interface PickerProps {
options: Array<{ id: string; label: string }>;
onPick: (id: string) => void;
icon?: React.ReactNode;
/** Mobile: render icon + chevron only (no value label) to save row width. */
iconOnly?: boolean;
}
function CompactPicker({ label, value, disabled, options, onPick, icon }: PickerProps) {
function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly }: PickerProps) {
const { isMobile } = useViewport();
const [open, setOpen] = useState(false);
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
@@ -129,7 +131,7 @@ function CompactPicker({ label, value, disabled, options, onPick, icon }: Picker
className="inline-flex items-center gap-1 min-h-[44px] px-1.5 rounded text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
>
{icon}
<span className="truncate max-w-[120px]">{currentLabel}</span>
{!iconOnly && <span className="truncate max-w-[120px]">{currentLabel}</span>}
<ChevronDown className="size-3 opacity-70 shrink-0" />
</button>
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
@@ -290,6 +292,7 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
options={modeOptions}
onPick={(modeId) => persist({ ...value, modeId })}
icon={<Shield className="size-3 shrink-0" />}
iconOnly
/>
<CompactPicker
label="Model"
@@ -308,22 +311,26 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
icon={<Brain className="size-3 shrink-0" />}
/>
)}
{connected !== undefined && (
<span
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0 ml-auto', connected ? 'bg-green-500' : 'bg-red-500')}
title={connected ? 'Connected' : 'Disconnected'}
/>
)}
<button
type="button"
onClick={() => void handleRefresh()}
disabled={refreshing}
className={cn('inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40', connected === undefined && 'ml-auto')}
aria-label="Refresh provider list"
title="Refresh providers"
>
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
</button>
{/* Status dot + refresh as one right-aligned unit so the refresh button
stays on the top line instead of wrapping past the edge-pinned dot. */}
<div className="ml-auto flex items-center gap-1 shrink-0">
{connected !== undefined && (
<span
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}
title={connected ? 'Connected' : 'Disconnected'}
/>
)}
<button
type="button"
onClick={() => void handleRefresh()}
disabled={refreshing}
className="inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
aria-label="Refresh provider list"
title="Refresh providers"
>
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
</button>
</div>
</div>
);
}

View File

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

View File

@@ -1,6 +1,5 @@
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import { api } from '@/api/client';
import { ChatInput } from '@/components/ChatInput';
interface Props {
@@ -9,6 +8,10 @@ interface Props {
agentId?: string | null;
onAgentChange?: (agentId: string | null) => void | Promise<void>;
onSend: (content: string) => void;
// Slash-command (skill) send from the landing page. The parent creates the
// chat, assigns it to the pane (so it transitions to ChatPane), and invokes
// the skill — same transition the text send uses. See useSessionChats.
onSkillInvoke: (skillName: string, userMessage: string | null) => void;
createChat: () => Promise<{ id: string }>;
}
@@ -18,6 +21,7 @@ export function SessionLandingPage({
agentId,
onAgentChange,
onSend,
onSkillInvoke,
createChat,
}: Props) {
const [chatId, setChatId] = useState<string | null>(null);
@@ -45,14 +49,13 @@ export function SessionLandingPage({
}
}, [ensureChat, onSend]);
const handleSlashCommand = useCallback(async (skillName: string, userMessage: string) => {
try {
const cid = await ensureChat();
await api.chats.skillInvoke(cid, skillName, userMessage.length > 0 ? userMessage : null);
} catch (err) {
toast.error(err instanceof Error ? err.message : `/${skillName} failed`);
}
}, [ensureChat]);
// Route to the parent, which creates the chat, assigns it to the pane (so the
// pane transitions to ChatPane and subscribes to the stream), then invokes the
// skill — mirroring the text-send transition. Doing the skill invoke locally
// (without the pane assignment) left the landing pane stuck/blank.
const handleSlashCommand = useCallback((skillName: string, userMessage: string) => {
onSkillInvoke(skillName, userMessage.length > 0 ? userMessage : null);
}, [onSkillInvoke]);
return (
<div className="flex flex-col h-full min-h-0">

View File

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

View File

@@ -82,6 +82,7 @@ export function Workspace({
deleteChat,
renameChat,
handleLandingSend,
handleLandingSkill,
} = chatsHook;
const { isMobile } = useViewport();
@@ -387,6 +388,7 @@ export function Workspace({
onAgentChange={onAgentChange}
createChat={() => api.chats.create(sessionId)}
onSend={(content) => void handleLandingSend(idx, content)}
onSkillInvoke={(skillName, userMessage) => void handleLandingSkill(idx, skillName, userMessage)}
/>
)}
</div>

View File

@@ -510,6 +510,31 @@ export function CoderPane({
[displayedCommands],
);
// v2.5.9: segmented slash menu — the active agent's commands first, then
// BooCoder skills. boocode has no separate "commands" group (it IS native),
// so it shows only Skills. Empty groups are dropped.
const agentCommands = useMemo(
() =>
agentConfig.provider === 'boocode'
? []
: mergeCommandsByName(providerCommands, liveTaskCommands),
[agentConfig.provider, providerCommands, liveTaskCommands],
);
const skillItems = useMemo(
() => skills.map((s) => ({ name: s.name, description: s.description })),
[skills],
);
const slashGroups = useMemo(() => {
const groups: Array<{ label: string; items: Array<{ name: string; description?: string }> }> = [];
if (agentCommands.length > 0) {
groups.push({ label: `${agentConfig.provider} commands`, items: agentCommands });
}
if (skillItems.length > 0) {
groups.push({ label: 'Skills', items: skillItems });
}
return groups;
}, [agentCommands, skillItems, agentConfig.provider]);
const { messages, setMessages, connected, loadMessages } = useCoderMessages(sessionId, chatId, {
onConnectedChange,
onPermissionRequested: (prompt) => {
@@ -736,19 +761,35 @@ export function CoderPane({
const handleChatInputSlash = useCallback(async (skillName: string, userMessage: string) => {
if (!chatId) return;
if (agentConfig.provider === 'boocode' && skillsByName.has(skillName)) {
setSending(true);
setPermissionPrompt(null);
setLiveTaskCommands([]);
try {
await api.coder.skillInvoke(sessionId, paneId, skillName, userMessage.length > 0 ? userMessage : null);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
} finally {
setSending(false);
}
// Only BooCoder skills route here; an agent's own commands (not skills) fall
// through to a literal send in ChatInput. Skills run under the active
// provider: boocode → native inference; external → body injected into a task.
if (!skillsByName.has(skillName)) return;
setSending(true);
setPermissionPrompt(null);
setLiveTaskCommands([]);
try {
const data = await api.coder.skillInvoke(
sessionId,
paneId,
skillName,
userMessage.length > 0 ? userMessage : null,
agentConfig.provider !== 'boocode'
? {
provider: agentConfig.provider,
model: agentConfig.model || undefined,
mode_id: agentConfig.modeId ?? undefined,
thinking_option_id: agentConfig.thinkingOptionId ?? undefined,
}
: undefined,
);
if (data.task_id) setActiveTaskId(data.task_id);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'skill invocation failed');
} finally {
setSending(false);
}
}, [chatId, sessionId, paneId, agentConfig.provider, skillsByName]);
}, [chatId, sessionId, paneId, agentConfig, skillsByName]);
return (
<div className="flex flex-col h-full bg-background">
@@ -810,6 +851,7 @@ export function CoderPane({
projectId={projectPath ?? ''}
onSend={handleChatInputSend}
onSlashCommand={handleChatInputSlash}
slashGroups={slashGroups}
chatId={chatId ?? undefined}
chatLabel="BooCode"
messages={messages as unknown as import('@/api/types').Message[]}

View File

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

View File

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