# 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.**
