UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

296 lines (295 loc) • 20.7 kB
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 } : {}) }; }