Compare commits

...

4 Commits

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

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

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

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

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

1
.gitignore vendored
View File

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

View File

@@ -2,6 +2,22 @@
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch. All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
## v2.5.6-provider-lifecycle-phase3 — 2026-05-29
Phase 3 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §5): generic ACP dispatch. `acp-spawn.ts` gains `resolveLaunchSpec(resolved, installPath)` — it consults the resolved registry's `launchCommand` (a config override or a custom-ACP entry's command) first, falling back to the kept `resolveAcpSpawnArgs` switch for built-ins. `acp-dispatch.ts` now spawns `spec.binary`/`spec.args` with `env: { ...process.env, ...spec.env }` instead of the hardcoded per-name argv, and `dispatcher.ts` loads the resolved def by `task.agent` and passes it through. This lets config-defined custom ACP providers dispatch with no new switch case. Built-in dispatch (claude/opencode/goose/qwen) is **byte-identical** to pre-v2.3 — proven by a regression test asserting opencode→`['acp']`, goose→`['acp']`, qwen→`['--acp']`, binary=`installPath ?? id`, and empty config env → plain `process.env`. One deliberate deviation from the spec's literal `!installPath → null`: the `installPath ?? id` fallback is preserved so a missing install path still spawns the bare agent name as before. `setSessionMode`/permission/streaming and the dispatcher poll/NOTIFY/running-guard are untouched. 7 new `acp-spawn.test.ts` cases. No routes/UI (Phase 4+). Builds on `v2.5.5-provider-lifecycle-phase2`.
## v2.5.5-provider-lifecycle-phase2 — 2026-05-29
Phase 2 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §4). `provider-snapshot.ts` stops returning `null` for uninstalled/disabled providers — it now emits one entry per registered provider with a lifecycle status (`loading | ready | unavailable | error`), an `enabled` flag, and a two-tier probe. Tier-1 is a fast `which`-style availability check (`command-availability.ts`, `execFile`/no-shell); tier-2 — the 530s cold ACP probe — is now SKIPPED unless forced (`POST /refresh`), the `available_agents.last_probed_at` row is older than `PROVIDER_PROBE_TTL_MS` (24h default), or the DB model list is empty, which kills snapshot latency on warm reads. A cache miss returns `status:'loading'` synchronously while the build settles in the background (client polling is deferred to Phase 5). `ProviderSnapshotStatus`/`ProviderSnapshotEntry` regained `loading`/`unavailable` and gained `enabled`, `description?`, `fetchedAt?` in both the coder and web copies, guarded by a runtime parity test (`provider-types-parity.test.ts`, mirroring the `ws-frames.test.ts` convention) that fails on any field drift — a compile-time cross-project assignability check was attempted first but blocked by TS6307 (web is a composite tsconfig project). Also tracks the previously-gitignored `data/coder-providers.json` seed via a `.gitignore` exception, completing the Phase 1 config file. No dispatch/route/UI changes (Phase 3+); AgentComposerBar filtering unchanged. Builds on `v2.5.4-provider-lifecycle-phase1`.
## v2.5.4-provider-lifecycle-phase1 — 2026-05-29
Phase 1 of the v2.3 provider-lifecycle batch (`openspec/changes/v2-3-provider-lifecycle/design.md` §23): a config-backed provider layer merged over the hardcoded built-ins, with no runtime change when no config file exists. Adds `CODER_PROVIDERS_PATH` (default `/data/coder-providers.json`); `provider-config.ts` (Zod `ProviderOverride`/`CoderProvidersFile` schemas + a loader that never throws at startup — a missing file, invalid JSON, or schema mismatch all fall back to built-ins-only — plus `save` for the Phase 4 PATCH route); and `provider-config-registry.ts` (`ResolvedProviderDef` + `buildResolvedRegistry` merge: built-in overrides, custom `extends:'acp'` entries requiring label+command, `boocode` always enabled, plus a module singleton). `agent-probe.ts` now iterates the resolved registry instead of the hardcoded list — custom ACP entries resolve their binary from `command[0]` via `execFile` (no shell), disabled providers skip probing without losing their row, and `enabled` is read from memory only (no DB column this phase). Six unit tests, including a regression proving an empty config yields exactly the built-ins. No snapshot/dispatch/route/UI changes (Phase 2+). The `data/coder-providers.json` seed exists on disk but is gitignored (`data/*`). Lands on top of `v2.5.3-remove-cursor-copilot`.
## v2.5.3-remove-cursor-copilot — 2026-05-29
Retire the cursor and copilot providers from BooCoder entirely. Removes their `acp-spawn` argv cases, `provider-manifest` mode blocks + manifest keys, `provider-commands` command maps, the `provider-snapshot` cursor model-CLI branch (and the now-orphaned `exec`/`promisify` imports), and the `agent-probe` copilot ACP-detect branch; deletes the dead `cursor-models.ts` module and its test. The `PROVIDERS` registry array already lacked both entries, so only the doc comment needed correcting. Built-ins unchanged: claude, opencode, goose, qwen, native boocode. Standalone cleanup; pairs with `v2.5.4-provider-lifecycle-phase1` which builds on it.
## v2.5.2-coder-ux-fixes — 2026-05-29 ## v2.5.2-coder-ux-fixes — 2026-05-29
Working-tree checkpoint bundling this session's fixes with in-progress coder UI work. This session: the BooCoder dispatcher now reacts to new tasks immediately via a Postgres `LISTEN/NOTIFY` (`tasks_new`) AFTER INSERT trigger, with the poll loop kept at 2s as a missed-notification fallback (`dispatcher.ts`, `apps/coder/src/schema.sql`); the mobile nav drawer no longer sticks open after returning to a backgrounded tab — `useViewport` re-syncs on `pageshow`/`visibilitychange`/`resize`/`orientationchange` (iOS reported a stale width on bfcache restore, leaving `isMobile=false`); assistant reasoning renders as a collapsible "Thinking" block in `MessageBubble`, surfacing ACP `agent_thought_chunk` from opencode/goose/qwen and native `reasoning_parts`; paste-to-chip inserts pasted text verbatim instead of wrapping it in a code fence; and a "New file from pasted text" affordance in the RightRail browser queues a `pending_changes` create through the new `POST /api/sessions/:id/pending/create` endpoint, paired with a fix repointing the DiffPanel's dead approve/reject calls to the real `/api/pending/:id/apply` and `/reject` routes. Also carried in the tree but not authored this session: the CoderPane `ChatInput` migration and `AgentComposerBar` refinements, plus backend tweaks to `auto_name`, inference `tool-phase`/`turn`, `secret_guard`, and `provider-registry`. Ships the `v2-6-persistent-agent-sessions` openspec proposal/design/tasks (free agent-switching with per-agent memory, opencode-as-server) as planning docs only — the feature is unimplemented and reserves the `v2.6.0` tag for it. Build green across server/coder/web; server suite 531 passing. (CHANGELOG note: the v2.3v2.5.1 entries were never backfilled and remain absent above.) Working-tree checkpoint bundling this session's fixes with in-progress coder UI work. This session: the BooCoder dispatcher now reacts to new tasks immediately via a Postgres `LISTEN/NOTIFY` (`tasks_new`) AFTER INSERT trigger, with the poll loop kept at 2s as a missed-notification fallback (`dispatcher.ts`, `apps/coder/src/schema.sql`); the mobile nav drawer no longer sticks open after returning to a backgrounded tab — `useViewport` re-syncs on `pageshow`/`visibilitychange`/`resize`/`orientationchange` (iOS reported a stale width on bfcache restore, leaving `isMobile=false`); assistant reasoning renders as a collapsible "Thinking" block in `MessageBubble`, surfacing ACP `agent_thought_chunk` from opencode/goose/qwen and native `reasoning_parts`; paste-to-chip inserts pasted text verbatim instead of wrapping it in a code fence; and a "New file from pasted text" affordance in the RightRail browser queues a `pending_changes` create through the new `POST /api/sessions/:id/pending/create` endpoint, paired with a fix repointing the DiffPanel's dead approve/reject calls to the real `/api/pending/:id/apply` and `/reject` routes. Also carried in the tree but not authored this session: the CoderPane `ChatInput` migration and `AgentComposerBar` refinements, plus backend tweaks to `auto_name`, inference `tool-phase`/`turn`, `secret_guard`, and `provider-registry`. Ships the `v2-6-persistent-agent-sessions` openspec proposal/design/tasks (free agent-switching with per-agent memory, opencode-as-server) as planning docs only — the feature is unimplemented and reserves the `v2.6.0` tag for it. Build green across server/coder/web; server suite 531 passing. (CHANGELOG note: the v2.3v2.5.1 entries were never backfilled and remain absent above.)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,14 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { import {
mergeModels, mergeModels,
prefixLlamaSwapModels, prefixLlamaSwapModels,
clearProviderSnapshotCache, clearProviderSnapshotCache,
getProviderSnapshot, getProviderSnapshot,
} from '../provider-snapshot.js'; } from '../provider-snapshot.js';
import { loadProviderConfig } from '../provider-config-registry.js';
vi.mock('../acp-probe.js', () => ({ vi.mock('../acp-probe.js', () => ({
probeAcpProvider: vi.fn(), probeAcpProvider: vi.fn(),
@@ -14,6 +18,13 @@ import { probeAcpProvider } from '../acp-probe.js';
const mockProbe = vi.mocked(probeAcpProvider); 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<{ function mockSql(agents: Array<{
name: string; name: string;
install_path: string | null; install_path: string | null;
@@ -21,6 +32,7 @@ function mockSql(agents: Array<{
models: Array<{ id: string; label: string }> | null; models: Array<{ id: string; label: string }> | null;
label: string | null; label: string | null;
transport: string | null; transport: string | null;
last_probed_at?: string | null;
}>) { }>) {
return vi.fn((strings: TemplateStringsArray) => { return vi.fn((strings: TemplateStringsArray) => {
const query = strings.join(''); const query = strings.join('');
@@ -36,6 +48,7 @@ function mockSql(agents: Array<{
const config = { const config = {
LLAMA_SWAP_URL: 'http://llama-swap.test', LLAMA_SWAP_URL: 'http://llama-swap.test',
PROVIDER_PROBE_TTL_MS: 86_400_000,
} as import('../config.js').Config; } as import('../config.js').Config;
describe('prefixLlamaSwapModels', () => { describe('prefixLlamaSwapModels', () => {
@@ -68,6 +81,8 @@ describe('mergeModels', () => {
describe('getProviderSnapshot', () => { describe('getProviderSnapshot', () => {
beforeEach(() => { beforeEach(() => {
clearProviderSnapshotCache(); clearProviderSnapshotCache();
// Reset the resolved registry to built-ins-only (missing path → {} config).
loadProviderConfig('/nonexistent-coder-providers.json');
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
@@ -165,4 +180,147 @@ describe('getProviderSnapshot', () => {
expect(claude?.modes.length).toBeGreaterThan(0); expect(claude?.modes.length).toBeGreaterThan(0);
expect(claude?.commands.some((c) => c.name === 'help')).toBe(true); 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('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 type { WsFrame } from '@boocode/server/ws-frames';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { findThoughtLevelConfigId } from './acp-derive.js'; 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 { createAcpNdJsonStream } from './acp-stream.js';
import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js'; import { waitForPermissionResponse, waitForElicitationResponse, cancelPendingPermission } from './permission-waiter.js';
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js'; import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
@@ -59,6 +60,9 @@ export interface AcpDispatchOpts {
messageId?: string; messageId?: string;
broker?: Broker; broker?: Broker;
installPath?: string; 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; signal?: AbortSignal;
log: FastifyBaseLogger; log: FastifyBaseLogger;
} }
@@ -282,8 +286,12 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatch
broker, broker,
} = opts; } = opts;
const args = resolveAcpSpawnArgs(agent); // v2.3 phase 3: launch from the resolved registry def (config override /
if (!args) { // 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 { return {
exitCode: 1, exitCode: 1,
output: `Agent '${agent}' does not support ACP.`, 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: spec.binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning');
log.info({ agent, binary, worktreePath, modeId, model: opts.model }, 'acp-dispatch: spawning'); const child = spawn(spec.binary, spec.args, {
const child = spawn(binary, args, {
cwd: worktreePath, cwd: worktreePath,
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env }, env: { ...process.env, ...spec.env },
}); });
const streamCtx = new AcpStreamContext( const streamCtx = new AcpStreamContext(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,8 +13,7 @@ export interface ProviderDef {
* - boocode: llama-swap only * - boocode: llama-swap only
* - opencode: ACP probe + mergeLlamaSwap (prefixed llama-swap/* ids) * - opencode: ACP probe + mergeLlamaSwap (prefixed llama-swap/* ids)
* - qwen: ACP probe + merge ~/.qwen/settings.json; PTY fallback reads settings only * - qwen: ACP probe + merge ~/.qwen/settings.json; PTY fallback reads settings only
* - cursor: ACP probe + cursor-agent models CLI fallback * - goose: ACP probe only
* - goose / copilot: ACP probe only
* - claude: static manifest models + thinking options * - claude: static manifest models + thinking options
*/ */
export const PROVIDERS: ProviderDef[] = [ export const PROVIDERS: ProviderDef[] = [

View File

@@ -2,24 +2,20 @@
* Provider snapshot cache — cold ACP probe per provider + static manifest merge. * Provider snapshot cache — cold ACP probe per provider + static manifest merge.
*/ */
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { exec as execCb } from 'node:child_process';
import { promisify } from 'node:util';
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import type { Sql } from '../db.js'; import type { Sql } from '../db.js';
import type { Config } from '../config.js'; import type { Config } from '../config.js';
import { PROVIDERS, type ProviderDef } from './provider-registry.js';
import { import {
getManifestDefaultModeId, getManifestDefaultModeId,
getManifestModes, getManifestModes,
PROVIDER_MANIFEST, PROVIDER_MANIFEST,
} from './provider-manifest.js'; } from './provider-manifest.js';
import { probeAcpProvider } from './acp-probe.js'; import { probeAcpProvider } from './acp-probe.js';
import { parseCursorAgentModelsOutput } from './cursor-models.js';
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js'; import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js';
import { getManifestCommands, mergeCommands } from './provider-commands.js'; import { getManifestCommands, mergeCommands } from './provider-commands.js';
import { readQwenSettingsModels } from './qwen-settings.js'; import { readQwenSettingsModels } from './qwen-settings.js';
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
const exec = promisify(execCb); import { isCommandAvailable } from './command-availability.js';
interface AgentRow { interface AgentRow {
name: string; name: string;
@@ -28,6 +24,7 @@ interface AgentRow {
models: ProviderModel[] | null; models: ProviderModel[] | null;
label: string | null; label: string | null;
transport: string | null; transport: string | null;
last_probed_at: string | Date | null;
} }
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> { async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
@@ -41,15 +38,6 @@ async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
} }
} }
async function fetchCursorModelsCli(installPath: string): Promise<ProviderModel[]> {
try {
const { stdout } = await exec(`"${installPath}" models`, { timeout: 15_000, maxBuffer: 1024 * 1024 });
return parseCursorAgentModelsOutput(stdout);
} catch {
return [];
}
}
/** Prefix llama-swap model ids so they don't collide with provider-native models. */ /** Prefix llama-swap model ids so they don't collide with provider-native models. */
export function prefixLlamaSwapModels(models: ProviderModel[]): ProviderModel[] { export function prefixLlamaSwapModels(models: ProviderModel[]): ProviderModel[] {
return models.map((m) => ({ return models.map((m) => ({
@@ -82,115 +70,160 @@ export function mergeModels(...lists: ProviderModel[][]): ProviderModel[] {
} }
async function buildProviderEntry( async function buildProviderEntry(
provider: ProviderDef, resolved: ResolvedProviderDef,
agentRow: AgentRow | undefined, agentRow: AgentRow | undefined,
llamaModels: ProviderModel[], llamaModels: ProviderModel[],
cwd: string, cwd: string,
): Promise<ProviderSnapshotEntry | null> { ttlMs: number,
const isNative = provider.name === 'boocode'; force: boolean,
const installed = isNative || !!agentRow; ): Promise<ProviderSnapshotEntry> {
if (!installed) return null; 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; // ACP built-ins fall back to PTY transport when the installed binary lacks ACP.
if (agentRow && provider.transport === 'acp' && !agentRow.supports_acp) { let transport = resolved.transport;
if (agentRow && resolved.transport === 'acp' && !agentRow.supports_acp) {
transport = 'pty'; transport = 'pty';
} }
const fallbackModes = getManifestModes(provider.name); // 1. Disabled → unavailable, no probe.
const defaultModeId = getManifestDefaultModeId(provider.name); if (!resolved.enabled) {
if (isNative) {
return { return {
name: provider.name, name, label, ...descr, transport, status: 'unavailable',
label: provider.label, enabled: false, installed: false, models: [], modes: fallbackModes,
transport, defaultModeId, commands: manifestCommands,
status: 'ready',
installed: true,
models: llamaModels,
modes: [],
defaultModeId: null,
commands: getManifestCommands(provider.name),
}; };
} }
// 2. Native boocode → always ready (llama-swap models).
if (isNative) {
return {
name, label: resolved.label, transport, status: 'ready',
enabled: true, installed: true, models: 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[] = []; let models: ProviderModel[] = [];
if (provider.modelSource === 'llama-swap' && provider.mergeLlamaSwap) { if (resolved.modelSource === 'llama-swap' && resolved.mergeLlamaSwap) {
models = llamaModels; models = llamaModels;
} else if (agentRow?.models?.length) { } else if (agentRow?.models?.length) {
models = agentRow.models; models = agentRow.models;
} else if (provider.staticModels) { } else if (resolved.staticModels) {
models = provider.staticModels.map((m) => ({ id: m.id, label: m.label })); models = resolved.staticModels.map((m) => ({ id: m.id, label: m.label }));
} }
if (provider.name === 'claude') { // claude: static models + thinking options, no ACP probe (unchanged from v2.2).
models = attachClaudeThinking(models); if (name === 'claude') {
return { return {
name: provider.name, name, label, transport, status: 'ready', enabled: true, installed: true,
label: agentRow?.label ?? provider.label, models: attachClaudeThinking(models), modes: fallbackModes, defaultModeId,
transport, commands: manifestCommands,
status: 'ready',
installed: true,
models,
modes: fallbackModes,
defaultModeId,
commands: getManifestCommands(provider.name),
}; };
} }
if (transport === 'acp' && agentRow?.install_path && agentRow.supports_acp) { const canProbeAcp =
const probe = await probeAcpProvider(provider.name, agentRow.install_path, cwd); transport === 'acp' &&
if (probe.models.length > 0) { ((agentRow?.install_path != null && agentRow.supports_acp) ||
models = probe.models; (resolved.isCustomAcp && resolved.launchCommand != null));
} else if (provider.name === 'cursor' && agentRow.install_path) {
models = await fetchCursorModelsCli(agentRow.install_path); if (canProbeAcp) {
} else if (provider.modelSource === 'llama-swap') { // Tier-2 gate (§4.3): cold ACP probe only on force, staleness, or empty DB
models = llamaModels; // 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: skipModels, modes: fallbackModes, defaultModeId, commands: manifestCommands,
};
} }
if (provider.name === 'qwen') { const probeTarget =
const settingsModels = await readQwenSettingsModels(); resolved.isCustomAcp && resolved.launchCommand
models = mergeModels(models, settingsModels); ? resolved.launchCommand[0]
} : agentRow!.install_path!;
const probe = await probeAcpProvider(name, probeTarget, cwd);
if (provider.mergeLlamaSwap && provider.modelSource !== 'llama-swap') { let probeModels = probe.models.length > 0 ? probe.models : models;
const nativeModels = probe.models.length > 0 ? probe.models : models; if (name === 'qwen') {
models = mergeModels(nativeModels, prefixLlamaSwapModels(llamaModels)); 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 { return {
name: provider.name, name, label, transport,
label: agentRow.label ?? provider.label,
transport,
status: probe.ok ? 'ready' : 'error', status: probe.ok ? 'ready' : 'error',
installed: true, enabled: true, installed: true,
models, models: probeModels,
modes: probe.modes.length > 0 ? probe.modes : fallbackModes, modes: probe.modes.length > 0 ? probe.modes : fallbackModes,
defaultModeId: probe.defaultModeId ?? defaultModeId, defaultModeId: probe.defaultModeId ?? defaultModeId,
commands: mergeCommands(getManifestCommands(provider.name), probe.commands), commands: mergeCommands(manifestCommands, probe.commands),
error: probe.error, ...(probe.error ? { error: probe.error } : {}),
fetchedAt: new Date().toISOString(),
}; };
} }
// PTY-only providers (qwen fallback when ACP unavailable) // PTY-only fallback (e.g. qwen without ACP) — installed + ready.
if (provider.name === 'qwen') { if (name === 'qwen' && models.length === 0) {
if (models.length === 0) {
models = await readQwenSettingsModels(); models = await readQwenSettingsModels();
} }
}
return { return {
name: provider.name, name, label, transport, status: 'ready', enabled: true, installed: true,
label: agentRow?.label ?? provider.label, models, modes: fallbackModes, defaultModeId, commands: manifestCommands,
transport,
status: 'ready',
installed: true,
models,
modes: fallbackModes,
defaultModeId,
commands: getManifestCommands(provider.name),
}; };
} }
/** Synchronous placeholder entries for a cache-miss while the build runs (§4.4). */
function loadingEntries(): ProviderSnapshotEntry[] {
return [...getResolvedRegistry().values()].map((r) => ({
name: r.id,
label: r.configLabel ?? r.label,
...(r.configDescription ? { description: r.configDescription } : {}),
transport: r.transport,
status: r.enabled ? ('loading' as const) : ('unavailable' as const),
enabled: r.enabled,
installed: false,
models: [],
modes: getManifestModes(r.id),
defaultModeId: getManifestDefaultModeId(r.id),
commands: getManifestCommands(r.id),
}));
}
const snapshotCache = new Map<string, { at: number; entries: ProviderSnapshotEntry[] }>(); const snapshotCache = new Map<string, { at: number; entries: ProviderSnapshotEntry[] }>();
const snapshotInflight = new Map<string, Promise<ProviderSnapshotEntry[]>>(); const snapshotInflight = new Map<string, Promise<ProviderSnapshotEntry[]>>();
const CACHE_TTL_MS = 5 * 60_000; const CACHE_TTL_MS = 5 * 60_000;
@@ -216,16 +249,16 @@ export async function getProviderSnapshot(
const build = async (): Promise<ProviderSnapshotEntry[]> => { const build = async (): Promise<ProviderSnapshotEntry[]> => {
const llamaModels = await fetchLlamaSwapModels(config); const llamaModels = await fetchLlamaSwapModels(config);
const agents = await sql<AgentRow[]>` const agents = await sql<AgentRow[]>`
SELECT name, install_path, supports_acp, models, label, transport 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 agentMap = new Map(agents.map((a) => [a.name, a]));
const ttlMs = config.PROVIDER_PROBE_TTL_MS;
const built = await Promise.all( const entries = await Promise.all(
PROVIDERS.map((provider) => [...getResolvedRegistry().values()].map((resolved) =>
buildProviderEntry(provider, agentMap.get(provider.name), llamaModels, resolvedCwd), 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 }); snapshotCache.set(cacheKey, { at: Date.now(), entries });
return entries; return entries;
@@ -235,7 +268,15 @@ export async function getProviderSnapshot(
snapshotInflight.delete(cacheKey); snapshotInflight.delete(cacheKey);
}); });
snapshotInflight.set(cacheKey, promise); snapshotInflight.set(cacheKey, promise);
return promise;
// force → await the full build (cold probes included). Non-force cache miss →
// return loading entries synchronously; the build settles in the background
// and the next call returns it via cache / inflight (§4.4; client polls).
if (force) return promise;
promise.catch(() => {
/* settled errors surface on the next call that awaits inflight/rebuilds */
});
return loadingEntries();
} }
export function clearProviderSnapshotCache(): void { export function clearProviderSnapshotCache(): void {

View File

@@ -23,24 +23,31 @@ export interface ProviderModel {
defaultThinkingOptionId?: string; 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 { export interface AgentCommand {
name: string; name: string;
description?: 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 { export interface ProviderSnapshotEntry {
name: string; name: string;
label: string; label: string;
description?: string;
transport: string; transport: string;
status: ProviderSnapshotStatus; status: ProviderSnapshotStatus;
enabled: boolean;
installed: boolean; installed: boolean;
models: ProviderModel[]; models: ProviderModel[];
modes: ProviderMode[]; modes: ProviderMode[];
defaultModeId: string | null; defaultModeId: string | null;
commands: AgentCommand[]; commands: AgentCommand[];
error?: string; error?: string;
fetchedAt?: string;
} }
export interface AgentSessionConfig { export interface AgentSessionConfig {

View File

@@ -232,19 +232,25 @@ export interface ThinkingOption {
isDefault?: boolean; 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 { export interface ProviderSnapshotEntry {
name: string; name: string;
label: string; label: string;
description?: string;
transport: string; transport: string;
status: ProviderSnapshotStatus; status: ProviderSnapshotStatus;
enabled: boolean;
installed: boolean; installed: boolean;
models: ProviderModel[]; models: ProviderModel[];
modes: ProviderMode[]; modes: ProviderMode[];
defaultModeId: string | null; defaultModeId: string | null;
commands: AgentCommand[]; commands: AgentCommand[];
error?: string; error?: string;
fetchedAt?: string;
} }
export interface AgentSessionConfig { export interface AgentSessionConfig {

View File

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