import { spawnSync, spawn } from "node:child_process"; // auditor:ignore-line -- spawning headless opencode worker processes is this skill's core, documented function (see SKILL.md Prerequisites + PR #979 dependency disclosure) import { setTimeout as delay } from "node:timers/promises"; import { readFileSync, statSync, mkdirSync, appendFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const SERVER = process.env.HIVEMIND_SERVER_URL || "http://127.0.0.1:4096"; // SERVER comes from the environment and PORT is handed to a child process, so validate // both here - and still emit exactly one JSON line when they are unusable. const PORT = resolvePort(SERVER); function bail(error) { console.log(JSON.stringify({ ok: false, stage: "args", error, result: "", tokens: null, cost_usd: null, duration_ms: 0 })); process.exit(0); } function resolvePort(server) { let url; try { url = new URL(server); } catch { bail("HIVEMIND_SERVER_URL is not a valid URL"); } const port = url.port || "4096"; if (!/^[0-9]{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) bail("HIVEMIND_SERVER_URL has an invalid port"); return port; } const DEFAULT_MODEL = "opencode/mimo-v2.5-free"; const STDERR_TAIL = 300; const RUNS_DIR = join(dirname(dirname(fileURLToPath(import.meta.url))), ".runs"); let RUN = null, LABEL = null, AGENT = null; function logRun(event, extra = {}) { if (!RUN) return; try { mkdirSync(RUNS_DIR, { recursive: true }); appendFileSync(join(RUNS_DIR, `${RUN}.jsonl`), JSON.stringify({ ts: Date.now(), event, label: LABEL, agent: AGENT, ...extra }) + "\n"); } catch {} } function parseArgs(argv) { const out = { agent: null, model: null, dir: null, timeoutMs: 600000, run: null, label: null, task: [] }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === "--agent") out.agent = argv[++i] ?? null; else if (a === "--model") out.model = argv[++i] ?? null; else if (a === "--dir") out.dir = argv[++i] ?? null; else if (a === "--timeout") out.timeoutMs = Number(argv[++i]) * 1000 || out.timeoutMs; else if (a === "--run") out.run = argv[++i] ?? null; else if (a === "--label") out.label = argv[++i] ?? null; else out.task.push(a); } out.task = out.task.join(" ").trim(); return out; } function fail(stage, message, extra = {}) { const payload = { ok: false, stage, error: String(message).slice(0, STDERR_TAIL), result: "", tokens: null, cost_usd: null, duration_ms: 0, ...extra }; if (payload.duration_ms) logRun("fail", { stage, error: payload.error, duration_ms: payload.duration_ms }); console.log(JSON.stringify(payload)); process.exit(0); } async function alive(url = SERVER, ms = 500) { try { await fetch(url, { signal: AbortSignal.timeout(ms) }); return true; } catch { return false; } } function resolveOpencode() { if (process.platform !== "win32") { const posix = spawnSync("which", ["opencode"], { encoding: "utf8" }); const path = posix.status === 0 ? posix.stdout.trim().split(/\r?\n/)[0] : ""; return path || "opencode"; } // Windows: npm installs a .cmd shim, and Node's EINVAL policy blocks spawning // .cmd directly - so resolve the real .exe (parsing the shim when needed). const probe = spawnSync("where.exe", ["opencode"], { encoding: "utf8" }); if (probe.status === 0) { const lines = probe.stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean); const exe = lines.find(l => l.toLowerCase().endsWith(".exe")); if (exe) return exe; const cmdShim = lines.find(l => l.toLowerCase().endsWith(".cmd")); if (cmdShim) { try { const body = readFileSync(cmdShim, "utf8"); const m = body.match(/"%dp0%\\(.*?\.exe)"/i); if (m) return join(dirname(cmdShim), m[1]); } catch {} } } return "opencode"; } async function ensureServer(bin) { if (await alive()) return []; // Args array with shell:false - nothing here is shell-interpolated, so a hostile // HIVEMIND_SERVER_URL cannot inject syntax and paths with spaces need no quoting. const child = spawn(bin, ["serve", "--port", PORT], { shell: false, detached: true, stdio: "ignore", windowsHide: true }); child.unref(); for (let i = 0; i < 10; i++) { await delay(500); if (await alive()) return ["--attach", SERVER]; } return []; } async function main() { const t0 = Date.now(); const opts = parseArgs(process.argv.slice(2)); if (!opts.task) fail("args", "no task given"); RUN = opts.run; LABEL = opts.label || opts.agent || "worker"; AGENT = opts.agent; logRun("start", { model: opts.model, dir: opts.dir }); if (opts.dir && !existsDir(opts.dir)) fail("args", `--dir does not exist: ${opts.dir}`); const bin = resolveOpencode(); const attachArgs = await ensureServer(bin); const baseArgs = ["run"]; if (opts.agent) baseArgs.push("--agent", opts.agent); if (opts.model) baseArgs.push("--model", opts.model); if (opts.dir) baseArgs.push("--dir", opts.dir); baseArgs.push(...attachArgs); let r = spawnSync(bin, [...baseArgs, "--format", "json", opts.task], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024, timeout: opts.timeoutMs, env: { ...process.env }, windowsHide: true, }); if (!r.stdout || !r.stdout.trim()) { const tailErr = ((r.stderr || "") + " " + (r.error ? String(r.error) : "")).trim(); const retry = spawnSync(bin, [...baseArgs, opts.task], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: opts.timeoutMs, windowsHide: true, }); const text = (retry.stdout || "").trim(); if (text) { logRun("done", { degraded: true, duration_ms: Date.now() - t0 }); console.log(JSON.stringify({ ok: true, degraded: "plain-format-fallback", result: text.slice(-20000), tokens: null, cost_usd: null, duration_ms: Date.now() - t0, agent: opts.agent, model: opts.model })); return; } fail("exec", tailErr || `opencode exited ${r.status}`, { duration_ms: Date.now() - t0 }); } const parsed = parseNdjson(r.stdout); if (!parsed.ok) { fail(parsed.stage, parsed.error, { duration_ms: Date.now() - t0, model: opts.model }); } const duration = Date.now() - t0; logRun("done", { tokens_total: parsed.tokens?.total ?? null, duration_ms: duration }); console.log(JSON.stringify({ ok: true, result: parsed.result.slice(-20000), tokens: parsed.tokens, cost_usd: parsed.costUsd, session_id: parsed.sessionId, duration_ms: duration, label: LABEL, agent: opts.agent, model: opts.model ?? DEFAULT_MODEL, })); } function parseNdjson(stdout) { const lines = stdout.split(/\r?\n/).filter(l => l.trim()); const texts = []; let tokens = null, costUsd = null, sessionId = null, sawFinish = false; for (const line of lines) { let ev; try { ev = JSON.parse(line); } catch { continue; } if (ev.type === "error") { const msg = ev.error?.data?.message || ev.error?.message || "APIError"; return { ok: false, stage: "api", error: msg }; } if (ev.type === "text" && ev.part?.text != null) texts.push(ev.part.text); if (ev.type === "step_finish" && ev.part) { sawFinish = true; sessionId = ev.sessionID || sessionId; if (ev.part.tokens) tokens = ev.part.tokens; if (typeof ev.part.cost === "number") costUsd = ev.part.cost; } } const result = texts.join("\n").trim(); if (!result && !sawFinish) return { ok: false, stage: "parse", error: "no text parts and no step_finish in stream" }; if (!result) return { ok: false, stage: "empty", error: "worker produced no text output" }; return { ok: true, result, tokens, costUsd, sessionId }; } function existsDir(p) { try { return statSync(p).isDirectory(); } catch { return false; } } main().catch(e => fail("crash", e?.stack || e));