@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
167 lines (166 loc) • 8.56 kB
JavaScript
import { randomUUID } from "node:crypto";
import { lstat, mkdir, readdir, readFile, rename, rm, stat } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { atomic, bridgeRoot, targetSchema } from "./protocol.js";
/**
* A worker's first prompt carried by its launch instead of typed into a
* starting pane. The brief is written to a private file on the computer that
* starts the agent, and the harness gets one short argument pointing at it:
* `claude "<prompt>"` and `codex "<prompt>"` both open the interactive TUI
* and submit that prompt themselves, so there is no Enter to lose and no
* starting pane to wait for. The same id is exported to the agent as
* `PHREN_DISPATCH_ID`, and its SessionStart and UserPromptSubmit hooks echo
* it back: the receipt is that echo, not a match on typed text.
*
* OpenCode takes its brief over the HTTP API its launched TUI serves
* (opencode-panes.ts); the file is still written so the arrival is recorded
* here. Copilot keeps the typed path: its `-i <prompt>` would submit a first
* prompt, but its hooks fire SessionStart lazily and their payload was not
* confirmed to carry the event name, so no arrival could be recorded.
*/
/** A dispatch receipt id or a scheduled run id: what `PHREN_DISPATCH_ID` carries. */
export const briefId = z.string().regex(/^[A-Za-z0-9_-]{8,64}$/);
export const launchBriefSchema = z.object({
id: briefId,
text: z.string().min(1).max(32768).refine(value => !!value.trim() && !/[\x00-\x08\x0b-\x1f\x7f]/.test(value)),
}).strict();
/** The variable a launched agent's hooks read its brief id from. */
export const DISPATCH_ID_ENV = "PHREN_DISPATCH_ID";
/** Briefs older than this are removed when the next one is written; a worker
* may re-read its brief after a compaction, so it is not removed on arrival. */
const KEEP_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_BRIEFS = 256;
export function briefRoot() { return path.join(bridgeRoot(), "briefs"); }
function briefDirectory(id) { return path.join(briefRoot(), briefId.parse(id)); }
/** The harnesses whose TUI takes a first prompt as an argument, and the
* arguments that carry it. Claude reads outside its working directory only
* with permission, so the brief's own folder is added to its tool access;
* `--add-dir` takes several values, so it comes after the prompt. */
export function briefArgs(kind, file) {
if (!launchesWithBrief(kind))
return undefined;
const prompt = `Read and follow the brief in ${file}`;
if (kind === "claude")
return [prompt, "--add-dir", path.dirname(file)];
return [prompt];
}
export function launchesWithBrief(kind) { return kind === "claude" || kind === "codex"; }
async function prune(now) {
const root = briefRoot();
const names = (await readdir(root).catch(() => [])).filter(name => briefId.safeParse(name).success);
const entries = (await Promise.all(names.map(async (name) => {
const info = await lstat(path.join(root, name)).catch(() => undefined);
return info?.isDirectory() ? { name, at: info.mtimeMs } : undefined;
}))).filter((entry) => !!entry).sort((a, b) => b.at - a.at);
const stale = entries.filter((entry, index) => index >= MAX_BRIEFS - 1 || now - entry.at > KEEP_MS);
await Promise.all(stale.map(entry => rm(path.join(root, entry.name), { recursive: true, force: true }).catch(() => undefined)));
}
const LABEL_FILE = "label";
const MAX_LABEL = 200;
/** A dispatch label as stored and shown: control characters and runs of blanks collapsed, at most 200 characters. */
function cleanLabel(value) { return value.replace(/[\x00-\x1f\x7f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, MAX_LABEL).trim(); }
/**
* The label the dispatcher gave a launched worker, kept beside its brief so
* this Hook can name the worker's session by it: the harness titles a session
* after its first prompt, which is only "Read and follow the brief in ...".
* Undefined for an id this computer wrote no label for (older dispatches).
*/
export async function briefLabel(id) {
if (!briefId.safeParse(id).success)
return undefined;
const file = path.join(briefDirectory(id), LABEL_FILE);
try {
const info = await lstat(file);
if (!info.isFile() || info.size > 4 * MAX_LABEL)
return undefined;
return cleanLabel(await readFile(file, "utf8")) || undefined;
}
catch {
return undefined;
}
}
/**
* Writes the brief (0600, in a 0700 folder of its own) and returns its path.
* A `label` is written beside it, inside the same folder, so it is published
* with the brief and never lags it.
* The folder is filled beside `briefs/` and renamed into place, so a brief
* folder never exists without its `brief.md`: anything that lists `briefs/`
* (the arrival route, a test, the owner) sees a whole brief or none. Writing
* it in place left a moment where the folder was there and the file was not.
*/
export async function writeLaunchBrief(brief, now = Date.now(), label) {
await mkdir(briefRoot(), { recursive: true, mode: 0o700 });
await prune(now);
const directory = briefDirectory(brief.id);
const file = path.join(directory, "brief.md");
const text = brief.text.endsWith("\n") ? brief.text : `${brief.text}\n`;
const named = label ? cleanLabel(label) : "";
const fill = async (folder) => {
await atomic(path.join(folder, "brief.md"), text);
if (named)
await atomic(path.join(folder, LABEL_FILE), named);
};
// The same id again (a retried launch) replaces the text and label in the existing folder.
if (await stat(directory).catch(() => undefined)) {
await fill(directory);
return file;
}
const staging = path.join(bridgeRoot(), "briefs-staging");
await mkdir(staging, { recursive: true, mode: 0o700 });
const draft = path.join(staging, `${brief.id}.${randomUUID()}`);
try {
await mkdir(draft, { mode: 0o700 });
await fill(draft);
await rename(draft, directory);
}
catch (error) {
await rm(draft, { recursive: true, force: true }).catch(() => undefined);
// Another writer published the same id first: write into its folder.
if (!(await stat(path.join(directory, "brief.md")).catch(() => undefined)))
throw error;
await fill(directory);
}
return file;
}
const arrivalEvent = z.object({ at: z.string().datetime(), target: targetSchema }).strict();
export const arrivalSchema = z.object({ started: arrivalEvent.optional(), accepted: arrivalEvent.optional() }).strict();
/** What the worker's own hooks have said about its brief: `started` at its
* SessionStart, `accepted` when the brief prompt was submitted. Empty while
* neither has arrived; undefined when this computer wrote no such brief. */
export async function briefArrival(id) {
const directory = briefDirectory(id);
if (!(await stat(directory).catch(() => undefined))?.isDirectory())
return undefined;
const text = await readFile(path.join(directory, "arrival.json"), "utf8").catch(() => undefined);
const parsed = text === undefined ? undefined : arrivalSchema.safeParse((() => { try {
return JSON.parse(text);
}
catch {
return undefined;
} })());
return parsed?.success ? parsed.data : {};
}
/** The brief id in a prompt that points at a brief file, for callbacks that
* cannot trust their own environment (Codex's shared hook daemon). */
export function briefIdInPrompt(prompt) {
const escaped = briefRoot().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = new RegExp(`${escaped}[\\\\/]([A-Za-z0-9_-]{8,64})[\\\\/]brief\\.md`).exec(prompt);
return match?.[1];
}
let arrivals = Promise.resolve();
/** Records a worker hook's report about its brief. Only the first of each
* event counts, and only for a brief this computer wrote. */
export function recordBriefArrival(id, event, target, now = new Date()) {
const kind = event === "UserPromptSubmit" ? "accepted" : event === "SessionStart" ? "started" : undefined;
if (!kind || !briefId.safeParse(id).success)
return Promise.resolve();
const run = arrivals.then(async () => {
const current = await briefArrival(id);
if (!current || current[kind])
return;
await atomic(path.join(briefDirectory(id), "arrival.json"), arrivalSchema.parse({ ...current, [kind]: { at: now.toISOString(), target } }));
});
arrivals = run.catch(() => undefined);
return run;
}