Persistent multi-turn opencode backend: one `opencode serve` HTTP server per BooCoder process, one opencode session per BooCode session (resumed on switch-back), single SSE read loop demuxed by session id. - backends/opencode-server.ts: AgentBackend implementation — spawn with waitForReady, session.next.* SSE event translation (text/reasoning/tool deltas), Paseo-ported reasoning dedup (streamedPartKeys), promptAsync fire-and-forget settled by session.idle, per-turn inactivity watchdog (180s) + reconnect reconciliation via session.messages, stale-session guard (crashed-not-resumed + config_hash fingerprint on model). - dispatcher.ts: opencode routes to pool backend (ensureSession→prompt); per-session concurrency Map replaces global running boolean (1.9); model coalesce (empty→DEFAULT_MODEL) + llama-swap/ prefix for opencode; diff-supersede (DELETE+INSERT pending_changes by session, stamp agent). - worktrees.ts: ensureSessionWorktree (session-keyed, captures base_commit, persists to session_worktrees); diffWorktree gains optional baseRef. - agent-probe.ts: mergeLlamaSwap branch fetches /v1/models, prefixes with llama-swap/, populates opencode's available_agents.models (was 0). - provider-snapshot.ts: export fetchLlamaSwapModels for probe reuse. - schema.sql: session_worktrees + agent_sessions tables (Phase 0) + config_hash column on agent_sessions, pending_changes.agent column. - package.json: @opencode-ai/sdk ~1.15.0 (resolved 1.15.12). Known Phase 1 limitation: single SSE stream scoped to most-recent session's directory; concurrent opencode sessions in different worktrees collide (warning logged, watchdog prevents hang). Phase 2 moves to per-session SSE. Smoke 1 verified: two turns in one session, both produce real tokens, same agent_session_id reused, same server port, turn 2 is 9x faster (no spawn). goose/qwen/claude paths untouched (runExternalAgent md5 identical). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
5.8 KiB
TypeScript
159 lines
5.8 KiB
TypeScript
import type { Sql } from '../db.js';
|
|
import type { FastifyBaseLogger } from 'fastify';
|
|
import { exec as execCb, execFile as execFileCb } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import { PROVIDERS_BY_NAME } from './provider-registry.js';
|
|
import { resolveAcpProbeBinaries } from './acp-spawn.js';
|
|
import { clearProviderSnapshotCache, fetchLlamaSwapModels, prefixLlamaSwapModels } from './provider-snapshot.js';
|
|
import { readQwenSettingsModels } from './qwen-settings.js';
|
|
import { loadConfig } from '../config.js';
|
|
import { loadProviderConfig } from './provider-config-registry.js';
|
|
|
|
const exec = promisify(execCb);
|
|
const execFile = promisify(execFileCb);
|
|
|
|
// `which` via execFile (no shell) — the binary name can come from the config
|
|
// file (custom ACP entries), so avoid interpolating it into a shell string.
|
|
async function whichBinary(bin: string): Promise<string | null> {
|
|
try {
|
|
const { stdout } = await execFile('which', [bin], { timeout: 10_000 });
|
|
const path = stdout.trim();
|
|
return path || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function resolveInstallPath(agentName: string): Promise<string | null> {
|
|
const candidates = resolveAcpProbeBinaries(agentName);
|
|
for (const bin of candidates) {
|
|
const path = await whichBinary(bin);
|
|
if (path) return path;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function detectAcpSupport(agentName: string, installPath: string): Promise<boolean> {
|
|
const transport = PROVIDERS_BY_NAME.get(agentName)?.transport;
|
|
if (transport !== 'acp') return false;
|
|
|
|
if (agentName === 'qwen') {
|
|
try {
|
|
const { stdout } = await exec(`"${installPath}" --help`, { timeout: 10_000 });
|
|
return stdout.includes('--acp');
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await exec(`"${installPath}" acp --help`, { timeout: 10_000 });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Probe for available agents on the HOST.
|
|
*
|
|
* v2.3: iterates the resolved provider registry (built-ins + config-backed
|
|
* custom ACP entries) rather than the hardcoded `PROBED_AGENT_NAMES`. Native
|
|
* boocode is not probed; disabled providers are skipped (their `available_agents`
|
|
* row is kept, not deleted). `enabled` is read from the in-memory registry only —
|
|
* no DB column in Phase 1 (design.md §3.3).
|
|
*/
|
|
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
|
|
clearProviderSnapshotCache();
|
|
log.info('agent-probe: scanning for known agents');
|
|
|
|
const registry = loadProviderConfig(loadConfig().CODER_PROVIDERS_PATH);
|
|
|
|
for (const resolved of registry.values()) {
|
|
const agentName = resolved.id;
|
|
|
|
// Native boocode is not a probed host agent.
|
|
if (resolved.transport === 'native') continue;
|
|
|
|
// Disabled providers: skip the probe, keep any existing row.
|
|
if (!resolved.enabled) {
|
|
log.info({ agent: agentName }, 'agent-probe: skipping disabled provider');
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
// Custom ACP entries resolve their binary from command[0]; built-ins use
|
|
// the per-agent probe binaries.
|
|
const installPath = resolved.isCustomAcp && resolved.launchCommand
|
|
? await whichBinary(resolved.launchCommand[0])
|
|
: await resolveInstallPath(agentName);
|
|
if (!installPath) continue;
|
|
|
|
let version: string | null = null;
|
|
try {
|
|
const { stdout: verOut } = await exec(`"${installPath}" --version`, { timeout: 15_000 });
|
|
version = verOut.trim().slice(0, 100);
|
|
} catch {
|
|
/* optional */
|
|
}
|
|
|
|
// Custom ACP entries are ACP by declaration; built-ins detect support.
|
|
let supportsAcp: boolean;
|
|
if (resolved.isCustomAcp) {
|
|
supportsAcp = true;
|
|
} else {
|
|
supportsAcp = resolved.transport === 'acp';
|
|
if (supportsAcp) {
|
|
supportsAcp = await detectAcpSupport(agentName, installPath);
|
|
}
|
|
}
|
|
|
|
let models: Array<{ id: string; label: string }> = [];
|
|
if (!resolved.isCustomAcp) {
|
|
const providerDef = PROVIDERS_BY_NAME.get(agentName);
|
|
if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
|
|
models = providerDef.staticModels;
|
|
}
|
|
if (agentName === 'qwen') {
|
|
models = await readQwenSettingsModels();
|
|
}
|
|
if (providerDef?.mergeLlamaSwap) {
|
|
try {
|
|
const config = loadConfig();
|
|
const llamaModels = prefixLlamaSwapModels(await fetchLlamaSwapModels(config));
|
|
models = [...models, ...llamaModels];
|
|
} catch (err) {
|
|
log.warn({ agent: agentName, err: err instanceof Error ? err.message : String(err) }, 'agent-probe: llama-swap model fetch failed (non-fatal)');
|
|
}
|
|
}
|
|
}
|
|
|
|
const label = resolved.configLabel ?? resolved.label;
|
|
const transport = resolved.isCustomAcp
|
|
? 'acp'
|
|
: resolved.transport === 'acp' && !supportsAcp
|
|
? 'pty'
|
|
: (resolved.transport ?? 'pty');
|
|
|
|
await sql`
|
|
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport)
|
|
VALUES (${agentName}, ${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,
|
|
models = EXCLUDED.models,
|
|
label = EXCLUDED.label,
|
|
transport = EXCLUDED.transport
|
|
`;
|
|
log.info({ agent: agentName, version, installPath, supportsAcp, modelCount: models.length }, 'agent-probe: found');
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
log.debug({ agent: agentName, err: msg }, 'agent-probe: not found');
|
|
}
|
|
}
|
|
|
|
log.info('agent-probe: scan complete');
|
|
}
|