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 { 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 { 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 { 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 { 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'); }