UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

188 lines (187 loc) • 9.54 kB
import { spawn, execFileSync } from "node:child_process"; import { randomBytes } from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; import { atomicWriteText } from "../phren-paths.js"; import { manifestSchema } from "../bridge/fanouts.js"; import { codex } from "./adapters/codex.js"; import { opencode } from "./adapters/opencode.js"; import { claude } from "./adapters/claude.js"; import { uuid } from "./adapters/types.js"; import { LoopWatchdog, stderrRefusal } from "./watchdog.js"; export const adapters = { codex, opencode, claude }; export function jobsRoot(store) { return path.join(store, ".runtime", "agent-fanouts"); } export function readJob(store, id) { if (!/^[a-z0-9][a-z0-9._-]{0,127}$/i.test(id)) throw new Error("Invalid job id."); const root = fs.realpathSync(jobsRoot(store)), job = path.join(root, id); if (!fs.lstatSync(job).isDirectory() || fs.realpathSync(job) !== job) throw new Error("Invalid job directory."); const file = path.join(job, "manifest.json"), info = fs.lstatSync(file); if (!info.isFile() || info.isSymbolicLink() || info.size > 65_536) throw new Error("Invalid job manifest."); const result = manifestSchema.parse(JSON.parse(fs.readFileSync(file, "utf8"))); if (result.id !== id) throw new Error("Job id does not match its manifest."); return result; } export function listJobs(store) { if (!fs.existsSync(jobsRoot(store))) return []; return fs.readdirSync(jobsRoot(store)).flatMap(id => { try { return [readJob(store, id)]; } catch { return []; } }); } export function swiftBuildCount() { const output = execFileSync("ps", ["-axo", "comm="], { encoding: "utf8", timeout: 5_000 }); return output.split("\n").filter(line => path.basename(line.trim()) === "xcodebuild").length; } /** The nearest codex or claude process above this one. Each agent sets its * own id for the commands it runs and inherits the other's, so a Claude * started from Codex has both; the process tree says which one launched. */ export function nearestAgent(pid = process.ppid) { if (process.platform === "win32") return undefined; for (let depth = 0; depth < 16 && pid > 1; depth++) { let row; try { row = execFileSync("ps", ["-o", "ppid=,comm=", "-p", String(pid)], { encoding: "utf8", timeout: 2_000 }).trim(); } catch { return undefined; } const match = /^(\d+)\s+(.+)$/.exec(row); if (!match) return undefined; const name = path.basename(match[2].trim()); if (name === "claude") return "claude"; if (name === "codex" || name.startsWith("codex-")) return "codex"; pid = Number(match[1]); } return undefined; } export function writeManifest(job, manifest) { atomicWriteText(path.join(job, "manifest.json"), JSON.stringify(manifestSchema.parse(manifest)) + "\n"); fs.chmodSync(path.join(job, "manifest.json"), 0o600); } export function createJob(options, env = process.env, nearest = nearestAgent) { if (!options.label.trim() || options.label.length > 200) throw new Error("Choose a label of 1 to 200 characters."); const worktree = fs.realpathSync(options.worktree); if (!fs.statSync(worktree).isDirectory()) throw new Error("Choose a worktree directory."); const id = `${options.provider}-${randomBytes(12).toString("hex")}`; const root = jobsRoot(options.store), job = path.join(root, id); fs.mkdirSync(job, { recursive: true, mode: 0o700 }); fs.chmodSync(root, 0o700); const claudeParent = uuid(env.CLAUDE_CODE_SESSION_ID); let codexParent = uuid(env.CODEX_THREAD_ID || env.CODEX_SESSION_ID); if (codexParent && claudeParent && nearest() === "claude") codexParent = undefined; const now = new Date().toISOString(); const manifest = { schemaVersion: 1, id, provider: options.provider, taskLabel: options.label, cwd: worktree, worktree, model: options.model, eventLog: "events.jsonl", createdAt: now, startedAt: now, updatedAt: now, status: "running", reason: options.reason, ...(options.resume ? { resumes: options.resume } : {}), ...(codexParent ? { parent: { provider: "codex", session: codexParent } } : claudeParent ? { parent: { provider: "claude", session: claudeParent } } : {}) }; for (const [file, content] of Object.entries({ "prompt.txt": options.prompt, "events.jsonl": "", "stderr.log": "" })) fs.writeFileSync(path.join(job, file), content, { mode: 0o600 }); writeManifest(job, manifest); return { job, manifest }; } export async function launch(options, reservation = createJob(options)) { const { job, manifest } = reservation, adapter = adapters[options.provider]; console.log(job); const out = fs.openSync(path.join(job, manifest.eventLog), "a"), err = fs.openSync(path.join(job, "stderr.log"), "a"); // A served harness answers only with this password, so another local // process cannot approve the worker's permission asks. const password = randomBytes(24).toString("hex"); const child = spawn("nice", ["-n", "15", adapter.command, ...adapter.argv({ ...options, job })], { cwd: manifest.worktree, env: { ...process.env, PHREN_FANOUT_JOB: manifest.id, PHREN_FANOUT_DIR: job, ...(adapter.drive ? { PHREN_FANOUT_APPROVALS: "1", OPENCODE_SERVER_USERNAME: "opencode", OPENCODE_SERVER_PASSWORD: password } : {}) }, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32", }); let pending = "", stderr = "", cancelled = false; let refused; const watchdog = new LoopWatchdog(); const kill = () => { try { if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGTERM"); else child.kill("SIGTERM"); } catch { /* Already exited. */ } }; const cancel = () => { cancelled = true; kill(); }; for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, cancel); const record = (session) => { if (session && !manifest.session) { manifest.session = session; writeManifest(job, manifest); } }; const events = (chunk) => { fs.writeSync(out, typeof chunk === "string" ? chunk : chunk.toString("utf8")); pending += chunk.toString(); let newline; while ((newline = pending.indexOf("\n")) >= 0) { const line = pending.slice(0, newline); pending = pending.slice(newline + 1); try { const event = JSON.parse(line); watchdog.observe(event); record(adapter.session(event)); refused ??= adapter.refusal?.(event); } catch { /* Partial or non-JSON provider output. */ } } if (pending.length > 1_048_576) pending = ""; }; if (!adapter.drive) child.stdout?.on("data", events); child.stderr?.on("data", (chunk) => { fs.writeSync(err, chunk); stderr = (stderr + chunk.toString("utf8")).slice(-16_384); }); child.stdin?.on("error", () => { }); child.stdin?.end(adapter.drive ? undefined : options.prompt); const closed = new Promise(resolve => { child.once("error", error => { fs.writeSync(err, `${error.message}\n`); resolve(127); }); child.once("close", code => resolve(cancelled ? 130 : code ?? 1)); }); const timer = setInterval(() => { const reason = watchdog.reason(); if (!reason) return; fs.writeFileSync(path.join(job, "looped.txt"), reason + "\n", { mode: 0o600 }); atomicWriteText(path.join(job, "blocked.json"), JSON.stringify({ type: "tool_loop", pattern: "", message: reason, at: new Date().toISOString() })); kill(); }, 30_000); let exitCode; if (adapter.drive) { // The served harness never exits on its own: the driver's result is the // worker's, and the server stops once the session is done. const driven = await Promise.race([ adapter.drive(child, { ...options, worktree: manifest.worktree, job, jobId: manifest.id, password, emit: events, session: record }) .catch(error => { fs.writeSync(err, `${error instanceof Error ? error.message : String(error)}\n`); return 1; }), closed.then(code => code === 0 ? 1 : code) ]); kill(); await closed; exitCode = cancelled ? 130 : driven; } else exitCode = await closed; clearInterval(timer); for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.off(signal, cancel); fs.closeSync(out); fs.closeSync(err); const blockedFile = path.join(job, "blocked.json"), refusal = refused ?? stderrRefusal(stderr); if (refusal && !fs.existsSync(blockedFile)) atomicWriteText(blockedFile, JSON.stringify({ ...refusal, at: new Date().toISOString() })); const blocked = fs.existsSync(blockedFile); if (blocked) console.error(`blocked: ${fs.readFileSync(blockedFile, "utf8").slice(0, 4000)}`); fs.writeFileSync(path.join(job, "exit.txt"), `${exitCode}\n`, { mode: 0o600 }); writeManifest(job, { ...manifest, status: blocked ? "failed" : cancelled ? "cancelled" : exitCode === 0 ? "completed" : "failed", exitCode, updatedAt: new Date().toISOString(), finishedAt: new Date().toISOString() }); return blocked && exitCode === 0 ? 1 : exitCode; }