v2.1.0-provider-picker: BooCoder systemd migration + provider picker

- BooCoder moves from Docker to host systemd service (boocoder.service)
- Agent dispatch (ACP + PTY) switches from SSH to direct spawn/exec
- SSH helpers marked @deprecated (kept for one release cycle)
- Provider registry (5 providers: boocode, opencode, goose, claude, qwen)
- Agent probe with direct which/exec + model discovery (qwen settings, static claude models)
- GET /api/providers route with installed status, models, transport fallback
- ProviderPicker frontend component in CoderPane header
- External provider messages route through tasks row instead of inference enqueue
- Smart scroll: MessageList only auto-scrolls when near bottom (150px threshold)
- DB: available_agents gets models, label, transport columns
- Bug fix: loadContext SELECT includes allowed_read_paths
- Bug fix: cap hit sentinel inserted before buildMessagesPayload
- docker-compose.yml: boocoder service commented out, BOOCODER_URL env var added
- CLAUDE.md: updated docs for systemd, provider registry, JSONB gotcha, loadContext
This commit is contained in:
2026-05-25 19:20:53 +00:00
parent e423579e99
commit d8ffee1950
21 changed files with 687 additions and 222 deletions

View File

@@ -1,12 +1,12 @@
/**
* ACP dispatch — runs ACP-capable agents (opencode, goose) on the host via SSH.
* ACP dispatch — runs ACP-capable agents (opencode, goose) directly on the host.
*
* Uses the @agentclientprotocol/sdk to establish a structured JSON-RPC session
* with the agent subprocess. The SSH tunnel provides stdio transport.
* v2.1.1: BooCoder runs on the host now — agents are spawned directly,
* no SSH needed. Uses @agentclientprotocol/sdk for structured JSON-RPC.
*
* Flow:
* 1. SSH to host, start `opencode acp` (or `goose acp`) in the worktree
* 2. Wrap SSH child's stdin/stdout into NDJSON streams
* 1. Spawn `opencode acp` (or `goose acp`) in the worktree
* 2. Wrap child's stdin/stdout into NDJSON streams
* 3. Create a ClientSideConnection from the SDK
* 4. Initialize → newSession → prompt(task)
* 5. Collect session updates (tool calls, text output)
@@ -28,7 +28,7 @@ import {
type CreateTerminalRequest,
type CreateTerminalResponse,
} from '@agentclientprotocol/sdk';
import { sshSpawn } from './ssh.js';
import { spawn } from 'node:child_process';
export interface AcpDispatchResult {
exitCode: number;
@@ -42,17 +42,17 @@ export interface AcpDispatchOpts {
task: string;
worktreePath: string;
model?: string;
installPath?: string;
signal?: AbortSignal;
log: FastifyBaseLogger;
}
/** Map agent name to the ACP command it exposes. */
function acpCommand(agent: string): string | null {
function acpArgs(agent: string): string[] | null {
switch (agent) {
case 'opencode':
return 'opencode acp';
return ['acp'];
case 'goose':
return 'goose acp';
return ['acp'];
default:
return null;
}
@@ -114,10 +114,10 @@ function nodeWritableToWeb(nodeStream: NodeJS.WritableStream): WritableStream<Ui
* all session updates. Returns the collected output and tool calls.
*/
export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatchResult> {
const { agent, task, worktreePath, signal, log } = opts;
const { agent, task, worktreePath, installPath, signal, log } = opts;
const cmd = acpCommand(agent);
if (!cmd) {
const args = acpArgs(agent);
if (!args) {
return {
exitCode: 1,
output: `Agent '${agent}' does not support ACP.`,
@@ -126,12 +126,13 @@ export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatch
};
}
// Spawn SSH with the ACP command running in the worktree
const escapedPath = worktreePath.replace(/'/g, "'\\''");
const fullCommand = `cd '${escapedPath}' && ${cmd}`;
log.info({ agent, worktreePath }, 'acp-dispatch: spawning');
const child = sshSpawn(fullCommand);
const binary = installPath ?? agent;
log.info({ agent, binary, worktreePath }, 'acp-dispatch: spawning');
const child = spawn(binary, args, {
cwd: worktreePath,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
// Wire up abort
let killed = false;

View File

@@ -1,69 +1,91 @@
import type { Sql } from '../db.js';
import type { FastifyBaseLogger } from 'fastify';
import { sshExec } from './ssh.js';
import { exec as execCb } from 'node:child_process';
import { promisify } from 'node:util';
import { PROVIDERS_BY_NAME } from './provider-registry.js';
const KNOWN_AGENTS: Array<{ name: string; supportsAcp: boolean }> = [
{ name: 'opencode', supportsAcp: true },
{ name: 'goose', supportsAcp: true },
{ name: 'claude', supportsAcp: false },
{ name: 'pi', supportsAcp: false },
{ name: 'qwen', supportsAcp: false },
];
const exec = promisify(execCb);
const KNOWN_AGENTS = ['opencode', 'goose', 'claude', 'qwen'].map((name) => ({
name,
supportsAcp: PROVIDERS_BY_NAME.get(name)?.transport === 'acp',
}));
/**
* Probe for available agents on the HOST via SSH.
* Probe for available agents on the HOST.
*
* The boocoder container can't run agents locally — they live on the host.
* We SSH to the host (same mechanism BooTerm uses) and check which agent
* binaries are on PATH.
* v2.1.1: BooCoder runs on the host now — agents are local binaries,
* no SSH needed. Direct `which` / `exec` calls.
*/
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
log.info('agent-probe: scanning HOST for known agents via SSH');
log.info('agent-probe: scanning for known agents');
for (const agent of KNOWN_AGENTS) {
try {
// Check if the agent binary is on the host's PATH
const whichResult = await sshExec(`which ${agent.name}`, { timeoutMs: 10_000 });
const installPath = whichResult.stdout.trim();
if (whichResult.exitCode !== 0 || !installPath) continue;
const { stdout: whichOut } = await exec(`which ${agent.name}`, { timeout: 10_000 });
const installPath = whichOut.trim();
if (!installPath) continue;
// Get version
let version: string | null = null;
try {
const verResult = await sshExec(`${agent.name} --version`, { timeoutMs: 15_000 });
if (verResult.exitCode === 0) {
version = verResult.stdout.trim().slice(0, 100);
}
const { stdout: verOut } = await exec(`${agent.name} --version`, { timeout: 15_000 });
version = verOut.trim().slice(0, 100);
} catch {
// Some agents may not support --version — that's fine
// Some agents may not support --version
}
// For ACP-capable agents, verify ACP mode actually works
let supportsAcp = agent.supportsAcp;
if (supportsAcp) {
try {
const acpCheck = await sshExec(`${agent.name} acp --help`, { timeoutMs: 10_000 });
supportsAcp = acpCheck.exitCode === 0;
await exec(`${agent.name} acp --help`, { timeout: 10_000 });
} catch {
supportsAcp = false;
}
}
// UPSERT into available_agents
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 { stdout: catOut } = await exec('cat ~/.qwen/settings.json', { timeout: 10_000 });
if (catOut.trim()) {
const settings = JSON.parse(catOut) 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
}
}
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)
VALUES (${agent.name}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp())
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport)
VALUES (${agent.name}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp(), ${sql.json(models as never)}, ${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
last_probed_at = EXCLUDED.last_probed_at,
models = EXCLUDED.models,
label = EXCLUDED.label,
transport = EXCLUDED.transport
`;
log.info({ agent: agent.name, version, installPath, supportsAcp }, 'agent-probe: found on host');
log.info({ agent: agent.name, version, installPath, supportsAcp, modelCount: models.length }, 'agent-probe: found');
} catch (err) {
// SSH failed or agent not found — skip silently
const msg = err instanceof Error ? err.message : String(err);
log.debug({ agent: agent.name, err: msg }, 'agent-probe: not found or SSH failed');
log.debug({ agent: agent.name, err: msg }, 'agent-probe: not found');
}
}

View File

@@ -34,8 +34,8 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
if (running || stopping) return;
// Grab one pending task
const rows = await sql<{ id: string; project_id: string; input: string; agent: string | null; model: string | null }[]>`
SELECT id, project_id, input, agent, model
const rows = await sql<{ id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null }[]>`
SELECT id, project_id, input, agent, model, session_id
FROM tasks
WHERE state = 'pending'
ORDER BY created_at
@@ -51,16 +51,16 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
});
}
async function runTask(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null }): Promise<void> {
async function runTask(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null }): Promise<void> {
const taskId = task.id;
// Determine execution path: if agent is specified AND exists in available_agents → Path B
if (task.agent) {
const [agentRow] = await sql<{ name: string; supports_acp: boolean }[]>`
SELECT name, supports_acp FROM available_agents WHERE name = ${task.agent}
const [agentRow] = await sql<{ name: string; supports_acp: boolean; install_path: string | null }[]>`
SELECT name, supports_acp, install_path FROM available_agents WHERE name = ${task.agent}
`;
if (agentRow) {
await runExternalAgent(task, agentRow.supports_acp);
await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
return;
}
// Agent specified but not available — fall through to Path A with a warning
@@ -73,7 +73,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
// ─── Path A: Native Inference ───────────────────────────────────────────────
async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null }): Promise<void> {
async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null }): Promise<void> {
const taskId = task.id;
log.info({ taskId }, 'dispatcher: starting task (path A — native)');
@@ -179,8 +179,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
// ─── Path B: External Agent Dispatch ──────<E29480><E29480><EFBFBD>─────────────────────────────────
async function runExternalAgent(
task: { id: string; project_id: string; input: string; agent: string | null; model: string | null },
task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null },
supportsAcp: boolean,
installPath: string | null,
): Promise<void> {
const taskId = task.id;
const agent = task.agent!;
@@ -189,14 +190,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
log.info({ taskId, agent, executionPath }, 'dispatcher: starting task (path B — external)');
// Resolve the project's root path
const [project] = await sql<{ root_path: string | null }[]>`
SELECT root_path FROM projects WHERE id = ${task.project_id}
const [project] = await sql<{ path: string | null }[]>`
SELECT path FROM projects WHERE id = ${task.project_id}
`;
const projectPath = project?.root_path;
const projectPath = project?.path;
if (!projectPath) {
await sql`
UPDATE tasks
SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no root_path — cannot create worktree'
SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no path — cannot create worktree'
WHERE id = ${taskId}
`;
return;
@@ -213,30 +214,49 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
WHERE id = ${taskId}
`;
// Create session + chat for this task (same as Path A — for output tracking)
const sessionName = `Task [${agent}]: ${task.input.slice(0, 30)}`;
const [session] = await sql<{ id: string }[]>`
INSERT INTO sessions (project_id, name, model, status)
VALUES (${task.project_id}, ${sessionName}, ${task.model ?? config.DEFAULT_MODEL}, 'open')
RETURNING id
`;
const sessionId = session!.id;
let sessionId: string;
let chatId: string;
const [chat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'External agent execution', 'open')
RETURNING id
`;
const chatId = chat!.id;
if (task.session_id) {
sessionId = task.session_id;
const chats = await sql<{ id: string }[]>`
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
`;
if (chats.length === 0) {
const [chat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'External agent execution', 'open')
RETURNING id
`;
chatId = chat!.id;
} else {
chatId = chats[0]!.id;
}
} else {
const sessionName = `Task [${agent}]: ${task.input.slice(0, 30)}`;
const [session] = await sql<{ id: string }[]>`
INSERT INTO sessions (project_id, name, model, status)
VALUES (${task.project_id}, ${sessionName}, ${task.model ?? config.DEFAULT_MODEL}, 'open')
RETURNING id
`;
sessionId = session!.id;
// Link task to session
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
const [chat] = await sql<{ id: string }[]>`
INSERT INTO chats (session_id, name, status)
VALUES (${sessionId}, 'External agent execution', 'open')
RETURNING id
`;
chatId = chat!.id;
// Create user message for the task input
await sql`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
`;
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
}
if (!task.session_id) {
await sql`
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
`;
}
// Step 1: Create worktree
log.info({ taskId, projectPath }, 'dispatcher: creating worktree');
@@ -251,6 +271,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
agent,
task: task.input,
worktreePath,
installPath: installPath ?? undefined,
model: task.model ?? undefined,
signal: ac.signal,
log,
@@ -267,6 +288,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
agent,
task: task.input,
worktreePath,
installPath: installPath ?? undefined,
model: task.model ?? undefined,
signal: ac.signal,
log,

View File

@@ -0,0 +1,46 @@
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',
},
];
export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p]));

