@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
742 lines (741 loc) • 38.9 kB
JavaScript
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { lstat, mkdir, open, readdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { z } from "zod";
import { object, sessionId } from "./protocol.js";
import { countGit } from "./metrics.js";
import { claudeHomes } from "./claude-accounts.js";
import { readOpencodeRequest } from "./opencode-approvals.js";
const MAX_MANIFEST_BYTES = 64 * 1024;
const MAX_EVENT_LOG_BYTES = 64 * 1024 * 1024;
const MAX_BLOCKED_BYTES = 16 * 1024;
const MAX_JOBS = 128;
/** A finished job is archived once its finish stamp is older than this. */
const ARCHIVE_AGE_MS = 24 * 60 * 60 * 1000;
/** How many folders the archive keeps; the oldest beyond that are deleted. */
export const ARCHIVE_MAX_FOLDERS = 500;
const ARCHIVED_STATUSES = new Set(["completed", "failed", "cancelled"]);
const WORKTREE_CACHE_MS = 15_000;
const exec = promisify(execFile);
const jobID = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/);
const timestamp = z.string().datetime({ offset: true });
const opencodeSession = z.string().regex(/^ses_[0-9A-Za-z]{1,64}$/);
export const manifestSchema = z.object({
schemaVersion: z.literal(1),
id: jobID,
parent: z.object({
provider: z.enum(["codex", "claude", "copilot", "phren", "opencode"]),
session: sessionId,
computer: z.string().uuid().optional(),
}).strict().optional(),
provider: z.enum(["opencode", "codex", "claude"]),
session: sessionId.optional(),
taskLabel: z.string().min(1).max(200),
cwd: z.string().min(1).max(4096).refine(path.isAbsolute),
worktree: z.string().min(1).max(4096).refine(path.isAbsolute),
model: z.string().min(1).max(200).optional(),
eventLog: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.jsonl$/),
nativeTranscript: z.string().min(1).max(4096).refine(path.isAbsolute).optional(),
unmanagedPid: z.number().int().positive().optional(),
createdAt: timestamp,
startedAt: timestamp,
updatedAt: timestamp,
finishedAt: timestamp.optional(),
status: z.enum(["queued", "running", "completed", "failed", "cancelled"]),
exitCode: z.number().int().min(0).max(255).optional(),
reason: z.string().max(4000).optional(),
resumes: sessionId.optional(),
schedule: z.object({ id: z.string().regex(/^[a-f0-9]{8}$/), project: z.string().min(1).max(200) }).optional(),
}).strict().superRefine((manifest, ctx) => {
if (manifest.nativeTranscript && (manifest.provider !== "claude" || !manifest.unmanagedPid || !manifest.session))
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["nativeTranscript"], message: "A native transcript needs an unmanaged Claude process." });
if (manifest.unmanagedPid && !manifest.nativeTranscript)
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["unmanagedPid"], message: "An unmanaged process needs a native transcript." });
// OpenCode sessions are `ses_…`; Codex and Claude sessions are UUIDs.
// A provider cannot claim another provider's identity shape.
if (manifest.session === undefined)
return;
const valid = manifest.provider === "opencode" ? opencodeSession.safeParse(manifest.session).success
: z.string().uuid().safeParse(manifest.session).success;
if (!valid)
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["session"],
message: `A ${manifest.provider} manifest needs a matching session identity.` });
});
/** What the opencode plugin writes when it refuses a fan-out worker's
* permission: the ask that was denied, so the Hook can report a worker that
* exited 0 as blocked rather than finished. */
const blockedSchema = z.object({
type: z.string().min(1).max(200),
pattern: z.string().max(2000).optional(),
message: z.string().max(4000).optional(),
at: z.string().max(100).optional(),
}).passthrough();
const worktreeCache = new Map();
async function worktreeDetails(worktree) {
const now = Date.now(), cached = worktreeCache.get(worktree);
if (cached && cached.expiresAt > now)
return cached.details;
let details = {};
const metadata = await stat(worktree).catch(() => undefined);
if (metadata?.isDirectory()) {
const worktreeName = path.basename(worktree).slice(0, 200);
details = worktreeName ? { worktreeName } : {};
try {
countGit("fanouts");
const { stdout } = await exec("git", ["-C", worktree, "rev-parse", "--abbrev-ref", "HEAD"], { timeout: 2_000, maxBuffer: 4_096 });
const branch = stdout.trim();
if (branch && branch !== "HEAD")
details.branch = branch.slice(0, 200);
}
catch { /* Missing repositories, detached heads, and git errors have no public branch. */ }
}
worktreeCache.set(worktree, { expiresAt: Date.now() + WORKTREE_CACHE_MS, details });
while (worktreeCache.size > MAX_JOBS)
worktreeCache.delete(worktreeCache.keys().next().value);
return details;
}
export function storeRoot(env) {
const configured = env.PHREN_PATH?.trim();
return !configured ? path.join(homedir(), ".phren")
: configured === "~" ? homedir() : configured.startsWith("~/") ? path.join(homedir(), configured.slice(2)) : path.resolve(configured);
}
export function fanoutRoot(env = process.env) {
return path.join(storeRoot(env), ".runtime", "agent-fanouts");
}
/** Bind public worker identities to the canonical store as well as their parent. */
export function fanoutChildID(root, manifest) {
return createHash("sha256").update(`${root}\0${manifest.parent?.provider}\0${manifest.parent?.session}\0${manifest.id}`).digest("hex").slice(0, 32);
}
export async function containedFanoutRoot(env) {
try {
const store = await realpath(storeRoot(env));
const expected = path.join(store, ".runtime", "agent-fanouts");
return await realpath(expected) === expected ? expected : undefined;
}
catch {
return undefined;
}
}
async function regularContainedFile(root, candidate, maxBytes) {
try {
const link = await lstat(candidate);
if (!link.isFile() || link.isSymbolicLink() || link.size > maxBytes)
return;
const resolved = await realpath(candidate);
if (!resolved.startsWith(root + path.sep))
return;
const metadata = await stat(resolved);
return metadata.isFile() && metadata.size <= maxBytes ? resolved : undefined;
}
catch {
return;
}
}
export async function nativeClaudeTranscript(candidate, session) {
if (path.basename(candidate) !== `${session}.jsonl`)
return;
for (const home of claudeHomes()) {
const resolvedRoot = await realpath(path.join(home.dir, "projects")).catch(() => undefined);
if (!resolvedRoot)
continue;
const file = await regularContainedFile(resolvedRoot, candidate, MAX_EVENT_LOG_BYTES);
if (file)
return file;
}
return undefined;
}
function pidGone(pid) {
try {
process.kill(pid, 0);
return false;
}
catch (error) {
return error.code === "ESRCH";
}
}
async function readBlocked(jobRoot) {
const file = await regularContainedFile(jobRoot, path.join(jobRoot, "blocked.json"), MAX_BLOCKED_BYTES);
if (file) {
try {
return blockedSchema.parse(JSON.parse(await readFile(file, "utf8")));
}
catch { /* fall through to stderr */ }
}
return readRefusedFromStderr(jobRoot);
}
/** OpenCode refuses some permissions itself in headless runs, before any
* plugin sees them, and only says so on stderr: `permission requested:
* <type> (<pattern>); auto-rejecting`. The launcher captures stderr into the
* job directory, so that line is the evidence when blocked.json is absent. */
const REFUSED_LINE = /permission requested: ([a-z_]+) \(([^)]*)\); auto-rejecting/g;
async function readRefusedFromStderr(jobRoot) {
const file = await regularContainedFile(jobRoot, path.join(jobRoot, "stderr.log"), 4 * 1024 * 1024);
if (!file)
return undefined;
try {
const handle = await open(file, "r");
let tail;
let at;
try {
const info = await handle.stat();
const buffer = Buffer.alloc(Math.min(info.size, 16_384));
const { bytesRead } = await handle.read(buffer, 0, buffer.length, Math.max(0, info.size - buffer.length));
tail = buffer.toString("utf8", 0, bytesRead);
at = info.mtime.toISOString();
}
finally {
await handle.close();
}
let last;
for (const match of tail.matchAll(REFUSED_LINE))
last = match;
if (!last)
return undefined;
return { type: last[1], pattern: last[2].slice(0, 500), message: `${last[1]}: ${last[2]}`.slice(0, 600), at };
}
catch {
return undefined;
}
}
function blockedReason(value) {
const pattern = typeof value.pattern === "string" ? value.pattern.trim() : "";
return (pattern ? `blocked: ${value.type} ${pattern}` : `blocked: ${value.type}`).slice(0, 500);
}
/** Read only manifests explicitly bound to the already validated parent. */
export async function fanoutChildren(parentProvider, parentSession, env = process.env, parentComputer) {
if (!sessionId.safeParse(parentSession).success)
return [];
const root = await containedFanoutRoot(env);
if (!root)
return [];
// Newest first, so a directory that outgrew MAX_JOBS drops old finished
// jobs rather than the workers running right now.
const entries = (await readdir(root).catch(() => [])).filter(name => jobID.safeParse(name).success);
const stamped = await Promise.all(entries.map(async (name) => ({ name, at: (await stat(path.join(root, name)).catch(() => undefined))?.mtimeMs ?? 0 })));
const names = stamped.sort((a, b) => b.at - a.at).slice(0, MAX_JOBS).map(entry => entry.name);
const children = [];
for (const name of names) {
const directory = path.join(root, name);
const manifestFile = await regularContainedFile(root, path.join(directory, "manifest.json"), MAX_MANIFEST_BYTES);
if (!manifestFile)
continue;
try {
const manifest = manifestSchema.parse(JSON.parse(await readFile(manifestFile, "utf8")));
if (manifest.id !== name || manifest.parent?.provider !== parentProvider || manifest.parent.session !== parentSession
|| (manifest.parent.computer !== undefined && parentComputer !== undefined && manifest.parent.computer !== parentComputer))
continue;
const jobRoot = await realpath(directory);
if (!jobRoot.startsWith(root + path.sep))
continue;
const transcript = manifest.nativeTranscript && manifest.session
? await nativeClaudeTranscript(manifest.nativeTranscript, manifest.session)
?? await regularContainedFile(jobRoot, path.join(jobRoot, manifest.eventLog), MAX_EVENT_LOG_BYTES)
: await regularContainedFile(jobRoot, path.join(jobRoot, manifest.eventLog), MAX_EVENT_LOG_BYTES);
if (!transcript)
continue;
const worktree = await worktreeDetails(manifest.worktree);
const id = fanoutChildID(root, manifest);
// A denied permission aborts the turn while the launcher still records a
// zero exit; blocked.json is the only evidence the worker did not finish.
const blocked = await readBlocked(jobRoot);
// A running worker waiting on the phone's answer to a permission ask.
const asking = !blocked && manifest.status === "running" && manifest.provider === "opencode" && manifest.session
? await readOpencodeRequest(manifest.session, storeRoot(env)) : undefined;
const gone = manifest.status === "running" && manifest.unmanagedPid !== undefined && pidGone(manifest.unmanagedPid);
const finishedAt = manifest.finishedAt ?? blocked?.at
?? (gone ? manifest.updatedAt : undefined)
?? (ARCHIVED_STATUSES.has(manifest.status)
? (await stat(path.join(jobRoot, "exit.txt")).catch(() => undefined))?.mtime.toISOString() ?? manifest.updatedAt : undefined);
children.push({ id, provider: manifest.provider, session: manifest.session, model: manifest.model, ...worktree, cwd: manifest.worktree,
path: manifest.taskLabel, callId: `fanout:${id}`,
state: blocked || manifest.status === "failed" || manifest.status === "cancelled" ? "failed" : gone ? "gone" : ["queued", "running"].includes(manifest.status) ? "running" : "completed",
...(blocked ? { reason: blockedReason(blocked) } : gone ? { reason: "gone: worker process exited" } : asking ? { reason: `needs-you: ${String(asking.message ?? asking.type)}`.slice(0, 500) } : {}),
...(manifest.status === "queued" ? {} : { startedAt: manifest.startedAt }), ...(finishedAt ? { finishedAt } : {}), transcript,
fanout: { resumable: manifest.session !== undefined }, children: [] });
}
catch { /* Torn, old, or untrusted manifests do not become child agents. */ }
}
return children.sort((a, b) => a.path.localeCompare(b.path) || a.id.localeCompare(b.id));
}
/** Every fan-out job's worktree with its task label and harness, whatever
* conversation launched it, so the Changes screen can name the worker editing
* a checkout. Newest first, bounded like the child listing. */
export async function fanoutWorktrees(env = process.env) {
const root = await containedFanoutRoot(env);
if (!root)
return [];
const entries = (await readdir(root).catch(() => [])).filter(name => jobID.safeParse(name).success);
const stamped = await Promise.all(entries.map(async (name) => ({ name, at: (await stat(path.join(root, name)).catch(() => undefined))?.mtimeMs ?? 0 })));
const jobs = [];
for (const { name } of stamped.sort((a, b) => b.at - a.at).slice(0, MAX_JOBS)) {
const manifestFile = await regularContainedFile(root, path.join(root, name, "manifest.json"), MAX_MANIFEST_BYTES);
if (!manifestFile)
continue;
try {
const manifest = manifestSchema.parse(JSON.parse(await readFile(manifestFile, "utf8")));
if (manifest.id !== name)
continue;
jobs.push({ worktree: manifest.worktree, label: manifest.taskLabel, provider: manifest.provider, state: manifest.status });
}
catch { /* Torn or untrusted manifests name no worker. */ }
}
return jobs;
}
/** Every fan-out job that left a blocked.json, for the Hook's push watcher. */
export async function blockedFanouts(env = process.env) {
const root = await containedFanoutRoot(env);
if (!root)
return [];
const names = (await readdir(root).catch(() => [])).filter(name => jobID.safeParse(name).success).slice(0, MAX_JOBS);
const blocked = [];
for (const name of names) {
const jobRoot = await realpath(path.join(root, name)).catch(() => undefined);
if (!jobRoot || !jobRoot.startsWith(root + path.sep))
continue;
const manifestFile = await regularContainedFile(root, path.join(jobRoot, "manifest.json"), MAX_MANIFEST_BYTES);
if (!manifestFile)
continue;
try {
const manifest = manifestSchema.parse(JSON.parse(await readFile(manifestFile, "utf8")));
if (manifest.id !== name)
continue;
const value = await readBlocked(jobRoot);
if (!value)
continue;
blocked.push({ id: manifest.id, provider: manifest.provider, label: manifest.taskLabel,
...(manifest.parent ? { parent: manifest.parent } : {}), reason: blockedReason(value), ...(value.at ? { at: value.at } : {}) });
}
catch { /* Torn or untrusted jobs are not pushed. */ }
}
return blocked;
}
/** The running OpenCode fan-out job a permission request names, when its own
* manifest confirms the worker session and a parent conversation: the parent
* is where the phone answers the ask. */
export async function fanoutAsking(job, session, env = process.env) {
if (!jobID.safeParse(job).success)
return undefined;
const root = await containedFanoutRoot(env);
if (!root)
return undefined;
const jobRoot = await realpath(path.join(root, job)).catch(() => undefined);
if (!jobRoot || !jobRoot.startsWith(root + path.sep))
return undefined;
const file = await regularContainedFile(root, path.join(jobRoot, "manifest.json"), MAX_MANIFEST_BYTES);
if (!file)
return undefined;
try {
const manifest = manifestSchema.parse(JSON.parse(await readFile(file, "utf8")));
if (manifest.id !== job || manifest.provider !== "opencode" || manifest.session !== session || manifest.status !== "running" || !manifest.parent)
return undefined;
return { id: manifest.id, label: manifest.taskLabel, worktree: manifest.worktree, parent: manifest.parent };
}
catch {
return undefined;
}
}
/** The job's own manifest when it parses, absent when the folder has none. */
async function jobManifest(root, directory) {
const manifestFile = await regularContainedFile(root, path.join(directory, "manifest.json"), MAX_MANIFEST_BYTES);
if (!manifestFile)
return undefined;
try {
return manifestSchema.parse(JSON.parse(await readFile(manifestFile, "utf8")));
}
catch {
return undefined;
}
}
/** The launcher's exit stamp: its presence is what says the job is finished. */
async function exitStamp(directory) {
const exit = await lstat(path.join(directory, "exit.txt")).catch(() => undefined);
return exit?.isFile() && !exit.isSymbolicLink() ? exit.mtimeMs : undefined;
}
/** When an archived folder counts as finished for the cap: its manifest's
* finishedAt, else exit.txt's mtime, else the folder's own mtime. */
async function archivedFinishedAt(directory) {
const manifestFile = await lstat(path.join(directory, "manifest.json")).catch(() => undefined);
if (manifestFile?.isFile() && !manifestFile.isSymbolicLink() && manifestFile.size <= MAX_MANIFEST_BYTES) {
try {
const value = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8"));
const finished = typeof value.finishedAt === "string" ? Date.parse(value.finishedAt) : NaN;
if (Number.isFinite(finished))
return finished;
}
catch { /* Missing, torn, or synthesized manifests fall back to the exit stamp. */ }
}
const exit = await exitStamp(directory);
if (exit !== undefined)
return exit;
return (await stat(directory).catch(() => undefined))?.mtimeMs ?? 0;
}
/** Move finished fan-out job folders into the archive.
*
* A folder moves when it has exit.txt (without it the job is still running and
* is never touched), its manifest status is completed, failed or cancelled,
* and its finishedAt (or exit.txt's mtime when there is none) is over
* ARCHIVE_AGE_MS old. A folder with no manifest goes too once its exit.txt is
* that old, gaining a synthesized {status: failed, reason: "no manifest"}
* manifest in the archive. The archive keeps ARCHIVE_MAX_FOLDERS folders,
* deleting the oldest beyond that. `dryRun` reports the same work without
* touching anything. `olderThanMs` replaces the 24 hour age (0 archives every
* finished job), and `parent` limits the sweep to one parent session's jobs;
* a folder without a manifest has no parent and is left alone then. */
export async function archiveFinishedFanouts(env = process.env, options = {}) {
const now = options.now ?? Date.now(), dryRun = options.dryRun ?? false;
const age = options.olderThanMs ?? ARCHIVE_AGE_MS, parent = options.parent;
const moves = [];
const configured = fanoutRoot(env);
const root = await realpath(configured).catch(() => undefined);
const archive = path.join(path.dirname(root ?? configured), "agent-fanouts-archive");
if (root) {
const names = (await readdir(root).catch(() => [])).filter(name => jobID.safeParse(name).success).sort();
for (const name of names) {
const directory = path.join(root, name);
const metadata = await lstat(directory).catch(() => undefined);
if (!metadata?.isDirectory() || metadata.isSymbolicLink())
continue;
if (await lstat(path.join(directory, "message-lock")).catch(() => undefined))
continue;
const messages = await readdir(path.join(directory, "messages")).catch(() => []);
if (messages.some(name => name.endsWith(".queued.json")))
continue;
const exit = await exitStamp(directory);
if (exit === undefined)
continue;
const manifest = await jobManifest(root, directory);
if (parent && (manifest?.parent?.session !== parent.session
|| (parent.provider !== undefined && manifest.parent.provider !== parent.provider)))
continue;
let basis;
if (manifest && ARCHIVED_STATUSES.has(manifest.status)) {
const finished = manifest.finishedAt ? Date.parse(manifest.finishedAt) : NaN;
basis = Number.isFinite(finished) ? finished : exit;
}
else if (!manifest) {
basis = exit;
}
else
continue;
// An explicit "now" (age 0) archives every finished job; comparing against
// the exit stamp raced file times a millisecond ahead of the clock.
if (age > 0 && now - basis <= age)
continue;
moves.push({ name, basis });
if (dryRun)
continue;
await mkdir(archive, { recursive: true, mode: 0o700 });
const destination = path.join(archive, name);
await rm(destination, { recursive: true, force: true });
await rename(directory, destination);
if (!manifest)
await writeFile(path.join(destination, "manifest.json"), JSON.stringify({ status: "failed", reason: "no manifest" }), { mode: 0o600 });
}
}
// Cap the archive: after the moves it may hold ARCHIVE_MAX_FOLDERS + n folders.
const archived = (await readdir(archive).catch(() => [])).filter(name => jobID.safeParse(name).success);
// Up to ARCHIVE_MAX_FOLDERS + n folders at two or three file reads each:
// one at a time that takes seconds on Windows, where every stat is slow.
const entries = [];
for (let index = 0; index < archived.length; index += 32) {
entries.push(...await Promise.all(archived.slice(index, index + 32)
.map(async (name) => ({ name, age: await archivedFinishedAt(path.join(archive, name)) }))));
}
// On a dry run the moves never landed, so weigh them at their finish stamp.
if (dryRun)
for (const move of moves)
entries.push({ name: move.name, age: move.basis });
let deleted = 0;
if (entries.length > ARCHIVE_MAX_FOLDERS) {
const excess = entries.sort((a, b) => a.age - b.age || a.name.localeCompare(b.name)).slice(0, entries.length - ARCHIVE_MAX_FOLDERS);
for (const entry of excess)
if (!dryRun)
await rm(path.join(archive, entry.name), { recursive: true, force: true });
deleted = excess.length;
}
return { moved: moves.map(move => move.name), deleted };
}
export const FANOUTS_ARCHIVE_USAGE = "Usage: phren bridge fanouts archive [--dry-run] [--parent <session-id>] [--older-than <minutes>]";
/** `--parent` limits the sweep to one parent chat's workers; `--older-than`
* replaces the 24 hour age, and 0 archives every finished one. */
export function parseFanoutArchiveFlags(flags) {
const options = {};
for (let index = 0; index < flags.length; index++) {
const flag = flags[index];
if (flag === "--dry-run") {
options.dryRun = true;
continue;
}
const value = flags[index + 1];
if (flag === "--parent" && value !== undefined && sessionId.safeParse(value).success) {
options.parent = { session: value };
index++;
continue;
}
if (flag === "--older-than" && value !== undefined && /^\d{1,7}$/.test(value)) {
options.olderThanMs = Number(value) * 60_000;
index++;
continue;
}
throw new Error(FANOUTS_ARCHIVE_USAGE);
}
return options;
}
/** Project raw `opencode run --format json` rows into the small public chat
* contract. Reasoning, snapshots, costs, and metadata are omitted. Like the
* Codex mapping, the command, URL or path a tool was given and a bounded
* output tail cross the wire so the owner can see what a worker is doing.
* An MCP tool keeps its own arguments and an edit, write or patch carries the
* changed-file diff the phone draws under the card; both are bounded. */
export function visibleOpenCodeRunEvent(raw, cwd) {
if (raw.type === "phren/fanout-message" && typeof raw.text === "string") {
return { type: "user/message", time: raw.timestamp, data: { message: {
role: "user", content: [{ type: "text", text: raw.text }],
} } };
}
const part = object(raw.part), time = typeof raw.timestamp === "number" && Number.isFinite(raw.timestamp)
? new Date(raw.timestamp).toISOString() : undefined;
if (raw.type === "text" && part.type === "text" && typeof part.text === "string") {
return { type: "assistant/message", ...(time ? { time } : {}), data: { message: {
role: "assistant", content: [{ type: "text", text: part.text.slice(0, 262_144) }],
} } };
}
if (raw.type === "tool_use" && part.type === "tool" && typeof part.tool === "string") {
const state = object(part.state), status = ["pending", "running", "completed", "error"].includes(String(state.status))
? String(state.status) : "completed";
const id = String(part.callID ?? "").slice(0, 200);
const content = [{ type: "tool_use", id, name: part.tool.slice(0, 200), input: opencodeToolInput(part.tool, object(state.input)), phrenStatus: status }];
if (status === "completed" || status === "error") {
content.push({ type: "tool_result", tool_use_id: id, content: opencodeToolOutput(part.tool, state), ...(status === "error" ? { is_error: true } : {}) });
}
const files = status === "completed" ? opencodeChanges(part.tool, state, cwd) : undefined;
return { type: "assistant/message", ...(time ? { time } : {}),
...(files?.length ? { phren_changes: { [id]: files } } : {}), data: { message: { role: "assistant", content } } };
}
if (raw.type === "step_finish") {
return { type: "system", ...(time ? { time } : {}), data: { message: {
role: "assistant", content: [{ type: "text", text: `Step finished${typeof part.reason === "string" ? `: ${part.reason.slice(0, 200)}` : "."}` }],
} } };
}
}
/** Project raw `codex exec --json` rows into the shape Codex's own rollout
* files use, so the existing Codex transcript reader renders them unchanged.
* Command text, a bounded output tail, and changed paths cross the wire so the
* owner can see what a worker is doing; diffs, usage, and costs do not. */
export function visibleCodexExecEvent(raw) {
if (raw.type === "phren/fanout-message" && typeof raw.text === "string") {
return { type: "response_item", timestamp: raw.timestamp, payload: { type: "message", role: "user",
content: [{ type: "input_text", text: raw.text }] } };
}
const item = object(raw.item), callId = () => String(item.id ?? "").slice(0, 200);
if (raw.type === "item.completed" && item.type === "agent_message") {
if (typeof item.text !== "string")
return undefined;
return { type: "response_item", payload: { type: "message", role: "assistant",
content: [{ type: "output_text", text: item.text.slice(0, 262_144) }] } };
}
if (raw.type === "item.started" && item.type === "command_execution") {
return { type: "response_item", payload: { type: "function_call", name: "shell",
call_id: callId(), arguments: JSON.stringify({ command: codexCommand(item) }) } };
}
if (raw.type === "item.completed" && item.type === "command_execution") {
return { type: "response_item", payload: { type: "function_call_output", call_id: callId(),
output: codexCommandOutput(item) } };
}
if (raw.type === "item.started" && item.type === "file_change") {
return { type: "response_item", payload: { type: "function_call", name: "apply_patch",
call_id: callId(), arguments: JSON.stringify({ files: codexFiles(item) }) } };
}
if (raw.type === "item.completed" && item.type === "file_change") {
const files = codexFiles(item), header = `${files.length} file(s) changed`;
return { type: "response_item", payload: { type: "function_call_output", call_id: callId(),
output: files.length ? `${header}\n${files.map(file => file.path).join("\n")}` : header } };
}
if (raw.type === "turn.completed")
return { type: "event_msg", payload: { type: "task_complete" } };
if (raw.type === "error" && typeof raw.message === "string") {
return { type: "event_msg", payload: { type: "error", message: raw.message.slice(0, 2000) } };
}
return undefined;
}
const OPENCODE_BUILTINS = new Set(["bash", "read", "edit", "write", "patch", "grep", "glob", "list", "webfetch", "todowrite", "todoread", "task", "skill"]);
/** The one argument that says what a tool did: a command, a URL, a path or a
* pattern. MCP tools (anything outside OpenCode's own set) keep their whole
* argument object, and an edit, write or patch keeps the text it touched. */
function opencodeToolInput(tool, input) {
const text = (key, max = 2000) => typeof input[key] === "string" ? collapseHomeText(String(input[key])).slice(0, max) : undefined;
switch (tool) {
case "bash": return { command: text("command") ?? "" };
case "webfetch": return { url: text("url") ?? "" };
case "read": return { path: text("filePath") ?? text("path") ?? "" };
case "edit": return { path: text("filePath") ?? text("path") ?? "", oldString: text("oldString", 4000) ?? "", newString: text("newString", 4000) ?? "" };
case "write": return { path: text("filePath") ?? text("path") ?? "", content: text("content", 4000) ?? "" };
case "patch": return { path: text("filePath") ?? text("path") ?? "", patch: text("patch", 4000) ?? text("content", 4000) ?? "" };
case "grep":
case "glob":
case "list": return { pattern: text("pattern", 500) ?? "", path: text("path") ?? "" };
default: return OPENCODE_BUILTINS.has(tool) ? {} : mcpToolInput(input);
}
}
const MCP_INPUT_BYTES = 8 * 1024;
const MCP_STRING_CHARS = 4000;
const MCP_INPUT_DEPTH = 4;
/** An MCP tool's arguments as the phone shows them: home paths collapsed,
* long strings cut, nesting bounded, and the whole object kept under 8 KB by
* shortening the strings further rather than dropping the call. */
function mcpToolInput(input) {
for (const cap of [MCP_STRING_CHARS, 2000, 1000, 500, 250, 120, 60, 0]) {
const value = boundedValue(input, 0, cap);
if (Buffer.byteLength(JSON.stringify(value)) <= MCP_INPUT_BYTES)
return object(value);
}
return {};
}
function boundedValue(value, depth, cap) {
if (depth > MCP_INPUT_DEPTH)
return undefined;
if (typeof value === "string") {
const text = collapseHomeText(value);
return text.length > cap ? `${text.slice(0, cap)}…` : text;
}
if (typeof value === "number" || typeof value === "boolean" || value === null)
return value;
if (Array.isArray(value))
return value.slice(0, 64).map(item => boundedValue(item, depth + 1, cap)).filter(item => item !== undefined);
if (value && typeof value === "object") {
const out = Object.create(null);
for (const [key, item] of Object.entries(value).slice(0, 64)) {
const bounded = boundedValue(item, depth + 1, cap);
if (bounded !== undefined)
out[key] = bounded;
}
return out;
}
return undefined;
}
const MAX_CHANGE_PATCH = 64 * 1024;
/** The files an OpenCode edit, write or patch touched, in the ChangedFile
* shape `phren_changes` already carries. `cwd` is the worker's checkout when
* the caller knows it, so the path is shown relative to it. */
function opencodeChanges(tool, state, cwd) {
if (!["edit", "write", "patch"].includes(tool))
return undefined;
const input = object(state.input), metadata = object(state.metadata), filediff = object(metadata.filediff);
let patch = [metadata.diff, filediff.patch, input.patch, input.diff].find(value => typeof value === "string" && value);
let status = patch ? changeStatus(patch) : "M";
if (tool === "write" && !patch && metadata.exists === false) {
patch = addedFilePatch(typeof input.content === "string" ? input.content : "");
status = "A";
}
if (!patch)
return undefined;
patch = collapseHomeText(patch);
const chunks = patch.split(/(?=^diff --git )/m).filter(Boolean);
const patches = chunks.length > 1 ? chunks : patch.split(/(?=^--- [^\n]+\n\+\+\+ )/m).filter(Boolean);
const multiple = patches.length > 1;
return patches.slice(0, 50).flatMap(patch => {
const given = multiple ? patchPath(patch) :
[filediff.file, metadata.filepath, input.filePath, input.path].find(value => typeof value === "string" && value)
?? patchPath(patch);
if (!given)
return [];
const counted = diffCounts(patch);
const validCount = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
const added = !multiple && validCount(filediff.additions) ? filediff.additions : counted.added;
const removed = !multiple && validCount(filediff.deletions) ? filediff.deletions : counted.removed;
// The phone draws worker patches without needing their private checkout root.
return [{ root: "", path: relativeChangePath(given, cwd), status: multiple ? changeStatus(patch) : status,
patch: patch.slice(0, MAX_CHANGE_PATCH), added, removed }];
});
}
/** A patch tool may carry only the diff; its `+++` (or `Index:`) header names
* the file. */
function patchPath(patch) {
const next = /^\+\+\+ (?:b\/)?(.+)$/m.exec(patch)?.[1]?.trim();
if (next && next !== "/dev/null")
return next;
return (/^--- (?:a\/)?(.+)$/m.exec(patch) ?? /^Index: (.+)$/m.exec(patch))?.[1]?.trim() || undefined;
}
function relativeChangePath(value, cwd) {
if (cwd && path.isAbsolute(value)) {
const relative = path.relative(cwd, value);
if (relative && relative !== ".." && !relative.startsWith(`..${path.sep}`))
return relative;
}
return collapseHome(value);
}
/** Does a unified diff add, delete or change the file? */
function changeStatus(patch) {
if (/^new file mode\b/m.test(patch) || /^--- \/dev\/null$/m.test(patch) || /^@@ -0,0 /m.test(patch))
return "A";
if (/^deleted file mode\b/m.test(patch) || /^\+\+\+ \/dev\/null$/m.test(patch))
return "D";
return "M";
}
function diffCounts(patch) {
let added = 0, removed = 0;
for (const line of patch.split("\n")) {
if (line.startsWith("+++") || line.startsWith("---"))
continue;
if (line.startsWith("+"))
added++;
else if (line.startsWith("-"))
removed++;
}
return { added, removed };
}
/** The `@@` hunk the phone's diff card expects from a new file's content. */
function addedFilePatch(content) {
const text = content.endsWith("\n") ? content.slice(0, -1) : content;
const lines = content ? text.split("\n") : [];
return [`@@ -0,0 +1,${lines.length} @@`, ...lines.map(line => `+${line}`)].join("\n");
}
function opencodeToolOutput(tool, state) {
const metadata = object(state.metadata);
const raw = typeof state.output === "string" ? state.output : typeof state.error === "string" ? state.error : "";
const text = collapseHomeText(raw).slice(-4000);
if (tool === "bash")
return `${text}${text && !text.endsWith("\n") ? "\n" : ""}${typeof metadata.exit === "number" ? `[exit ${metadata.exit}]` : "[finished]"}`;
return text;
}
/** Replace the real home directory wherever it appears in free text (a
* command line, an output tail) with `~`, so the account name stays home. */
function collapseHomeText(value) {
const home = homedir();
const base = home.endsWith(path.sep) ? home.slice(0, -path.sep.length) : home;
return base.length > 1 ? value.split(base).join("~") : value;
}
/** Replace a leading real home directory with `~` for display. */
function collapseHome(value) {
const home = homedir();
const trimmed = home.endsWith(path.sep) ? home.slice(0, -path.sep.length) : home;
for (const base of [home, trimmed]) {
if (value === base)
return "~";
const prefix = `${base}${path.sep}`;
if (value.startsWith(prefix))
return `~${path.sep}${value.slice(prefix.length)}`;
}
return value;
}
function codexCommand(item) {
const value = item.command;
const text = Array.isArray(value) ? value.filter(part => typeof part === "string").join(" ")
: typeof value === "string" ? value : "";
return text.slice(0, 2000);
}
function codexCommandOutput(item) {
const text = typeof item.aggregated_output === "string" ? item.aggregated_output.slice(-4000) : "";
const status = typeof item.exit_code === "number" ? `[exit ${item.exit_code}]` : "[finished]";
return `${text}\n${status}`;
}
function codexFiles(item) {
const changes = Array.isArray(item.changes) ? item.changes : [];
return changes.slice(0, 50).map(change => {
const entry = object(change);
return { path: collapseHome(String(entry.path ?? "")).slice(0, 512), kind: String(entry.kind ?? "").slice(0, 512) };
});
}