@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
177 lines (176 loc) • 8.48 kB
JavaScript
import { lstatSync, readFileSync } from "node:fs";
import path from "node:path";
import { z } from "zod";
import { isConductorName, paneAgentName } from "./herdr.js";
import { atomicInPrivateDir, bridgeRoot, id, objects, provider, serverName } from "./protocol.js";
/**
* Which pane is this computer's conductor, as the Hook keeps it
* (docs/conductor-sets.md). The role follows the pane: server, pane id and
* terminal id. When the agent in that pane restarts or logs in again, the
* role stays and the new session is recorded. The Herdr agent name or tmux
* `@phren_agent` is only a label, read once to migrate a conductor that
* predates this file.
*/
const recordSchema = z.object({
server: serverName, pane: id, terminal: z.string().max(200).optional(), workspace: id.optional(), tab: id.optional(),
source: provider.optional(), session: z.string().max(200).optional(),
since: z.string().datetime(), by: z.enum(["launch", "owner", "migrated"]),
});
/** A cleared name keeps its time (`name: null`), so an older name arriving later does not return. */
const setNameSchema = z.object({ name: z.string().min(1).max(60).nullable(), namedAt: z.string().datetime() });
/** `conductor` absent is legacy mode: nothing has decided the role yet, so a
* conductor is still recognized by its mux name. `null` is no conductor. */
const stateSchema = z.object({ version: z.literal(1), conductor: recordSchema.nullable().optional(), set: setNameSchema.optional() });
const roleFile = () => path.join(bridgeRoot(), "conductor-role.json");
/** The Hook is the only writer, so the last state it read or wrote stands in for the file. */
let cached;
/** The saved state; undefined before the file exists. */
export async function readRoleState() {
const file = roleFile();
if (cached?.file === file)
return cached.state;
// Read once per process and synchronously: the overview's time budget
// should not wait on disk for a file of a few hundred bytes.
let text;
try {
const info = lstatSync(file);
// Only a small regular file the Hook wrote is read; anything else counts as damaged.
text = info.isFile() && info.size <= 65_536 ? readFileSync(file, "utf8") : "";
}
catch {
text = undefined;
}
let state;
if (text !== undefined) {
// A damaged file is a state with no conductor, never a return to names.
try {
state = stateSchema.parse(JSON.parse(text));
}
catch {
state = { version: 1, conductor: null };
}
}
cached = { file, state };
return state;
}
async function writeRoleState(state) {
const file = roleFile();
await atomicInPrivateDir(file, JSON.stringify(state, null, 2) + "\n");
cached = { file, state };
}
/** Every read-modify-write of the state runs one at a time: an overview poll
* noting a moved pane must not interleave with a make or stop and bring back
* the record it replaced. */
let queue = Promise.resolve();
function serialized(run) {
const next = queue.then(run, run);
queue = next.catch(() => undefined);
return next;
}
/** For tests: forget the cached state. */
export function resetRoleState() { cached = undefined; }
const liveStatus = (pane) => !["completed", "exited", "failed", "stopped"].includes(String(pane.agent_status));
/** A pane running an agent the Hook can talk to. */
export const runsAgent = (pane) => !!pane && provider.safeParse(pane.agent).success && liveStatus(pane);
function recordFor(server, pane, by, session) {
return {
server, pane: String(pane.pane_id), ...(typeof pane.terminal_id === "string" ? { terminal: pane.terminal_id } : {}),
...(id.safeParse(pane.workspace_id).success ? { workspace: String(pane.workspace_id) } : {}),
...(id.safeParse(pane.tab_id).success ? { tab: String(pane.tab_id) } : {}),
...(provider.safeParse(pane.agent).success ? { source: pane.agent } : {}),
...(session ? { session } : {}), since: new Date().toISOString(), by,
};
}
/** The recorded pane in a snapshot of its server, when it is still the same terminal. */
function recordedPane(record, s) {
const pane = objects(s.panes).find(p => p.pane_id === record.pane);
if (!pane)
return undefined;
// A pane id the multiplexer gave to a new terminal is another pane.
if (record.terminal && typeof pane.terminal_id === "string" && pane.terminal_id !== record.terminal)
return undefined;
return pane;
}
/**
* The conductor's pane on `server`, from a snapshot of it, whether or not an
* agent runs there right now. A recorded pane gone from the snapshot ends the
* role. In legacy mode a live pane with a conductor name is adopted and saved.
*/
export function conductorPane(server, s) {
return serialized(async () => {
const state = await readRoleState();
if (state?.conductor === undefined) {
const named = objects(s.panes).find(pane => isConductorName(paneAgentName(s, pane)) && runsAgent(pane));
if (!named)
return undefined;
await writeRoleState({ ...state, version: 1, conductor: recordFor(server, named, "migrated") });
return named;
}
const record = state.conductor;
if (!record || record.server !== server)
return undefined;
const pane = recordedPane(record, s);
if (!pane) {
await writeRoleState({ ...state, conductor: null });
return undefined;
}
// Keep the place current: a pane moved to another tab or workspace is the same pane.
const moved = (id.safeParse(pane.workspace_id).success && pane.workspace_id !== record.workspace) || (id.safeParse(pane.tab_id).success && pane.tab_id !== record.tab)
|| (provider.safeParse(pane.agent).success && pane.agent !== record.source);
if (moved)
await writeRoleState({ ...state, conductor: { ...record, workspace: String(pane.workspace_id), tab: String(pane.tab_id),
...(provider.safeParse(pane.agent).success ? { source: pane.agent } : {}) } });
return pane;
});
}
/** The recorded conductor, whatever its server; undefined in legacy mode or with none. */
export async function recordedConductor() {
return (await readRoleState())?.conductor ?? undefined;
}
/** Makes `pane` on `server` this computer's conductor, replacing any record. */
export function recordConductor(server, pane, by, session) {
return serialized(async () => {
const state = await readRoleState() ?? { version: 1 };
const record = recordFor(server, pane, by, session);
await writeRoleState({ ...state, conductor: record });
return record;
});
}
/** The session now running in the conductor's pane: a restart or new login reattaches here. */
export function noteConductorSession(server, pane, session) {
return serialized(async () => {
const state = await readRoleState();
const record = state?.conductor;
if (!state || !record || record.server !== server || record.pane !== pane || record.session === session)
return;
await writeRoleState({ ...state, conductor: { ...record, session } });
});
}
/** Ends the role. With `pane`, only when that pane holds it. Returns what was stopped. */
export function clearConductor(pane) {
return serialized(async () => {
const state = await readRoleState();
const record = state?.conductor ?? undefined;
if (record && pane !== undefined && record.pane !== pane)
return undefined;
// Written even with nothing to clear: a stop also ends legacy mode, so a
// leftover conductor name is not adopted again.
await writeRoleState({ ...state, version: 1, conductor: null });
return record;
});
}
/** The set name this Hook holds. */
export async function readSetName() {
return (await readRoleState())?.set;
}
/** Keeps `name` when it is newer than the one held; `null` clears. Returns whether it changed. */
export function saveSetName(name, namedAt) {
return serialized(async () => {
const state = await readRoleState();
const held = state?.set;
if (held && Date.parse(held.namedAt) >= Date.parse(namedAt))
return false;
await writeRoleState({ ...state, version: 1, set: { name, namedAt } });
return held?.name !== name;
});
}