@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
203 lines (202 loc) • 10.5 kB
JavaScript
import { lstat, mkdtemp, realpath, rm } from "node:fs/promises";
import { hostname, tmpdir } from "node:os";
import path from "node:path";
import { getProjectDirs } from "../phren-paths.js";
import { listLiveSessions } from "./hand-off.js";
import { canaryFile, readCanary } from "./health.js";
import { paneIdentity, servers, snapshot } from "./herdr.js";
import { terminalProvider } from "./terminal.js";
import { TMUX_HIDDEN } from "./terminal-tmux.js";
import { atomic, BridgeError, bridgeRoot, id, objects, provider } from "./protocol.js";
import { readScheduleDocument } from "./schedules.js";
import { TranscriptReader, transcriptPath } from "./transcripts.js";
/** The label every canary workspace carries, so cleanup finds only its own. */
export const CANARY_LABEL = "phren canary";
const DAY_MS = 24 * 60 * 60_000;
function reason(error) {
const text = error instanceof Error ? error.message : "Unknown failure.";
return text.replace(/[\x00-\x1f\x7f]+/g, " ").trim().slice(0, 300) || "Unknown failure.";
}
async function step(steps, name, run) {
const started = Date.now();
try {
steps.push({ name, durationMs: 0, ...await run() });
}
catch (error) {
steps.push({ name, durationMs: 0, status: "failed", reason: reason(error) });
}
steps.at(-1).durationMs = Date.now() - started;
}
/** Close every workspace this canary created, by id and by label. Never
* touches a workspace that existed before the canary started. */
async function closeCanaryWorkspaces(server, before, created) {
const failures = [];
let current;
try {
current = await snapshot(server);
}
catch (error) {
return `could not list workspaces to clean up (${reason(error)})`;
}
const mine = objects(current.workspaces).filter(workspace => !before.has(String(workspace.workspace_id))
&& (workspace.workspace_id === created || workspace.label === CANARY_LABEL));
for (const workspace of mine) {
if (!id.safeParse(workspace.workspace_id).success)
continue;
try {
await terminalProvider().groupAction(server, "close", { workspace: String(workspace.workspace_id) });
}
catch (error) {
failures.push(`${String(workspace.workspace_id)}: ${reason(error)}`);
}
}
return failures.length ? `cleanup failed for ${failures.join("; ")}` : undefined;
}
/** (1) A Claude conductor launched through launchSession in a temporary folder, then closed. */
async function conductorStep(options, server) {
const before = new Set(objects((await snapshot(server)).workspaces).map(workspace => String(workspace.workspace_id)));
const folder = await realpath(await mkdtemp(path.join(tmpdir(), "phren-canary-")));
let created, failure, detail;
try {
const launched = await options.launch(server, { role: "conductor", kind: "claude", label: CANARY_LABEL, cwd: folder, timeoutMs: 45_000 });
created = typeof launched.workspaceId === "string" ? launched.workspaceId : undefined;
detail = `claude started (${typeof launched.agentStatus === "string" ? launched.agentStatus : "status unknown"})`;
}
catch (error) {
failure = reason(error);
}
finally {
const cleanup = await closeCanaryWorkspaces(server, before, created);
await rm(folder, { recursive: true, force: true }).catch(() => { });
if (cleanup)
failure = failure ? `${failure}; ${cleanup}` : cleanup;
}
return failure ? { status: "failed", reason: failure } : { status: "ok", detail };
}
/** (2) Every project's schedules.yaml parses and the Hook's scheduler ticked recently. Nothing runs. */
async function scheduleStep(options) {
if (!options.scheduler)
return { status: "skipped", reason: "The schedules module is off on this computer." };
let count = 0;
const broken = [];
for (const directory of getProjectDirs(options.store)) {
const present = await lstat(path.join(directory, "schedules.yaml")).catch(() => undefined);
if (!present)
continue;
try {
count += (await readScheduleDocument(directory)).schedules.length;
}
catch (error) {
broken.push(`${path.basename(directory)}: ${reason(error)}`);
}
}
const tick = options.scheduler.lastTickAt;
if (broken.length)
return { status: "failed", reason: `schedules.yaml does not parse in ${broken.join("; ")}` };
if (!tick || Date.now() - tick.getTime() > 120_000)
return { status: "failed", reason: tick ? `The scheduler last ticked at ${tick.toISOString()}.` : "The scheduler has not ticked since the Hook started." };
return { status: "ok", detail: `${count} schedule${count === 1 ? "" : "s"} parse; the scheduler ticked at ${tick.toISOString()}` };
}
/** (3) One existing idle session's transcript, read only. No input reaches any pane. */
async function transcriptStep() {
for (const live of await servers()) {
const server = String(live.session), current = await snapshot(server);
const workspaceLabels = new Map(objects(current.workspaces).map(workspace => [workspace.workspace_id, workspace.label]));
for (const pane of objects(current.panes)) {
if (pane.agent_status !== "idle" || !provider.safeParse(pane.agent).success || workspaceLabels.get(pane.workspace_id) === CANARY_LABEL)
continue;
const session = await paneIdentity(server, pane).catch(() => undefined);
if (!session)
continue;
const source = pane.agent;
let file;
try {
file = await transcriptPath(source, session);
}
catch {
continue;
}
const page = await new TranscriptReader(file, source).read();
return { status: "ok", detail: `read ${page.entries.length} entries from an idle ${source} session` };
}
}
return { status: "skipped", reason: "No idle agent session with a transcript to read." };
}
/** (4) Every live session here and on enrolled computers; an unreachable computer fails the step. */
async function sessionsStep() {
const live = await listLiveSessions();
const detail = `${live.sessions.length} live session${live.sessions.length === 1 ? "" : "s"} across ${live.enrolled + 1} computer${live.enrolled ? "s" : ""}`;
if (live.unreachable.length)
return { status: "failed", detail, reason: `Unreachable: ${live.unreachable.map(item => `${item.computer} (${item.error})`).join("; ")}` };
return { status: "ok", detail };
}
/** Where the canary's conductor starts: Herdr's default server, else the
* first Herdr server running; on a computer without Herdr, tmux's hidden
* server, so nothing opens where the owner is working. Undefined with neither. */
export async function canaryServer() {
const live = await servers().catch(() => []);
const herdr = live.filter(server => server.terminal !== "tmux").map(server => String(server.session));
if (herdr.length)
return herdr.includes("default") ? "default" : herdr[0];
return live.some(server => server.session === TMUX_HIDDEN) ? TMUX_HIDDEN : undefined;
}
let running;
/** Exercise the real launch, schedule, transcript and session paths once and
* save the outcome to canary.json. Everything it opens is closed again, even on
* failure; it never sends input to an existing pane and never runs or edits the
* owner's schedules or tasks. */
export function runCanary(options) {
if (running)
throw new BridgeError(409, "A canary is already running on this computer.");
running = (async () => {
const started = new Date(), steps = [], server = options.server ?? await canaryServer();
await step(steps, "conductor", async () => server ? conductorStep(options, server)
: { status: "failed", reason: "No terminal to start an agent in: Herdr is not running and tmux is not installed (or PHREN_TMUX=off)." });
await step(steps, "schedules", () => scheduleStep(options));
await step(steps, "transcript", () => transcriptStep());
await step(steps, "sessions", () => sessionsStep());
const finished = new Date();
const result = { version: 1, trigger: options.trigger, computer: hostname(), startedAt: started.toISOString(),
finishedAt: finished.toISOString(), durationMs: finished.getTime() - started.getTime(),
ok: steps.every(item => item.status !== "failed"), steps };
await atomic(canaryFile(options.root ?? bridgeRoot()), JSON.stringify(result, null, 2) + "\n");
return result;
})().finally(() => { running = undefined; });
return running;
}
/** Whether the daily canary is on: `PHREN_CANARY_DAILY=1`, or `phren canary --daily on`. */
export async function dailyCanaryEnabled(root = bridgeRoot()) {
if (process.env.PHREN_CANARY_DAILY === "1")
return true;
return !!(await lstat(path.join(root, "canary-daily")).catch(() => undefined))?.isFile();
}
/** A daily canary is due when none has run in the last day. */
export async function dailyCanaryDue(root = bridgeRoot(), now = Date.now()) {
if (running || !await dailyCanaryEnabled(root))
return false;
const last = await readCanary(root);
return !last || now - Date.parse(last.startedAt) >= DAY_MS;
}
/** `phren canary [--daily on|off]`: run the canary through this computer's Hook, or switch the daily run. */
export async function runCanaryCommand(args) {
const { hookRequest } = await import("./client.js");
const { mkdir, writeFile } = await import("node:fs/promises");
if (args[0] === "--daily") {
const flag = path.join(bridgeRoot(), "canary-daily");
if (args[1] === "on") {
await mkdir(bridgeRoot(), { recursive: true, mode: 0o700 });
await writeFile(flag, "", { mode: 0o600 });
}
else if (args[1] === "off")
await rm(flag, { force: true });
else
throw new Error("Usage: phren canary [--daily on|off]");
console.log(`Daily canary ${args[1]}. The Hook checks every ten minutes and runs it once a day.`);
return 0;
}
if (args.length)
throw new Error("Usage: phren canary [--daily on|off]");
const result = await hookRequest("/v1/canary", {}, undefined, 240_000);
console.log(JSON.stringify(result, null, 2));
return result.ok === true ? 0 : 1;
}