View File

@@ -1,19 +1,18 @@
/**
* PTY dispatch — runs external agents on the host via SSH.
* PTY dispatch — runs external agents directly on the host.
*
* For agents without ACP support (claude, pi), we pipe the task into their
* non-interactive mode and capture stdout/stderr. The agent runs in a git
* worktree so it can modify files freely.
* v2.1.3: Spawns agent binaries directly (no sh -c wrapper) using the
* install_path from agent-probe. Follows Paseo's pattern: direct binary
* path + args array + cwd.
*
* Supported agents:
* - claude: `claude -p --model <model>` (print mode, reads task from stdin)
* - opencode: `echo <task> | opencode` (stdin pipe — exact flags TBD)
* - qwen: `qwen -p <task> --output-format stream-json` (NDJSON structured output)
* - goose: stub (not yet supported)
* - pi: stub (not yet supported)
* - opencode: `opencode --model <model>` (stdin pipe)
* - qwen: `qwen -p <task> --output-format stream-json`
* - goose: `goose run --text <task>`
*/
import type { FastifyBaseLogger } from 'fastify';
import { sshSpawnWithStdin } from './ssh.js';
import { spawn } from 'node:child_process';
export interface DispatchResult {
exitCode: number;
@@ -26,62 +25,61 @@ export interface PtyDispatchOpts {
task: string;
worktreePath: string;
model?: string;
installPath?: string;
signal?: AbortSignal;
log: FastifyBaseLogger;
}
/**
* Build the shell command that runs the agent non-interactively.
* The command will be executed inside `cd <worktreePath> && ...`.
*/
function buildAgentCommand(agent: string, task: string, model?: string): string | null {
// Escape the task for embedding in a shell command
const escapedTask = task.replace(/'/g, "'\\''");
interface AgentCommand {
binary: string;
args: string[];
stdin?: string;
}
function buildAgentCommand(agent: string, task: string, model?: string, installPath?: string): AgentCommand | null {
const binary = installPath ?? agent;
switch (agent) {
case 'claude':
// Claude Code's print mode: reads prompt from stdin, runs autonomously, prints result
return model
? `echo '${escapedTask}' | claude -p --model '${model}'`
: `echo '${escapedTask}' | claude -p`;
return {
binary,
args: model ? ['-p', '--model', model] : ['-p'],
stdin: task,
};
case 'opencode':
// opencode non-interactive: pipe task via stdin
// NOTE: exact flags may vary — opencode may need --non-interactive or --pipe
return model
? `echo '${escapedTask}' | opencode --model '${model}'`
: `echo '${escapedTask}' | opencode`;
return {
binary,
args: model ? ['--model', model] : [],
stdin: task,
};
case 'qwen':
// Qwen Code: structured JSON output mode for parseable events
return model
? `qwen -p '${escapedTask}' --model '${model}' --output-format stream-json`
: `qwen -p '${escapedTask}' --output-format stream-json`;
return {
binary,
args: model
? ['-p', task, '--model', model, '--output-format', 'stream-json']
: ['-p', task, '--output-format', 'stream-json'],
};
case 'goose':
// Not yet verified for non-interactive use
return null;
case 'pi':
// Not yet verified for non-interactive use
return null;
return {
binary,
args: model
? ['run', '--text', task, '--model', model]
: ['run', '--text', task],
};
default:
return null;
}
}
/**
* Dispatch a task to an external agent via SSH.
*
* The agent runs in the worktree directory on the host. stdout/stderr are
* captured in full and returned. The SSH process is killed on abort signal.
*/
export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchResult> {
const { agent, task, worktreePath, model, signal, log } = opts;
const { agent, task, worktreePath, model, installPath, signal, log } = opts;
const agentCmd = buildAgentCommand(agent, task, model);
if (!agentCmd) {
const cmd = buildAgentCommand(agent, task, model, installPath);
if (!cmd) {
return {
exitCode: 1,
stdout: '',
@@ -89,22 +87,19 @@ export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchRes
};
}
// Wrap in cd to the worktree
const fullCommand = `cd '${worktreePath.replace(/'/g, "'\\''")}' && ${agentCmd}`;
log.info({ agent, worktreePath }, 'pty-dispatch: starting');
log.info({ agent, binary: cmd.binary, worktreePath }, 'pty-dispatch: starting');
return new Promise<DispatchResult>((resolve, reject) => {
const child = sshSpawnWithStdin(fullCommand, '');
// Note: sshSpawnWithStdin already closes stdin. For agents that read from
// stdin via echo piping, the command itself handles the piping on the remote
// side. We just need the SSH tunnel.
const child = spawn(cmd.binary, cmd.args, {
cwd: worktreePath,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
// Actually, re-think: sshSpawnWithStdin writes input and closes stdin on the
// LOCAL ssh process. But the remote command is `echo '...' | agent`, which
// provides its own stdin. So we should use sshSpawn (no local stdin needed)
// or just let the empty stdin close — the remote shell handles piping internally.
// This is fine as-is because the echo piping happens WITHIN the remote shell command.
if (cmd.stdin) {
child.stdin!.write(cmd.stdin);
}
child.stdin!.end();
let stdout = '';
let stderr = '';
@@ -117,7 +112,6 @@ export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchRes
if (!killed) {
killed = true;
child.kill('SIGTERM');
// Give it a moment then force-kill
setTimeout(() => child.kill('SIGKILL'), 5_000);
}
};

View File

@@ -1,4 +1,7 @@
/**
* @deprecated v2.1.1 — BooCoder runs on the host now. Use direct spawn/exec instead.
* Kept for one release cycle in case of rollback.
*
* SSH helper — spawns commands on the host via SSH.
*
* BooCode's container cannot directly spawn host processes (opencode, goose, claude, pi).