@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
188 lines (187 loc) • 9.54 kB
JavaScript
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;
}