@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
191 lines (190 loc) • 13.1 kB
JavaScript
import { randomUUID } from "node:crypto";
import { deliveryIdSchema } from "./prompt-once.js";
import { terminalPaneFromEnv } from "./terminal.js";
import { z } from "zod";
import { computerName } from "./computers.js";
import { hookRequest } from "./client.js";
import { projectName } from "./dispatch.js";
import { paneProject } from "./approval-summary.js";
import { isAccountSlug } from "./claude-accounts.js";
import { grantLabel, findGrant } from "./grants.js";
import { hookPeers, optionalHookPeers, peerRequest } from "./peers.js";
import { BridgeError, errorCode, object, objects, sessionId, targetSchema } from "./protocol.js";
import { findPhrenPath } from "../phren-paths.js";
import { listMachines } from "../profile-store.js";
import { localNames } from "./computer-names.js";
import { computerLabel, foldComputers, linkedComputer } from "./computer-identity.js";
export { computerLabel };
const promptText = z.string().min(1).max(32768).refine(value => !/[\x00-\x08\x0b-\x1f\x7f]/.test(value));
export const handOffSchema = z.object({
computer: computerName.optional().describe("Enrolled computer name. Omit for this computer."),
target: targetSchema.optional().describe("Complete live target for the existing session."),
session: sessionId.optional().describe("Session id to resolve through the Hook workspace overview."),
project: projectName.optional().describe("Project slug this hand-off belongs to, for conductor grant matching."),
account: z.string().refine(isAccountSlug, "Account must be default or a lowercase slug.").optional()
.describe("Claude account id the session must run under (default, or a slug). A session of another account is refused; a row without an account counts as default."),
deliveryId: deliveryIdSchema.describe("Stable message id to keep on retries or to check status."),
status: z.boolean().optional().describe("Read delivery status without sending; requires deliveryId."),
text: promptText.optional().describe("Prompt to deliver to the existing session, at most 32768 characters."),
}).strict().superRefine((value, context) => {
if (value.status ? !value.deliveryId || value.text !== undefined : !value.text)
context.addIssue({ code: "custom", message: "A send requires text; a status query requires deliveryId and no text." });
if ((value.target === undefined) === (value.session === undefined))
context.addIssue({ code: "custom", message: "Provide exactly one of target or session." });
});
/** The pane's project (its folder) or workspace label, for the conductor's
* card; never a reason to fail a delivery. */
function labelOf(group, tab) {
const folder = typeof tab.cwd === "string" && tab.role !== "conductor" ? tab.cwd.split("/").filter(Boolean).at(-1) : undefined;
return folder || (typeof group.label === "string" && group.label ? group.label : undefined);
}
/** The account id an overview row runs under; a row without one is the default account. */
const accountOf = (tab) => typeof object(tab.account).id === "string" ? String(object(tab.account).id) : "default";
async function findInOverview(request, match, server) {
const route = server ? `/v1/workspaces?server=${encodeURIComponent(server)}` : "/v1/workspaces";
const overview = await request(route);
for (const group of objects(overview.groups))
for (const tab of objects(group.children)) {
const parsed = targetSchema.safeParse(tab.target);
if (parsed.success && match(parsed.data))
return { target: parsed.data, label: labelOf(group, tab), account: accountOf(tab) };
}
return undefined;
}
async function targetFromOverview(request, session, server, account) {
const found = await findInOverview(request, target => target.session === session, server);
if (!found)
throw new BridgeError(404, "No live session with that id appears in the workspace overview.");
if (account && found.account !== account)
throw new BridgeError(409, `That session runs under account ${found.account}, not ${account}.`, { code: "account_mismatch" });
return found;
}
/** A stable delivery id names a message on the receiving Hook. The Hook
* queues busy targets and retains outcomes across restarts. */
export async function handOff(input, options = {}) {
const data = handOffSchema.parse(input);
let request;
let peer;
if (data.computer === undefined)
request = (route, body) => hookRequest(route, body);
else {
// No hooks.yaml only matters when the name turns out to be another computer.
let peersError;
const peers = await hookPeers().catch(error => { peersError = error; return []; });
// An alias or hostname (`Mac`, `Desk.local`) names the same computer as its hooks.yaml name.
const named = peers.some(candidate => candidate.name === data.computer) ? undefined : await linkedComputer(data.computer).catch(() => undefined);
if (named && "local" in named)
request = (route, body) => hookRequest(route, body);
else {
if (peersError)
throw peersError;
const found = peers.find(candidate => candidate.name === (named && "peer" in named ? named.peer : data.computer));
if (!found)
throw new BridgeError(404, "Unknown computer. Add its verified connection to hooks.yaml.");
peer = found;
request = (route, body) => peerRequest(found, route, body);
}
}
const resolved = data.target ? undefined : await targetFromOverview(request, data.session, peer?.server, data.account);
const target = data.target ?? resolved.target;
if (peer && target.server !== peer.server)
throw new BridgeError(400, "The target belongs to a different Herdr server on that computer.");
const grant = await findGrant({ action: "hand_off", project: data.project, computer: data.computer });
let origin;
if (!data.status && options.notifySender !== false) {
const here = await terminalPaneFromEnv();
if (here)
origin = await findInOverview((route, body) => hookRequest(route, body), found => found.server === here.server && found.pane === here.pane, here.server).then(found => found?.target, () => undefined);
}
const originComputer = peer && origin ? await hookRequest("/v1/health").then(health => object(health.computer).name, () => undefined) : undefined;
const deliveryId = data.deliveryId ?? options.deliveryId ?? randomUUID().replaceAll("-", "");
const result = await request(data.status ? "/v1/hand-off/status" : "/v1/hand-off", { target, deliveryId,
...(!data.status ? { text: data.text, ...(origin ? { origin, ...(typeof originComputer === "string" ? { originComputer } : {}) } : {}) } : {}) });
// A bare ok only acknowledges typing. Even an unchanged, busy Codex pane
// may have kept the text in its composer instead of submitting a turn.
const delivered = result.ok === true && result.delivered === true && result.deliveryUncertain !== true && result.unsubmitted !== true;
// A session id was resolved from the overview already; an explicit target
// is looked up once more, best effort, for its label.
const label = resolved ? resolved.label
: await findInOverview(request, found => found.pane === target.pane && found.session === target.session, peer?.server)
.then(found => found?.label, () => undefined);
const queued = result.queued === true && result.deliveryUncertain !== true;
return { ok: delivered || queued, delivered, target,
...(queued ? { queued: true } : {}),
...(typeof result.deliveryId === "string" ? { deliveryId: result.deliveryId } : {}),
...(typeof result.state === "string" ? { state: result.state } : {}),
...(!delivered && !queued && (result.ok === true || result.deliveryUncertain === true) ? { deliveryUncertain: true } : {}),
...(result.unsubmitted === true ? { unsubmitted: true } : {}),
...(label ? { label } : {}), ...(grant ? { granted: grantLabel(grant) } : {}) };
}
function sessionsFrom(overview, computer, local, now = Date.now()) {
const sessions = [];
for (const group of objects(overview.groups))
for (const tab of objects(group.children)) {
if (typeof tab.agent !== "string")
continue;
// A worker opened as a tab in the conductor's workspace is named by its
// tab (Herdr's bare tab numbers do not count), never as the conductor.
const inConductorWorkspace = tab.role !== "conductor" && objects(group.children).some(other => other.role === "conductor");
const label = inConductorWorkspace ? (typeof tab.label === "string" && !/^\d+$/.test(tab.label) ? tab.label : undefined) : group.label;
const target = targetSchema.safeParse(tab.target);
const cwd = typeof tab.cwd === "string" ? tab.cwd : "";
const text = (value) => typeof value === "string" && value ? value : undefined;
const changedAt = typeof tab.lastChangedAt === "string" ? Date.parse(tab.lastChangedAt) : NaN;
// A conductor sits in the store, not a project.
sessions.push({ computer, local, project: tab.role === "conductor" ? undefined : text(paneProject(cwd)), label: text(label),
title: text(tab.title), agent: tab.agent, status: text(tab.agentStatus), role: text(tab.role),
branch: text(tab.branch), model: text(tab.model), ...(typeof object(tab.account).id === "string" ? { account: String(object(tab.account).id) } : {}), ...(target.success ? { target: target.data } : {}),
...(typeof tab.backgroundTasks === "number" && tab.backgroundTasks > 0 ? { backgroundTasks: tab.backgroundTasks } : {}),
...(tab.stalled === true ? { stalled: true, stallFor: Number(tab.stallFor), stalledSince: String(tab.stalledSince) } : {}),
...(Number.isFinite(changedAt) ? { idleFor: Math.max(0, Math.floor((now - changedAt) / 1000)) } : {}) });
}
return sessions;
}
/** Computers the store registers (machines.yaml) that this Hook has no
* verified connection to, so their sessions cannot be listed from here: the
* unlinked rows of `foldComputers`, which folds names by first label and by
* shared profile into the computers it can see. */
export function notLinkedFrom(store, here, peers) {
if (!store)
return [];
const machines = listMachines(store);
if (!machines.ok)
return [];
return foldComputers({ local: { names: [here, ...localNames()] }, peers, machines: machines.data })
.filter(row => !row.linked).map(row => row.aliases.length ? { name: row.name, aliases: row.aliases } : { name: row.name });
}
/** As `notLinkedFrom` for a flat list of names the linked computers answer
* to, each standing for its own computer. */
export function notLinkedComputers(store, here, linked) {
return notLinkedFrom(store, here, linked.map(name => ({ name, address: name })));
}
/** Every live agent the conductor could hand work to: this computer's Herdr
* overview plus each enrolled computer's, read through its verified Hook.
* An unreachable computer is reported, never silently dropped. */
export async function listLiveSessions(options = {}) {
const health = await hookRequest("/v1/health");
const here = typeof object(health.computer).name === "string" ? String(object(health.computer).name) : "this computer";
const sessions = sessionsFrom(await hookRequest("/v1/workspaces"), here, true);
const { peers, peerError } = await optionalHookPeers();
const unreachable = [];
// Names each peer answers to (its hostname, Bonjour name), so a computer
// registered under another of its names is not reported as unlinked.
const peerNames = new Map();
await Promise.all(peers.map(async (peer) => {
try {
const route = peer.server && peer.server !== "default" ? `/v1/workspaces?server=${encodeURIComponent(peer.server)}` : "/v1/workspaces";
const [overview, health] = await Promise.all([peerRequest(peer, route), peerRequest(peer, "/v1/health").catch(() => ({}))]);
sessions.push(...sessionsFrom(overview, peer.name, false));
const computer = object(object(health).computer);
peerNames.set(peer.name, [computer.name, ...(Array.isArray(computer.aliases) ? computer.aliases : [])].filter((name) => typeof name === "string"));
}
catch (error) {
const code = errorCode(error);
unreachable.push({ computer: peer.name, error: error instanceof Error ? error.message : "Unreachable.", ...(code ? { code } : {}) });
}
}));
const store = options.store !== undefined ? options.store : findPhrenPath();
const notLinked = notLinkedFrom(store, here, peers.map(peer => ({ name: peer.name, address: peer.address, names: peerNames.get(peer.name) })));
return { sessions, unreachable, notLinked, enrolled: peers.length, ...(peerError ? { peerError } : {}) };
}