Compare commits
2 Commits
a8c84ecfe4
...
3730dc9341
| Author | SHA1 | Date | |
|---|---|---|---|
| 3730dc9341 | |||
| a359a4ab8b |
@@ -2,6 +2,14 @@
|
||||
|
||||
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.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,9 @@ 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.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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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,8 +2,6 @@
|
||||
* 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';
|
||||
@@ -14,13 +12,10 @@ import {
|
||||
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);
|
||||
|
||||
interface AgentRow {
|
||||
name: string;
|
||||
install_path: string | null;
|
||||
@@ -41,15 +36,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) => ({
|
||||
@@ -141,8 +127,6 @@ async function buildProviderEntry(
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user