import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { existsSync } from "node:fs"; import path from "node:path"; import { resolveOnPath, AGENTS, type AgentDef, type AgentProtocol } from "./agents-detect.js"; export type InvokeOpts = { agent: string; prompt: string; cwd?: string; model?: string; signal?: AbortSignal; binOverride?: string; }; type BinResolution = | { kind: "ok"; bin: string } | { kind: "override-missing"; tried: string } | { kind: "not-found" }; function resolveBinForAgent( def: (typeof AGENTS)[number], binOverride: string | undefined, ): BinResolution { const tryPath = (p: string | undefined): string | null => { if (!p) return null; const trimmed = p.trim(); if (!trimmed) return null; if (/^([a-zA-Z]:[\\/]|[\\/])/.test(trimmed)) { return existsSync(trimmed) ? trimmed : null; } if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.startsWith(".")) { const resolved = path.resolve(trimmed); return existsSync(resolved) ? resolved : null; } return resolveOnPath(trimmed); }; if (binOverride && binOverride.trim()) { const fromOverride = tryPath(binOverride); if (fromOverride) return { kind: "ok", bin: fromOverride }; return { kind: "override-missing", tried: binOverride.trim() }; } if (def.envOverride) { const fromEnv = tryPath(process.env[def.envOverride]); if (fromEnv) return { kind: "ok", bin: fromEnv }; } for (const c of [def.bin, ...(def.fallbackBins ?? [])]) { const found = resolveOnPath(c); if (found) return { kind: "ok", bin: found }; } return { kind: "not-found" }; } export type InvokeEvent = | { type: "start"; bin: string; argv: string[]; promptBytes: number } | { type: "delta"; text: string } | { type: "html"; text: string } | { type: "meta"; key: string; value: unknown } | { type: "stderr"; text: string } | { type: "raw"; text: string } | { type: "done"; code: number | null } | { type: "error"; message: string }; // ─── argv builder ──────────────────────────────────────────────────── type AgentArgvOpts = { model?: string; openclawAgentId?: string; }; class UnsupportedAgentProtocolError extends Error { constructor(public readonly agent: string, public readonly protocol: string) { super( `${agent} uses the ${protocol} protocol, which is not yet wired up in this build. ` + `Pick one of: claude / codex / cursor-agent / gemini / copilot / opencode / qwen / qoder / codewhale / deepseek-tui / aider.`, ); } } function buildArgv(agent: string, opts: AgentArgvOpts = {}): string[] { const { model } = opts; switch (agent) { case "claude": return [ "-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--permission-mode", "bypassPermissions", ...(model ? ["--model", model] : []), ]; case "openclaw": return [ "agent", "--local", "--json", "--agent", opts.openclawAgentId ?? "main", ...(model ? ["--model", model] : []), ]; case "codex": return [ "exec", "--json", "--skip-git-repo-check", "--sandbox", "workspace-write", "-c", "sandbox_workspace_write.network_access=true", ...(model ? ["--model", model] : []), ]; case "cursor-agent": return [ "--print", "--output-format", "stream-json", "--stream-partial-output", "--force", "--trust", ...(model ? ["--model", model] : []), ]; case "gemini": return [ "--output-format", "stream-json", "--yolo", ...(model ? ["--model", model] : []), ]; case "copilot": return [ "--allow-all-tools", "--output-format", "json", ...(model ? ["--model", model] : []), ]; case "opencode": return [ "run", "--format", "json", "--dangerously-skip-permissions", ...(model ? ["--model", model] : []), "-", ]; case "qwen": return ["--yolo", ...(model ? ["--model", model] : []), "-"]; case "aider": return [ "--no-pretty", "--no-stream", "--yes-always", "--message-file", "-", ...(model ? ["--model", model] : []), ]; case "qoder": return [ "-p", "--output-format", "stream-json", "--yolo", ...(model ? ["--model", model] : []), ]; case "codewhale": case "deepseek-tui": return ["exec", "--auto", ...(model ? ["--model", model] : [])]; case "hermes": case "kimi": case "devin": case "kiro": case "kilo": case "vibe": throw new UnsupportedAgentProtocolError(agent, "ACP JSON-RPC"); case "pi": throw new UnsupportedAgentProtocolError(agent, "pi-rpc"); default: throw new Error(`unknown agent: ${agent}`); } } function envFor(agent: string): NodeJS.ProcessEnv { const base = { ...process.env }; if (agent === "gemini") base.GEMINI_CLI_TRUST_WORKSPACE = "true"; return base; } // ─── stdout parser ──────────────────────────────────────────────────── type AgentParse = | { kind: "delta"; text: string } | { kind: "meta"; key: string; value: unknown } | { kind: "html"; text: string } | { kind: "noise" }; type ParseState = { sawStreamEventText?: boolean }; function rescueHtmlFromToolUse( content: Array<{ type?: string; name?: string; input?: unknown }> | undefined, ): string { if (!Array.isArray(content)) return ""; const parts: string[] = []; for (const block of content) { if (!block || block.type !== "tool_use") continue; const name = (block.name ?? "").toLowerCase(); if ( name !== "write" && name !== "create_file" && name !== "createfile" && name !== "writefile" && name !== "write_file" && name !== "filewrite" ) continue; const input = block.input as Record | undefined; if (!input || typeof input !== "object") continue; const path = String(input.file_path ?? input.path ?? input.filename ?? "").toLowerCase(); if (path && !/\.(html?|htm)$/.test(path)) continue; const text = typeof input.content === "string" ? input.content : typeof input.text === "string" ? input.text : typeof input.file_content === "string" ? input.file_content : ""; if (text) parts.push(text); } return parts.join(""); } function parseLineWithState(agent: string, line: string, state: ParseState): AgentParse[] { const trimmed = line.trim(); if (!trimmed) return []; if (agent === "aider" || agent === "codewhale" || agent === "deepseek-tui") { return [{ kind: "delta", text: trimmed.endsWith("\n") ? trimmed : trimmed + "\n" }]; } let parsed: unknown; try { parsed = JSON.parse(trimmed); } catch { return [{ kind: "noise" }]; } if (!parsed || typeof parsed !== "object") return []; const obj = parsed as Record; const out: AgentParse[] = []; if (agent === "claude") { if (obj.type === "system" && obj.subtype === "init") { out.push({ kind: "meta", key: "model", value: obj.model }); out.push({ kind: "meta", key: "session", value: obj.session_id }); if (obj.cwd) out.push({ kind: "meta", key: "cwd", value: obj.cwd }); } if (obj.type === "stream_event" && obj.event && typeof obj.event === "object") { const ev = obj.event as { type?: string; delta?: { type?: string; text?: string; thinking?: string } }; if (ev.type === "content_block_delta" && ev.delta?.type === "text_delta" && typeof ev.delta.text === "string") { state.sawStreamEventText = true; out.push({ kind: "delta", text: ev.delta.text }); } else if (ev.type === "content_block_delta" && ev.delta?.type === "thinking_delta") { out.push({ kind: "meta", key: "thinking", value: ev.delta.thinking }); } } if (obj.type === "assistant" && obj.message && typeof obj.message === "object") { const msg = obj.message as { content?: Array<{ type?: string; text?: string; name?: string; input?: unknown }>; usage?: Record; model?: string; }; const toolHtml = rescueHtmlFromToolUse(msg.content); if (toolHtml) { out.push({ kind: "html", text: toolHtml }); state.sawStreamEventText = true; } if (!state.sawStreamEventText) { const text = (msg.content ?? []) .filter((c) => c?.type === "text" && typeof c.text === "string") .map((c) => c.text!) .join(""); if (text) out.push({ kind: "delta", text }); } if (msg.usage) out.push({ kind: "meta", key: "usage_partial", value: msg.usage }); } if (obj.type === "result") { if (obj.usage) out.push({ kind: "meta", key: "usage", value: obj.usage }); if (typeof obj.duration_ms === "number") out.push({ kind: "meta", key: "duration_ms", value: obj.duration_ms }); if (typeof obj.total_cost_usd === "number") out.push({ kind: "meta", key: "cost_usd", value: obj.total_cost_usd }); if (typeof obj.subtype === "string") out.push({ kind: "meta", key: "result", value: obj.subtype }); } } if (agent === "codex") { if (obj.type === "item.completed" && obj.item && typeof obj.item === "object") { const item = obj.item as { item_type?: string; type?: string; text?: string }; const itemType = item.item_type ?? item.type; if ( (itemType === "assistant_message" || itemType === "agent_message") && typeof item.text === "string" ) { out.push({ kind: "delta", text: item.text }); } } if (obj.type === "item.delta" && typeof obj.text === "string") { out.push({ kind: "delta", text: obj.text }); } if (obj.msg && typeof obj.msg === "object") { const msg = obj.msg as { type?: string; message?: string }; if (msg.type === "agent_message" && typeof msg.message === "string") { out.push({ kind: "delta", text: msg.message }); } } if (obj.type === "task_complete" && obj.usage) { out.push({ kind: "meta", key: "usage", value: obj.usage }); } if (obj.type === "turn.completed" && obj.usage) { out.push({ kind: "meta", key: "usage", value: obj.usage }); } } if (agent === "cursor-agent" || agent === "gemini") { if (obj.type === "stream_event" && obj.event && typeof obj.event === "object") { const ev = obj.event as { type?: string; delta?: { type?: string; text?: string } }; if (ev.delta?.type === "text_delta" && typeof ev.delta.text === "string") { state.sawStreamEventText = true; out.push({ kind: "delta", text: ev.delta.text }); } } if (obj.type === "assistant" && obj.message && typeof obj.message === "object") { const msg = obj.message as { content?: Array<{ type?: string; text?: string; name?: string; input?: unknown }> }; const toolHtml = rescueHtmlFromToolUse(msg.content); if (toolHtml) { out.push({ kind: "html", text: toolHtml }); state.sawStreamEventText = true; } if (!state.sawStreamEventText) { const text = (msg.content ?? []) .filter((c) => c?.type === "text" && typeof c.text === "string") .map((c) => c.text!) .join(""); if (text) out.push({ kind: "delta", text }); } } if (typeof obj.text === "string" && !state.sawStreamEventText && obj.type !== "assistant") { out.push({ kind: "delta", text: obj.text as string }); } } if (agent === "copilot") { if (typeof obj.response === "string") out.push({ kind: "delta", text: obj.response }); if (typeof obj.text === "string") out.push({ kind: "delta", text: obj.text }); } if (agent === "opencode" || agent === "qwen") { if (typeof obj.text === "string") out.push({ kind: "delta", text: obj.text }); if (typeof obj.content === "string") out.push({ kind: "delta", text: obj.content }); if (typeof obj.message === "string") out.push({ kind: "delta", text: obj.message }); } if (agent === "qoder") { if (obj.type === "system" && obj.subtype === "init") { if (obj.model) out.push({ kind: "meta", key: "model", value: obj.model }); if (obj.session_id) out.push({ kind: "meta", key: "session", value: obj.session_id }); } if (obj.type === "stream_event" && obj.event && typeof obj.event === "object") { const ev = obj.event as { type?: string; delta?: { type?: string; text?: string } }; if (ev.type === "content_block_delta" && ev.delta?.type === "text_delta" && typeof ev.delta.text === "string") { state.sawStreamEventText = true; out.push({ kind: "delta", text: ev.delta.text }); } } if (obj.type === "assistant" && obj.message && typeof obj.message === "object") { const msg = obj.message as { content?: Array<{ type?: string; text?: string; name?: string; input?: unknown }> }; const toolHtml = rescueHtmlFromToolUse(msg.content); if (toolHtml) { out.push({ kind: "html", text: toolHtml }); state.sawStreamEventText = true; } if (!state.sawStreamEventText) { const text = (msg.content ?? []) .filter((c) => c?.type === "text" && typeof c.text === "string") .map((c) => c.text!) .join(""); if (text) out.push({ kind: "delta", text }); } } if (obj.type === "result") { if (obj.usage) out.push({ kind: "meta", key: "usage", value: obj.usage }); if (typeof obj.duration_ms === "number") out.push({ kind: "meta", key: "duration_ms", value: obj.duration_ms }); } if (typeof obj.text === "string" && !state.sawStreamEventText && obj.type !== "assistant") { out.push({ kind: "delta", text: obj.text }); } } return out; } function makeParser(agent: string): (line: string) => AgentParse[] { const state: ParseState = {}; return (line: string) => parseLineWithState(agent, line, state); } // ─── resolve OpenClaw agent id ──────────────────────────────────────── let openclawAgentIdCache: { value: string; expiresAt: number } | null = null; async function resolveOpenclawAgentId(bin: string): Promise { const now = Date.now(); if (openclawAgentIdCache && openclawAgentIdCache.expiresAt > now) { return openclawAgentIdCache.value; } let resolved = "main"; try { const { spawn: spawnAsync } = await import("node:child_process"); const out = await new Promise((res, rej) => { const child = spawnAsync(bin, ["agents", "list"], { stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32", }); let buf = ""; child.stdout.setEncoding("utf8"); child.stdout.on("data", (c: string) => (buf += c)); child.on("close", () => res(buf)); child.on("error", rej); setTimeout(() => { try { child.kill("SIGTERM"); } catch {} rej(new Error("openclaw agents list timed out")); }, 5_000); }); const m = out.match(/^- (\S+)/m); if (m && m[1]) resolved = m[1]; } catch {} openclawAgentIdCache = { value: resolved, expiresAt: now + 5 * 60_000 }; return resolved; } // ─── main invoke function ───────────────────────────────────────────── export function invokeAgent(opts: InvokeOpts): ReadableStream { const def = AGENTS.find((a) => a.id === opts.agent); if (!def) { return errorStream(`unknown agent: ${opts.agent}`); } const resolved = resolveBinForAgent(def, opts.binOverride); if (resolved.kind === "override-missing") { return errorStream( `${def.label}: custom path \`${resolved.tried}\` does not exist.`, ); } if (resolved.kind === "not-found") { return errorStream( `${def.label} (\`${def.bin}\`) is not installed or not on PATH.`, ); } const bin: string = resolved.bin; const env = envFor(opts.agent); const promptViaArgv = def.protocol === "argv"; const promptViaMessageFlag = def.protocol === "argv-message"; return new ReadableStream({ async start(controller) { let closed = false; let child: ChildProcessWithoutNullStreams | null = null; const safeEnqueue = (ev: InvokeEvent) => { if (closed) return; try { controller.enqueue(ev); } catch { closed = true; } }; const safeClose = () => { if (closed) return; closed = true; try { controller.close(); } catch {} }; let argv: string[]; try { const argvOpts: AgentArgvOpts = { model: opts.model, }; if (opts.agent === "openclaw") { argvOpts.openclawAgentId = await resolveOpenclawAgentId(bin); } argv = buildArgv(opts.agent, argvOpts); } catch (err) { safeEnqueue({ type: "error", message: err instanceof UnsupportedAgentProtocolError ? err.message : err instanceof Error ? err.message : String(err), }); safeClose(); return; } if (promptViaArgv) argv = [...argv, opts.prompt]; if (promptViaMessageFlag) argv = [...argv, "--message", opts.prompt]; try { child = spawn(bin, argv, { cwd: opts.cwd ?? process.cwd(), env, stdio: ["pipe", "pipe", "pipe"], shell: process.platform === "win32", }); } catch (err) { safeEnqueue({ type: "error", message: err instanceof Error ? err.message : String(err), }); safeClose(); return; } safeEnqueue({ type: "start", bin, argv, promptBytes: Buffer.byteLength(opts.prompt, "utf8"), }); child.stdin.on("error", () => {}); try { if (!promptViaArgv && !promptViaMessageFlag) child.stdin.write(opts.prompt); child.stdin.end(); } catch {} const parse = makeParser(opts.agent); let stdoutBuf = ""; child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => { if (closed) return; stdoutBuf += chunk; if (opts.agent === "openclaw") return; let nl: number; while ((nl = stdoutBuf.indexOf("\n")) !== -1) { const line = stdoutBuf.slice(0, nl); stdoutBuf = stdoutBuf.slice(nl + 1); if (!line) continue; for (const part of parse(line)) { if (part.kind === "delta") safeEnqueue({ type: "delta", text: part.text }); else if (part.kind === "html") safeEnqueue({ type: "html", text: part.text }); else if (part.kind === "meta") safeEnqueue({ type: "meta", key: part.key, value: part.value }); else safeEnqueue({ type: "raw", text: line.slice(0, 240) }); } } }); child.stderr.setEncoding("utf8"); child.stderr.on("data", (chunk: string) => { safeEnqueue({ type: "stderr", text: chunk }); }); child.on("error", (err) => { safeEnqueue({ type: "error", message: err.message }); safeClose(); }); child.on("close", (code) => { if (opts.agent === "openclaw") { if (stdoutBuf.trim()) { try { const obj = JSON.parse(stdoutBuf) as { payloads?: Array<{ text?: string }>; meta?: { finalAssistantVisibleText?: string; finalAssistantRawText?: string; executionTrace?: { winnerProvider?: string; winnerModel?: string }; completion?: { stopReason?: string }; agentMeta?: { sessionId?: string }; }; }; const text = obj?.meta?.finalAssistantVisibleText ?? obj?.meta?.finalAssistantRawText ?? obj?.payloads?.[0]?.text ?? ""; if (text) safeEnqueue({ type: "delta", text }); } catch (err) { safeEnqueue({ type: "error", message: `OpenClaw JSON parse failed: ${err instanceof Error ? err.message : String(err)}`, }); } } } else if (stdoutBuf) { if (opts.agent === "aider" || opts.agent === "codewhale" || opts.agent === "deepseek-tui") { safeEnqueue({ type: "delta", text: stdoutBuf }); } else { for (const part of parse(stdoutBuf)) { if (part.kind === "delta") safeEnqueue({ type: "delta", text: part.text }); else if (part.kind === "html") safeEnqueue({ type: "html", text: part.text }); else if (part.kind === "meta") safeEnqueue({ type: "meta", key: part.key, value: part.value }); } } } safeEnqueue({ type: "done", code }); safeClose(); }); const onAbort = () => { try { child?.kill("SIGTERM"); } catch {} safeClose(); }; opts.signal?.addEventListener("abort", onAbort, { once: true }); }, cancel() {}, }); } function errorStream(message: string): ReadableStream { return new ReadableStream({ start(controller) { controller.enqueue({ type: "error", message }); controller.close(); }, }); }