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:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user