@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
274 lines (273 loc) • 17 kB
JavaScript
import { spawn } from "node:child_process";
import { createWriteStream } from "node:fs";
import { mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { finished as streamFinished } from "node:stream/promises";
import { askpassEnv } from "./sudo.js";
import { claudeHome, claudeLaunchEnv } from "./claude-accounts.js";
import { pickClaudeAccount } from "./account-choice.js";
import { fanoutRoot } from "./fanouts.js";
import { pretrustFolder } from "./folder-trust.js";
import { findPane, paneIdentity, servers, snapshot } from "./herdr.js";
import { agentNotReady, terminalProvider } from "./terminal.js";
import { atomicInPrivateDir, BridgeError, object, objects } from "./protocol.js";
import { defaultPhrenPath } from "../shared.js";
import { realTranscriptStamp, watchHerdrRun } from "./schedule-watch.js";
import { claudeTeachAutoModeDialog } from "./pane-status.js";
/** Starting a scheduled run: in a Herdr pane when a server is live, otherwise
* as a headless job under the fan-out root. */
const CLAUDE_SCHEDULE_SETTINGS = JSON.stringify({ enableAllProjectMcpServers: true });
export function createScheduleLauncher(launchHerdr, store = defaultPhrenPath(), deliver) {
const abort = new AbortController(), children = new Set();
const launcher = async (context) => {
if (context.reuse && deliver) {
const continued = await continueSession(context, context.reuse, deliver, abort.signal);
if (continued)
return continued;
}
const live = await servers();
if (live.length)
return launchInHerdr(String(live[0].session), context, launchHerdr, abort.signal);
return launchHeadless(context, store, child => { children.add(child); child.once("exit", () => children.delete(child)); });
};
launcher.close = () => { abort.abort(); for (const child of children)
child.kill("SIGTERM"); children.clear(); };
launcher.resume = (run, schedule) => resumeScheduleRun(run, schedule, abort.signal);
return launcher;
}
function busyReason(status) {
const state = status === "blocked" || status === "waiting" ? "is waiting for you" : status === "working" || status === "starting" ? "is still working" : `is ${status || "busy"}`;
return `The session this schedule continues ${state}, so this firing was skipped.`;
}
const finishedPane = (pane) => pane.agent_status === "idle" || pane.agent_status === "done";
/** Claude's optional "Teach auto mode about your environment?" dialog takes a typed prompt as an answer. */
async function dismissAutoModeDialog(server, pane, env) {
const screen = env.screen ?? ((name, id) => terminalProvider().readScreen(name, id, { scope: "pane", source: "visible", lines: 120, stripAnsi: true, timeoutMs: 1000 }));
const keys = env.keys ?? ((name, id, pressed) => terminalProvider().sendKeys(name, id, pressed));
for (let attempt = 0; attempt < 2; attempt++) {
const shown = await screen(server, pane).then(text => claudeTeachAutoModeDialog(text), () => false);
if (!shown)
return true;
await keys(server, pane, ["esc"]).catch(() => undefined);
await new Promise(resolve => setTimeout(resolve, 300));
}
return !await screen(server, pane).then(text => claudeTeachAutoModeDialog(text), () => false);
}
/**
* `session: reuse`: types the prompt into the newest session of this schedule
* whose pane still runs its harness (`previous`, newest first). A busy newest
* session is skipped rather than given a second prompt; a pane whose prompt
* cannot be delivered (a dialog that will not dismiss, a refused delivery)
* passes the firing to the next older one. Once a pane has taken the firing,
* the schedule's older finished panes are closed. Undefined when no session is
* left (the panes closed, their agents exited, their conversations were
* replaced), so the caller starts a new one, which the next firing continues.
*/
export async function continueSession(context, previous, deliver, signal, env = {}) {
const source = context.schedule.harness;
const panes = env.panes ?? (async (name) => objects((await snapshot(name)).panes));
const looked = new Map(), live = [];
for (const record of previous) {
const { server, workspaceId, tabId, paneId } = record;
if (!server || !workspaceId || !tabId || !paneId)
continue;
if (!looked.has(server))
looked.set(server, await panes(server).catch(() => []));
const pane = findPane({ panes: looked.get(server) }, { workspace: workspaceId, tab: tabId, pane: paneId, source });
if (pane)
live.push({ record, pane });
}
if (!live.length)
return undefined;
const recordOf = (item, sessionId) => ({ mode: "herdr", server: item.record.server, workspaceId: item.record.workspaceId, tabId: item.record.tabId, paneId: item.record.paneId, ...(sessionId ? { sessionId } : {}), reused: true });
const newest = live[0];
if (!finishedPane(newest.pane))
return { launch: recordOf(newest, newest.record.sessionId), skipped: busyReason(String(newest.pane.agent_status ?? "")) };
for (const item of live) {
if (!finishedPane(item.pane))
continue;
const { server, workspaceId, tabId, paneId } = item.record;
const sessionId = item.record.sessionId ?? await (env.identity ?? ((name, pane) => paneIdentity(name, pane)))(server, item.pane).catch(() => undefined);
if (!sessionId)
continue;
if (source === "claude" && !await dismissAutoModeDialog(server, paneId, env))
continue;
const transcript = await (env.transcriptStamp ?? realTranscriptStamp)(source, sessionId).catch(() => undefined);
let result;
try {
result = await deliver({ server, workspace: workspaceId, tab: tabId, pane: paneId, source, session: sessionId }, context.schedule.prompt, `schedule-${context.runId.replaceAll("-", "")}`);
}
catch (error) {
// The pane or its conversation went away between the look and the send.
if (error instanceof BridgeError && (error.status === 404 || error.status === 409))
continue;
throw error;
}
if (result.state === "failed")
continue;
if (result.delivered !== true && result.queued !== true) {
throw new Error(`The prompt to the session this schedule continues was not confirmed${typeof result.error === "string" ? `: ${result.error}` : "."}`);
}
// A Claude /clear in the same terminal moves the delivery to the new conversation.
const session = typeof object(result.target).session === "string" ? String(object(result.target).session) : sessionId;
const close = env.close ?? closeFinishedPane;
for (const other of live)
if (other !== item && finishedPane(other.pane))
await close(String(other.record.server), other.pane).catch(() => undefined);
return { launch: recordOf(item, session),
completion: (env.watch ?? watchHerdrRun)(server, { workspaceId, tabId, paneId }, signal, { source, startedAt: Date.now(), sessionId: session, continued: { transcript } }) };
}
return undefined;
}
async function closeFinishedPane(server, pane) {
const { markPaneClosed } = await import("./worker-close.js");
await markPaneClosed(server, pane).catch(() => undefined);
await terminalProvider().closePane(server, String(pane.pane_id));
}
/**
* The outcome of a run a previous Hook process launched and never saw finish
* (a restart or crash mid-run). A Herdr pane is followed again to its real
* end; a headless job's manifest says how it ended, if it got that far. A run
* whose end cannot be known fails with that reason rather than staying open,
* since an open run blocks its schedule for good.
*/
export async function resumeScheduleRun(run, schedule, signal, watch = watchHerdrRun) {
const launch = run.launch;
if (run.status === "launched")
return { status: "failed", reason: "Phren Hook restarted before this run finished launching." };
if (launch.mode === "herdr" && launch.server && launch.workspaceId && launch.tabId && launch.paneId) {
return watch(launch.server, { workspaceId: launch.workspaceId, tabId: launch.tabId, paneId: launch.paneId }, signal, { source: schedule.harness, startedAt: Date.parse(run.startedAt), sessionId: launch.sessionId });
}
if (launch.mode === "headless" && launch.jobDir) {
const manifest = await readFile(path.join(launch.jobDir, "manifest.json"), "utf8").then(text => JSON.parse(text)).catch(() => undefined);
if (manifest?.status === "completed")
return { status: "finished" };
if (manifest?.status === "failed") {
return { status: "failed", reason: typeof manifest.exitCode === "number" ? `The scheduled agent exited with code ${manifest.exitCode}.` : "The scheduled agent failed." };
}
}
return { status: "failed", reason: "Phren Hook restarted while this run was going, so its end was not observed." };
}
async function launchInHerdr(server, context, launchHerdr, signal) {
// The prompt goes with the launch where the harness takes one (Claude,
// Codex); otherwise it is typed once the agent is ready.
const brief = context.schedule.prompt.trim() ? { brief: { id: context.runId, text: context.schedule.prompt } } : {};
const launched = await launchHerdr(server, { cwd: context.cwd, label: context.schedule.name, kind: context.schedule.harness, model: context.schedule.model, ...(context.schedule.account ? { account: context.schedule.account } : {}), ...brief });
const workspaceId = String(launched.workspaceId), tabId = String(launched.tabId), paneId = String(launched.paneId);
if (launched.briefLaunched !== true)
await promptWhenReady(server, paneId, context.schedule.prompt, signal);
let sessionId = typeof launched.sessionId === "string" ? launched.sessionId : undefined;
for (let attempt = 0; attempt < 10 && !sessionId; attempt++) {
const pane = findPane(await snapshot(server), { workspace: workspaceId, tab: tabId, pane: paneId });
if (pane)
sessionId = await paneIdentity(server, pane).catch(() => undefined);
if (!sessionId)
await new Promise(resolve => setTimeout(resolve, 200));
}
const launch = { mode: "herdr", server, workspaceId, tabId, paneId,
...(sessionId ? { sessionId } : {}) };
return { launch, completion: watchHerdrRun(server, { workspaceId, tabId, paneId }, signal, { source: context.schedule.harness, startedAt: Date.now(), sessionId, onBlocked: context.blockedStartup }) };
}
/** Herdr refuses a prompt until the agent has finished starting; a run
* launched a moment ago waits for it rather than failing on the first try. */
async function promptWhenReady(server, paneId, text, signal, waitMs = 60_000) {
const deadline = Date.now() + waitMs;
for (;;) {
try {
await terminalProvider().prompt(server, paneId, text, signal);
return;
}
catch (error) {
const starting = agentNotReady(error);
if (!starting || signal.aborted)
throw error;
if (Date.now() >= deadline)
throw new BridgeError(409, `The agent in ${paneId} never became ready for the prompt; it may be waiting at a startup screen on the computer.`);
await new Promise(resolve => setTimeout(resolve, 1_000));
}
}
}
/** The environment a headless run gets: this Hook's, SUDO_ASKPASS, plus the chosen Claude account's config directory. */
export function headlessEnv(schedule, env = process.env) {
const askpass = askpassEnv();
if (askpass.SUDO_ASKPASS)
env = { ...env, ...askpass };
if (!schedule.account || schedule.harness !== "claude")
return env;
const home = claudeHome(schedule.account, env);
if (!home)
throw new Error(`Schedule "${schedule.name}" uses Claude account "${schedule.account}", which is not set up on this computer. Run \`phren bridge accounts\`.`);
return { ...env, ...claudeLaunchEnv(home) };
}
async function launchHeadless(context, store, started) {
// A Claude schedule that names no account runs under the one with the most room, as a Herdr launch does.
const choice = context.schedule.harness === "claude" && !context.schedule.account
? await pickClaudeAccount(`Schedule "${context.schedule.name}"`).catch(() => undefined) : undefined;
const env = headlessEnv(choice ? { ...context.schedule, account: choice.account } : context.schedule);
const root = fanoutRoot({ ...process.env, PHREN_PATH: store }), jobDir = path.join(root, context.runId);
await mkdir(jobDir, { recursive: true, mode: 0o700 });
const eventLog = "events.jsonl", now = new Date().toISOString();
const manifest = { schemaVersion: 1, id: context.runId, provider: context.schedule.harness,
taskLabel: context.schedule.name, cwd: context.cwd, worktree: context.cwd, ...(context.schedule.model ? { model: context.schedule.model } : {}),
eventLog, createdAt: now, startedAt: now, updatedAt: now, status: "queued", schedule: { id: context.schedule.id, project: context.project } };
await writeManifest(jobDir, manifest);
const command = headlessCommand(context.schedule, context.cwd);
// An untrusted directory only costs Codex a prompt; the run still starts, and the log says why.
// Headless Claude (`-p`) has no trust screen, so only Codex needs it here.
if (context.schedule.harness === "codex")
await pretrustFolder("codex", context.cwd, `scheduled run ${context.runId}`);
let child;
try {
child = spawn(command.file, command.args, { cwd: command.cwd, env, stdio: ["pipe", "pipe", "pipe"] });
}
catch (error) {
await writeManifest(jobDir, { ...manifest, status: "failed", updatedAt: new Date().toISOString(), finishedAt: new Date().toISOString() });
throw error;
}
started(child);
const closed = new Promise(resolve => {
child.once("close", (code, signal) => resolve({ code, signal }));
});
const output = createWriteStream(path.join(jobDir, eventLog), { flags: "a", mode: 0o600 });
const errors = createWriteStream(path.join(jobDir, "stderr.log"), { flags: "a", mode: 0o600 });
const streamsFinished = Promise.all([streamFinished(output), streamFinished(errors)]).then(() => undefined).catch(() => undefined);
child.stdin?.on("error", () => { });
child.stdout?.pipe(output);
child.stderr?.pipe(errors);
child.stdin?.end(context.schedule.prompt);
await new Promise((resolve, reject) => {
child.once("spawn", resolve);
child.once("error", reject);
}).catch(async (error) => {
output.end();
errors.end();
await writeManifest(jobDir, { ...manifest, status: "failed", updatedAt: new Date().toISOString(), finishedAt: new Date().toISOString() });
throw error;
});
await writeManifest(jobDir, { ...manifest, status: "running", updatedAt: new Date().toISOString() });
const completion = closed.then(async ({ code, signal }) => {
await streamsFinished;
const finishedAt = new Date().toISOString(), ok = code === 0;
await writeManifest(jobDir, { ...manifest, status: ok ? "completed" : "failed", updatedAt: finishedAt, finishedAt,
...(typeof code === "number" ? { exitCode: code } : {}) }).catch(() => { });
return ok ? { status: "finished" } : { status: "failed", reason: signal ? `The scheduled agent exited after ${signal}.` : `The scheduled agent exited with code ${code ?? "unknown"}.` };
});
return { launch: { mode: "headless", jobDir }, completion };
}
export function headlessCommand(schedule, cwd) {
const model = schedule.model ? ["--model", schedule.model] : [];
if (schedule.harness === "codex")
return { file: "codex", cwd, args: ["exec", ...model, "--sandbox", "workspace-write", "-C", cwd,
"--skip-git-repo-check", "--json", "-"] };
if (schedule.harness === "opencode")
return { file: "opencode", cwd, args: ["run", "--format", "json", "--dir", cwd, ...model] };
// Copilot's non-interactive mode needs every tool allowed up front; file
// access stays inside the working folder. `=` keeps a prompt that starts
// with "-" from reading as a flag.
if (schedule.harness === "copilot")
return { file: "copilot", cwd, args: [`--prompt=${schedule.prompt}`, "--allow-all-tools", "--output-format", "json", ...model] };
return { file: "claude", cwd, args: ["-p", "--output-format", "stream-json", "--settings", CLAUDE_SCHEDULE_SETTINGS, ...model] };
}
async function writeManifest(jobDir, manifest) {
await atomicInPrivateDir(path.join(jobDir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
}