- Archive all 10 shipped openspec changes to openspec/changes/archived/ - Update boocode_roadmap.md: date, shipped status for v1.14/v1.15/v2.0, add v2.1.0 section - Update README.md: 3-app monorepo, add services table, add What's shipped section - Remove stale active openspec folders (all work shipped)
12 KiB
BooCoder Provider Picker — Backend (Steps 1–3)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Expose a GET /api/providers endpoint on BooCoder (port 9502) that returns all available providers with their model lists, so the frontend can build a two-level provider → model picker.
Architecture: A static provider registry maps agent names to their metadata (transport, model source). The existing agent-probe.ts is extended to discover models for each agent and persist them in a new models JSONB column on available_agents. A new /api/providers route merges the registry with DB state and llama-swap models to produce the response.
Tech Stack: Fastify, postgres (porsager), Zod, SSH exec to host for agent discovery.
File Map
| Action | File | Responsibility |
|---|---|---|
| Create | apps/coder/src/services/provider-registry.ts |
Static provider metadata (label, transport, model source) |
| Modify | apps/coder/src/schema.sql |
Add models, label, transport columns to available_agents |
| Modify | apps/coder/src/services/agent-probe.ts |
Discover models per agent, persist to DB |
| Create | apps/coder/src/routes/providers.ts |
GET /api/providers route |
| Modify | apps/coder/src/index.ts |
Register providers route |
Task 1: Provider Registry
Files:
-
Create:
apps/coder/src/services/provider-registry.ts -
Step 1: Create the provider registry
// apps/coder/src/services/provider-registry.ts
export interface ProviderDef {
name: string;
label: string;
transport: 'native' | 'acp' | 'pty';
modelSource: 'llama-swap' | 'static';
staticModels?: Array<{ id: string; label: string }>;
}
export const PROVIDERS: ProviderDef[] = [
{
name: 'boocode',
label: 'BooCoder',
transport: 'native',
modelSource: 'llama-swap',
},
{
name: 'opencode',
label: 'OpenCode',
transport: 'acp',
modelSource: 'llama-swap',
},
{
name: 'goose',
label: 'Goose',
transport: 'acp',
modelSource: 'llama-swap',
},
{
name: 'claude',
label: 'Claude Code',
transport: 'pty',
modelSource: 'static',
staticModels: [
{ id: 'claude-opus-4-20250514', label: 'Opus 4' },
{ id: 'claude-sonnet-4-20250514', label: 'Sonnet 4' },
],
},
{
name: 'qwen',
label: 'Qwen Code',
transport: 'pty',
modelSource: 'static',
// Models discovered at probe time from ~/.qwen/settings.json on host
},
];
export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p]));
- Step 2: Verify TypeScript compiles
Run: npx tsc -p apps/coder/tsconfig.json --noEmit 2>&1 | head -20
Expected: No errors from provider-registry.ts
Task 2: Schema Migration
Files:
-
Modify:
apps/coder/src/schema.sql -
Step 1: Back up the schema file
Run: cp apps/coder/src/schema.sql apps/coder/src/schema.sql.bak-$(date +%Y%m%d)
- Step 2: Add columns to available_agents
Append to the end of apps/coder/src/schema.sql:
-- v2.1.0: provider picker — extend available_agents with model discovery.
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
- Step 3: Verify schema applies cleanly
This can't be tested locally without a DB connection. The schema is idempotent (ADD COLUMN IF NOT EXISTS), so it's safe to apply on startup. Verify syntax by reading the file.
Task 3: Extend agent-probe for model discovery
Files:
-
Modify:
apps/coder/src/services/agent-probe.ts -
Step 1: Import the provider registry
Add at the top of agent-probe.ts:
import { PROVIDERS_BY_NAME } from './provider-registry.js';
- Step 2: Replace KNOWN_AGENTS with registry-driven list
Replace the KNOWN_AGENTS array and its type with a derivation from the provider registry:
const KNOWN_AGENTS = ['opencode', 'goose', 'claude', 'qwen'].map((name) => ({
name,
supportsAcp: PROVIDERS_BY_NAME.get(name)?.transport === 'acp',
}));
This preserves the same shape the rest of probeAgents expects while deriving supportsAcp from the registry's transport field. pi is dropped (no provider def, not actively used). boocode is excluded (native — no binary to probe on host).
- Step 3: Add model discovery after the existing version check
Inside the for (const agent of KNOWN_AGENTS) loop, after the ACP check block and before the UPSERT, add model discovery:
// Discover models for this agent
let models: Array<{ id: string; label: string }> = [];
const providerDef = PROVIDERS_BY_NAME.get(agent.name);
if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
models = providerDef.staticModels;
}
if (agent.name === 'qwen') {
try {
const catResult = await sshExec('cat ~/.qwen/settings.json', { timeoutMs: 10_000 });
if (catResult.exitCode === 0 && catResult.stdout.trim()) {
const settings = JSON.parse(catResult.stdout) as {
modelProviders?: { openai?: Array<{ id: string }> };
};
const openaiModels = settings?.modelProviders?.openai;
if (Array.isArray(openaiModels)) {
models = openaiModels.map((m) => ({ id: m.id, label: m.id }));
}
}
} catch {
// ~/.qwen/settings.json missing or unparseable — fall back to empty
}
}
- Step 4: Update the UPSERT to include new columns
Replace the existing UPSERT statement with:
const label = providerDef?.label ?? agent.name;
const transport = providerDef?.transport ?? 'pty';
await sql`
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport)
VALUES (${agent.name}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp(), ${JSON.stringify(models)}::jsonb, ${label}, ${transport})
ON CONFLICT (name) DO UPDATE SET
install_path = EXCLUDED.install_path,
version = EXCLUDED.version,
supports_acp = EXCLUDED.supports_acp,
last_probed_at = EXCLUDED.last_probed_at,
models = EXCLUDED.models,
label = EXCLUDED.label,
transport = EXCLUDED.transport
`;
- Step 5: Update the log line to include model count
Replace the existing log.info with:
log.info({ agent: agent.name, version, installPath, supportsAcp, modelCount: models.length }, 'agent-probe: found on host');
- Step 6: Verify TypeScript compiles
Run: npx tsc -p apps/coder/tsconfig.json --noEmit 2>&1 | head -20
Expected: No errors
Task 4: Goose PTY dispatch
Files:
-
Modify:
apps/coder/src/services/pty-dispatch.ts -
Step 1: Add goose case to buildAgentCommand
In pty-dispatch.ts, replace the goose case (line ~61):
case 'goose':
return model
? `goose run --text '${escapedTask}' --model '${model}'`
: `goose run --text '${escapedTask}'`;
Note: goose run --text is the non-interactive execution flag. If goose's actual CLI differs, the dispatch will fail with a nonzero exit code and the task will be marked failed — no silent corruption.
- Step 2: Update the module docstring
Replace goose: stub (not yet supported) with goose: \goose run --text ` (non-interactive)` in the header comment.
- Step 3: Verify TypeScript compiles
Run: npx tsc -p apps/coder/tsconfig.json --noEmit 2>&1 | head -20
Expected: No errors
Task 5: GET /api/providers Route
Files:
-
Create:
apps/coder/src/routes/providers.ts -
Modify:
apps/coder/src/index.ts -
Step 1: Create the providers route
// apps/coder/src/routes/providers.ts
import type { FastifyInstance } from 'fastify';
import type { Sql } from '../db.js';
import type { Config } from '../config.js';
import { PROVIDERS } from '../services/provider-registry.js';
interface ProviderModel {
id: string;
label: string;
}
interface ProviderResponse {
name: string;
label: string;
transport: string;
installed: boolean;
models: ProviderModel[];
}
interface LlamaSwapModel {
id: string;
[key: string]: unknown;
}
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
try {
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`);
if (!res.ok) return [];
const parsed = (await res.json()) as { data?: LlamaSwapModel[] };
return (parsed.data ?? []).map((m) => ({ id: m.id, label: m.id }));
} catch {
return [];
}
}
export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
app.get('/api/providers', async (_req, _reply) => {
// Fetch llama-swap models (shared by boocode, opencode, goose)
const llamaModels = await fetchLlamaSwapModels(config);
// Fetch installed agents from DB
const agents = await sql<{ name: string; models: ProviderModel[]; label: string | null; transport: string | null }[]>`
SELECT name, models, label, transport FROM available_agents
`;
const agentMap = new Map(agents.map((a) => [a.name, a]));
const result: ProviderResponse[] = [];
for (const provider of PROVIDERS) {
const isNative = provider.name === 'boocode';
const agentRow = agentMap.get(provider.name);
const installed = isNative || !!agentRow;
if (!installed) continue;
let models: ProviderModel[];
if (provider.modelSource === 'llama-swap') {
models = llamaModels;
} else if (agentRow?.models && agentRow.models.length > 0) {
models = agentRow.models;
} else if (provider.staticModels) {
models = provider.staticModels;
} else {
models = [];
}
result.push({
name: provider.name,
label: agentRow?.label ?? provider.label,
transport: agentRow?.transport ?? provider.transport,
installed,
models,
});
}
return result;
});
}
- Step 2: Register the route in index.ts
In apps/coder/src/index.ts, add the import near the other route imports (around line 28):
import { registerProviderRoutes } from './routes/providers.js';
Add the registration call after the other register*Routes calls (around line 148):
registerProviderRoutes(app, sql, config);
- Step 3: Verify TypeScript compiles
Run: npx tsc -p apps/coder/tsconfig.json --noEmit 2>&1 | head -20
Expected: No errors
- Step 4: Build and test
Run:
docker compose build --no-cache boocode && docker compose up -d
Then verify:
curl http://100.114.205.53:9502/api/providers | jq .
Expected shape:
[
{ "name": "boocode", "label": "BooCoder", "transport": "native", "installed": true, "models": [...] },
{ "name": "opencode", ... },
...
]
Checkpoint Verification
After Task 5, report:
curl http://100.114.205.53:9502/api/providersoutputavailable_agentsschema after migration:psql -h localhost -p 5500 -U boocode -d boochat -c '\d available_agents'- Any issues with qwen model discovery from
~/.qwen/settings.json
Do NOT proceed to frontend (Step 4 in the spec) without confirming the API works.