Ship Paseo-equivalent provider snapshot, AgentComposerBar, ACP dispatch rewrite with streaming/persist, permission prompts, and agent commands. Follow-up: pane-scoped chat resolution, CoderMessageList tool timeline, WS user-delta replace, and inference orphan tool_call stripping. Archive openspec v2-2; update CHANGELOG and CURRENT. Co-authored-by: Cursor <cursoragent@cursor.com>
382 lines
12 KiB
Markdown
382 lines
12 KiB
Markdown
# BooCoder Provider Picker — Backend (Steps 1–3)
|
||
|
||
> **Superseded:** Shipped as `v2.1.0-provider-picker` (2026-05-25). Agent discovery uses direct `exec()` on the host, not SSH. See `CHANGELOG.md` and `apps/coder/src/services/agent-probe.ts` for current implementation.
|
||
|
||
> **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, direct host exec for agent discovery (historical plan referenced SSH — superseded at v2.1.0).
|
||
|
||
---
|
||
|
||
## 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**
|
||
|
||
```typescript
|
||
// 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`:
|
||
|
||
```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`:
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```typescript
|
||
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):
|
||
|
||
```typescript
|
||
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 <task>\` (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**
|
||
|
||
```typescript
|
||
// 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):
|
||
|
||
```typescript
|
||
import { registerProviderRoutes } from './routes/providers.js';
|
||
```
|
||
|
||
Add the registration call after the other `register*Routes` calls (around line 148):
|
||
|
||
```typescript
|
||
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:
|
||
```bash
|
||
docker compose build --no-cache boocode && docker compose up -d
|
||
```
|
||
|
||
Then verify:
|
||
```bash
|
||
curl http://100.114.205.53:9502/api/providers | jq .
|
||
```
|
||
|
||
Expected shape:
|
||
```json
|
||
[
|
||
{ "name": "boocode", "label": "BooCoder", "transport": "native", "installed": true, "models": [...] },
|
||
{ "name": "opencode", ... },
|
||
...
|
||
]
|
||
```
|
||
|
||
---
|
||
|
||
## Checkpoint Verification
|
||
|
||
After Task 5, report:
|
||
|
||
1. `curl http://100.114.205.53:9502/api/providers` output
|
||
2. `available_agents` schema after migration: `psql -h localhost -p 5500 -U boocode -d boochat -c '\d available_agents'`
|
||
3. 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.**
|