@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
296 lines (295 loc) • 20.7 kB
JavaScript
import { redactPeerHandoff } from "./peer-handoff.js";
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 { moveToSchema, projectName } from "./dispatch.js";
import { paneProject } from "./approval-summary.js";
import { isAccountSlug } from "./claude-accounts.js";
import { accountWindows } from "./account-choice.js";
import { resetsIn } from "../computers/read.js";
import { grantLabel, findGrant } from "./grants.js";
import { hookPeers, optionalHookPeers, peerRequest } from "./peers.js";
import { BridgeError, errorCode, id, 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. */
/** The Hook of `computer` (this one when undefined): its request function, and its peer when it is another computer. */
async function hookFor(computer) {
if (computer === undefined)
return { request: (route, body) => hookRequest(route, body) };
// 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 === computer) ? undefined : await linkedComputer(computer).catch(() => undefined);
if (named && "local" in named)
return { request: (route, body) => hookRequest(route, body) };
if (peersError)
throw peersError;
const found = peers.find(candidate => candidate.name === (named && "peer" in named ? named.peer : computer));
if (!found)
throw new BridgeError(404, "Unknown computer. Add its verified connection to hooks.yaml.");
return { peer: found, request: (route, body) => peerRequest(found, route, body) };
}
export async function handOff(input, options = {}) {
const data = handOffSchema.parse(input);
if (!options.bypassConductor) {
const here = await terminalPaneFromEnv();
if (here)
return await hookRequest("/v1/conductor/call", { action: "hand_off", data, origin: here });
}
const { request, peer } = await hookFor(data.computer);
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: peer ? redactPeerHandoff(data.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) } : {}) };
}
export const moveSessionSchema = z.object({
computer: computerName.optional().describe("Enrolled computer the session runs on. Omit for this computer."),
target: targetSchema.optional().describe("Complete live target of the session to move."),
session: sessionId.optional().describe("Session id to resolve through the Hook workspace overview."),
pane: id.optional().describe("Pane id (as Herdr or tmux shows it, e.g. wC9:p1) of the session to move, resolved through the Hook workspace overview."),
harness: moveToSchema.shape.harness,
account: moveToSchema.shape.account,
model: moveToSchema.shape.model,
effort: moveToSchema.shape.effort,
id: z.string().uuid().optional().describe("Move id: keep it on a retry, or pass it with status:true to read where the move is."),
status: z.boolean().optional().describe("Read the move's state without starting one; requires id."),
wait: z.boolean().optional().describe("Wait (up to five minutes) until the move finished or failed. Defaults to true."),
}).strict().superRefine((value, context) => {
if (!value.status && [value.target, value.session, value.pane].filter(item => item !== undefined).length !== 1)
context.addIssue({ code: "custom", message: "Provide exactly one of target, session or pane." });
if (value.status && !value.id)
context.addIssue({ code: "custom", message: "A status query requires id." });
});
const MOVE_WAIT_MS = 5 * 60_000;
/** The live session in a pane, by the pane id the terminal shows. */
async function paneTarget(request, pane, server) {
const found = await findInOverview(request, target => target.pane === pane, server);
if (!found)
throw new BridgeError(404, "No live session in that pane appears in the workspace overview.");
return found.target;
}
/**
* Moves a live session to another harness on its own computer (session-move.ts):
* the agent writes a hand-off and exits, and the new agent starts in its pane
* with it. Answers with the move record; a dispatched worker's receipt follows
* the move on its next poll.
*/
export async function moveSession(input, options = {}) {
const data = moveSessionSchema.parse(input);
const { request, peer } = await hookFor(data.computer);
const read = async (id) => object((await request(`/v1/sessions/move?id=${encodeURIComponent(id)}`)).move);
const settled = (move) => move.state === "moved" || move.state === "failed";
let move;
if (data.status)
move = await read(data.id);
else {
const target = data.target ?? (data.session ? (await targetFromOverview(request, data.session, peer?.server)).target : await paneTarget(request, data.pane, peer?.server));
if (peer && target.server !== peer.server)
throw new BridgeError(400, "The target belongs to a different Herdr server on that computer.");
const to = { harness: data.harness, ...(data.account ? { account: data.account } : {}), ...(data.model ? { model: data.model } : {}), ...(data.effort ? { effort: data.effort } : {}) };
move = object((await request("/v1/sessions/move", { target, to: moveToSchema.parse(to), ...(options.agent ? { origin: "agent" } : {}), ...(data.id ? { id: data.id } : {}) })).move);
if (data.wait !== false && typeof move.id === "string") {
const until = Date.now() + (options.waitMs ?? MOVE_WAIT_MS);
while (!settled(move) && Date.now() < until) {
await new Promise(resolve => setTimeout(resolve, options.pollMs ?? 3_000));
move = await read(String(move.id)).catch(() => move);
}
}
}
return { ok: move.state !== "failed", move, settled: settled(move) };
}
const CLAUDE_USAGE_ROUTE = "/v1/usage?sources=claude&accounts=all";
/** Each Claude account's room in one computer's usage answer. */
export function claudeAccountRooms(answer, computer, now = Date.now()) {
const window = (room) => {
if (!room)
return undefined;
const until = room.resetsAt ? resetsIn(room.resetsAt, now) : undefined;
return { ...room, ...(until ? { resetsIn: until } : {}) };
};
return objects(answer.accounts).filter(row => row.source === "claude" && Array.isArray(row.windows)).map(row => {
const usage = row;
const { fiveHour, week } = accountWindows(usage, now);
const five = window(fiveHour), seven = window(week);
return { computer, account: usage.account?.id ?? "default", ...(usage.account?.label ? { label: usage.account.label } : {}),
...(usage.account?.email ? { email: usage.account.email } : {}), ...(five ? { fiveHour: five } : {}), ...(seven ? { week: seven } : {}) };
});
}
/** Owner-visible login metadata and remaining allowance for continuation.
* Config paths, identity hashes and provider credentials stay on the Hook. */
export function claudeMoveAccounts(answer) {
return claudeAccountRooms(answer, "local").map(room => ({ id: room.account,
...(room.email ? { email: room.email } : {}),
...(typeof object(objects(answer.accounts).find(row => row.source === "claude" && object(row.account).id === room.account)?.subscription).plan === "string" ? { plan: String(object(objects(answer.accounts).find(row => row.source === "claude" && object(row.account).id === room.account)?.subscription).plan) } : {}),
...(room.fiveHour?.resetsAt ? { fiveHourResetsAt: room.fiveHour.resetsAt } : {}),
...(room.week?.resetsAt ? { weekResetsAt: room.week.resetsAt } : {}),
...(room.fiveHour?.leftPercent !== undefined ? { fiveHourLeft: room.fiveHour.leftPercent } : {}),
...(room.week?.leftPercent !== undefined ? { weekLeft: room.week.leftPercent } : {}),
...(room.fiveHour?.resetsAt ?? room.week?.resetsAt ? { resetsAt: room.fiveHour?.resetsAt ?? room.week?.resetsAt } : {}),
}));
}
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 [overview, localUsage] = await Promise.all([hookRequest("/v1/workspaces"), hookRequest(CLAUDE_USAGE_ROUTE, undefined, undefined, 10_000).catch(() => undefined)]);
const sessions = sessionsFrom(overview, here, true);
const claudeAccounts = localUsage ? claudeAccountRooms(localUsage, here) : [];
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, usage] = await Promise.all([peerRequest(peer, route), peerRequest(peer, "/v1/health").catch(() => ({})),
peerRequest(peer, CLAUDE_USAGE_ROUTE, undefined, 10_000).catch(() => undefined)]);
sessions.push(...sessionsFrom(overview, peer.name, false));
if (usage)
claudeAccounts.push(...claudeAccountRooms(usage, peer.name));
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) })));
// Each Claude session carries its account's room, so the conductor and phone see how long it can keep working.
for (const session of sessions) {
if (session.agent !== "claude")
continue;
const room = claudeAccounts.find(item => item.computer === session.computer && item.account === (session.account ?? "default"));
if (room?.fiveHour || room?.week)
session.quota = { ...(room.fiveHour ? { fiveHour: room.fiveHour } : {}), ...(room.week ? { week: room.week } : {}) };
}
claudeAccounts.sort((a, b) => a.computer.localeCompare(b.computer) || a.account.localeCompare(b.account));
return { sessions, unreachable, notLinked, enrolled: peers.length, ...(claudeAccounts.length ? { claudeAccounts } : {}), ...(peerError ? { peerError } : {}) };
}