Compare commits
3 Commits
v2.5.2-cod
...
v2.5.5-pro
| Author | SHA1 | Date | |
|---|---|---|---|
| 35a0aba211 | |||
| 3730dc9341 | |||
| a359a4ab8b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,4 +16,5 @@ data/*
|
||||
!data/AGENTS.md
|
||||
!data/skills/
|
||||
!data/mcp.json
|
||||
!data/coder-providers.json
|
||||
codecontext/fork.tar.gz
|
||||
|
||||
12
CHANGELOG.md
12
CHANGELOG.md
@@ -2,6 +2,18 @@
|
||||
|
||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||
|
||||
## v2.5.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 5–30s 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` §2–3): 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
|
||||
|
||||
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.3–v2.5.1 entries were never backfilled and remain absent above.)
|
||||
|
||||
@@ -13,3 +13,4 @@ GITEA_USER=indifferentketchup
|
||||
GITEA_SSH_HOST=100.114.205.53:2222
|
||||
MCP_CONFIG_PATH=/data/mcp.json
|
||||
SKILLS_ROOT=/opt/boocode/data/skills
|
||||
CODER_PROVIDERS_PATH=/opt/boocode/data/coder-providers.json
|
||||
|
||||
@@ -23,6 +23,13 @@ const ConfigSchema = z.object({
|
||||
GITEA_TOKEN: z.string().optional(),
|
||||
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
|
||||
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.
|
||||
FAST_MODEL: z.string().optional(),
|
||||
// SSH access to the host for external agent dispatch (Phase 5)
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { getManifestCommands, mergeCommands, PROVIDER_COMMANDS } from '../provid
|
||||
|
||||
describe('provider-commands', () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,147 @@ 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('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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -6,10 +6,6 @@ export function resolveAcpSpawnArgs(agent: string): string[] | null {
|
||||
case 'opencode':
|
||||
case 'goose':
|
||||
return ['acp'];
|
||||
case 'cursor':
|
||||
return ['acp'];
|
||||
case 'copilot':
|
||||
return ['--acp'];
|
||||
case 'qwen':
|
||||
return ['--acp'];
|
||||
default:
|
||||
@@ -18,12 +14,5 @@ export function resolveAcpSpawnArgs(agent: string): string[] | null {
|
||||
}
|
||||
|
||||
export function resolveAcpProbeBinaries(agent: string): string[] {
|
||||
switch (agent) {
|
||||
case 'cursor':
|
||||
return ['cursor-agent', 'agent'];
|
||||
case 'copilot':
|
||||
return ['copilot'];
|
||||
default:
|
||||
return [agent];
|
||||
}
|
||||
return [agent];
|
||||
}
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
import type { Sql } from '../db.js';
|
||||
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 { 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 { clearProviderSnapshotCache } from './provider-snapshot.js';
|
||||
import { readQwenSettingsModels } from './qwen-settings.js';
|
||||
import { loadConfig } from '../config.js';
|
||||
import { loadProviderConfig } from './provider-config-registry.js';
|
||||
|
||||
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> {
|
||||
const candidates = resolveAcpProbeBinaries(agentName);
|
||||
for (const bin of candidates) {
|
||||
try {
|
||||
const { stdout } = await exec(`which ${bin}`, { timeout: 10_000 });
|
||||
const path = stdout.trim();
|
||||
if (path) return path;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
const path = await whichBinary(bin);
|
||||
if (path) return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -27,15 +37,6 @@ async function detectAcpSupport(agentName: string, installPath: string): Promise
|
||||
const transport = PROVIDERS_BY_NAME.get(agentName)?.transport;
|
||||
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') {
|
||||
try {
|
||||
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.
|
||||
*
|
||||
* 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> {
|
||||
clearProviderSnapshotCache();
|
||||
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 {
|
||||
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;
|
||||
|
||||
let version: string | null = null;
|
||||
@@ -73,24 +97,34 @@ export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<voi
|
||||
/* optional */
|
||||
}
|
||||
|
||||
const providerDef = PROVIDERS_BY_NAME.get(agentName);
|
||||
let supportsAcp = providerDef?.transport === 'acp';
|
||||
if (supportsAcp) {
|
||||
supportsAcp = await detectAcpSupport(agentName, installPath);
|
||||
// Custom ACP entries are ACP by declaration; built-ins detect support.
|
||||
let supportsAcp: boolean;
|
||||
if (resolved.isCustomAcp) {
|
||||
supportsAcp = true;
|
||||
} else {
|
||||
supportsAcp = resolved.transport === 'acp';
|
||||
if (supportsAcp) {
|
||||
supportsAcp = await detectAcpSupport(agentName, installPath);
|
||||
}
|
||||
}
|
||||
|
||||
let models: Array<{ id: string; label: string }> = [];
|
||||
if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
|
||||
models = providerDef.staticModels;
|
||||
if (!resolved.isCustomAcp) {
|
||||
const providerDef = PROVIDERS_BY_NAME.get(agentName);
|
||||
if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
|
||||
models = providerDef.staticModels;
|
||||
}
|
||||
if (agentName === 'qwen') {
|
||||
models = await readQwenSettingsModels();
|
||||
}
|
||||
}
|
||||
|
||||
if (agentName === 'qwen') {
|
||||
models = await readQwenSettingsModels();
|
||||
}
|
||||
|
||||
const label = providerDef?.label ?? agentName;
|
||||
const transport =
|
||||
providerDef?.transport === 'acp' && !supportsAcp ? 'pty' : (providerDef?.transport ?? 'pty');
|
||||
const label = resolved.configLabel ?? resolved.label;
|
||||
const transport = resolved.isCustomAcp
|
||||
? 'acp'
|
||||
: resolved.transport === 'acp' && !supportsAcp
|
||||
? 'pty'
|
||||
: (resolved.transport ?? 'pty');
|
||||
|
||||
await sql`
|
||||
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport)
|
||||
|
||||
22
apps/coder/src/services/command-availability.ts
Normal file
22
apps/coder/src/services/command-availability.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -27,13 +27,6 @@ const OPENCODE_COMMANDS: AgentCommand[] = [
|
||||
{ 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[] = [
|
||||
{ name: 'help', description: 'Show available commands' },
|
||||
{ name: 'clear', description: 'Clear conversation' },
|
||||
@@ -49,23 +42,12 @@ const QWEN_COMMANDS: AgentCommand[] = [
|
||||
{ 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. */
|
||||
export const PROVIDER_COMMANDS: Record<string, AgentCommand[]> = {
|
||||
claude: CLAUDE_COMMANDS,
|
||||
opencode: OPENCODE_COMMANDS,
|
||||
cursor: CURSOR_COMMANDS,
|
||||
goose: GOOSE_COMMANDS,
|
||||
qwen: QWEN_COMMANDS,
|
||||
copilot: COPILOT_COMMANDS,
|
||||
boocode: [],
|
||||
};
|
||||
|
||||
|
||||
125
apps/coder/src/services/provider-config-registry.ts
Normal file
125
apps/coder/src/services/provider-config-registry.ts
Normal 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()];
|
||||
}
|
||||
65
apps/coder/src/services/provider-config.ts
Normal file
65
apps/coder/src/services/provider-config.ts
Normal 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');
|
||||
}
|
||||
@@ -24,31 +24,6 @@ const OPENCODE_MODES: ProviderMode[] = [
|
||||
{ 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[] = [
|
||||
{ id: 'default', label: 'Default', description: 'Prompt for approval' },
|
||||
{ id: 'plan', label: 'Plan', description: 'Plan only — no edits' },
|
||||
@@ -75,14 +50,6 @@ export const PROVIDER_MANIFEST: Record<string, ProviderManifestEntry> = {
|
||||
defaultModeId: 'build',
|
||||
modes: OPENCODE_MODES,
|
||||
},
|
||||
copilot: {
|
||||
defaultModeId: 'https://agentclientprotocol.com/protocol/session-modes#agent',
|
||||
modes: COPILOT_MODES,
|
||||
},
|
||||
cursor: {
|
||||
defaultModeId: 'agent',
|
||||
modes: CURSOR_CLI_MODES,
|
||||
},
|
||||
goose: {
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
|
||||
@@ -13,8 +13,7 @@ export interface ProviderDef {
|
||||
* - boocode: llama-swap only
|
||||
* - opencode: ACP probe + mergeLlamaSwap (prefixed llama-swap/* ids)
|
||||
* - qwen: ACP probe + merge ~/.qwen/settings.json; PTY fallback reads settings only
|
||||
* - cursor: ACP probe + cursor-agent models CLI fallback
|
||||
* - goose / copilot: ACP probe only
|
||||
* - goose: ACP probe only
|
||||
* - claude: static manifest models + thinking options
|
||||
*/
|
||||
export const PROVIDERS: ProviderDef[] = [
|
||||
|
||||
@@ -2,24 +2,20 @@
|
||||
* Provider snapshot cache — cold ACP probe per provider + static manifest merge.
|
||||
*/
|
||||
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 { Sql } from '../db.js';
|
||||
import type { Config } from '../config.js';
|
||||
import { PROVIDERS, type ProviderDef } from './provider-registry.js';
|
||||
import {
|
||||
getManifestDefaultModeId,
|
||||
getManifestModes,
|
||||
PROVIDER_MANIFEST,
|
||||
} from './provider-manifest.js';
|
||||
import { probeAcpProvider } from './acp-probe.js';
|
||||
import { parseCursorAgentModelsOutput } from './cursor-models.js';
|
||||
import type { ProviderModel, ProviderSnapshotEntry } from './provider-types.js';
|
||||
import { getManifestCommands, mergeCommands } from './provider-commands.js';
|
||||
import { readQwenSettingsModels } from './qwen-settings.js';
|
||||
|
||||
const exec = promisify(execCb);
|
||||
import { getResolvedRegistry, type ResolvedProviderDef } from './provider-config-registry.js';
|
||||
import { isCommandAvailable } from './command-availability.js';
|
||||
|
||||
interface AgentRow {
|
||||
name: string;
|
||||
@@ -28,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[]> {
|
||||
@@ -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. */
|
||||
export function prefixLlamaSwapModels(models: ProviderModel[]): ProviderModel[] {
|
||||
return models.map((m) => ({
|
||||
@@ -82,115 +70,160 @@ 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) {
|
||||
// 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: 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(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.name === 'cursor' && agentRow.install_path) {
|
||||
models = await fetchCursorModelsCli(agentRow.install_path);
|
||||
} 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: 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: 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, modes: fallbackModes, defaultModeId, commands: manifestCommands,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 snapshotInflight = new Map<string, Promise<ProviderSnapshotEntry[]>>();
|
||||
const CACHE_TTL_MS = 5 * 60_000;
|
||||
@@ -216,16 +249,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;
|
||||
@@ -235,7 +268,15 @@ export async function getProviderSnapshot(
|
||||
snapshotInflight.delete(cacheKey);
|
||||
});
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
3
data/coder-providers.json
Normal file
3
data/coder-providers.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"providers": {}
|
||||
}
|
||||
Reference in New Issue
Block a user