@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
949 lines • 62.3 kB
JavaScript
import { redactPeerHandoff } from "./peer-handoff.js";
import { integratorSchema, prsSchema } from "./return-contract.js";
import { randomUUID } from "node:crypto";
import { lstat, mkdir, readFile, readdir, realpath, stat } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { logger } from "../logger.js";
import { homeDir } from "../home-paths.js";
import { getMachineName } from "../machine-identity.js";
import { getProjectSourcePath } from "../project-config.js";
import { computerName } from "./computers.js";
import { dispatchParentSchema, validateDispatchParent } from "./dispatch-tree.js";
import { findGrant, grantLabel, permissionModeAllowed, DEFAULT_MAX_PERMISSION_MODE } from "./grants.js";
import { checkAgentDispatch, projectAuthority, readAuthority, RELEASE_ACTIONS, releaseAction } from "./authority.js";
import { hookPeers } from "./peers.js";
import { callerQuery, localCaller, oneWayHint } from "./conductor-group.js";
import { prepareDispatchOriginHandover, trackDispatchOrigin } from "./dispatch-origins.js";
import { readRoleState, recordedConductor } from "./conductor-role.js";
import { linkedComputer } from "./computer-identity.js";
import { isLocalComputer, localHost, peerHost } from "./dispatch-hosts.js";
import { object, atomic, BridgeError, bridgeRoot, id, launchEfforts, PERMISSION_MODES, PROTOCOL, provider, serverName, startingTargetSchema, targetSchema } from "./protocol.js";
import { phrenStoreRoot } from "./transcripts.js";
import { isAccountSlug } from "./claude-accounts.js";
import { hasUsable } from "./harnesses.js";
import { arrivalSchema } from "./launch-brief.js";
const text = (max) => z.string().min(1).max(max).refine(value => !!value.trim() && !/[\x00-\x1f\x7f]/.test(value));
export const projectName = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/);
/** The harnesses a dispatch, a code note's hand-off or a schedule can start. */
export const PEER_COPILOT_REASON = "Copilot cannot continue on another computer: its harness does not confirm launch-brief arrival. Continue locally or choose another harness.";
export const DISPATCH_HARNESSES = ["codex", "claude", "opencode", "copilot"];
export const dispatchSchema = z.object({
computer: z.union([z.literal("anywhere"), computerName]).describe("Enrolled computer name, or anywhere for the least busy connected computer."),
project: projectName.describe("Project slug registered on the receiving computer."),
harness: z.enum(DISPATCH_HARNESSES).describe("Agent harness on the receiving computer."),
model: text(200).optional().describe("Explicit model, otherwise the remote harness default."),
effort: z.enum(launchEfforts).optional().describe("Reasoning effort for the worker (minimal, low, medium, high, xhigh, max), otherwise the harness default."),
account: z.string().refine(isAccountSlug, "Account must be default or a lowercase slug.").optional()
.describe("Claude account id to run the worker under (default, or a slug from `phren bridge accounts`). anywhere only picks computers where that account is signed in; a named computer without it fails. Without it, a computer with several signed-in Claude accounts picks the one with the most 5-hour room, then weekly; the receipt's accountChoice says why."),
permissionMode: z.enum(PERMISSION_MODES).optional().describe("Permission mode the worker starts in: supervised, auto-edits, auto or full-access (Claude, Codex and Copilot; not OpenCode); otherwise the receiving computer's own default."),
releaseActions: z.array(releaseAction).min(1).max(RELEASE_ACTIONS.length).optional()
.describe("Release-type actions the brief asks the worker to do (merge, publish, deploy, app-store, github-admin). Ask-first projects refuse them from an agent unless the owner confirmed."),
prompt: z.string().min(1).max(32768).refine(value => !/[\x00-\x08\x0b-\x1f\x7f]/.test(value)).describe("Worker brief, at most 32768 characters."),
label: text(200).describe("Short task label."),
closeOnFinish: z.boolean().optional().describe("Close the finished worker pane after its done return is read. Defaults to true; false keeps it open."),
integrator: integratorSchema.optional().describe("Forward structured PR reports to this integrator, overriding the Hook default."),
parent: dispatchParentSchema.optional().describe("Explicit local conversation parent for work-tree attachment."),
parentTarget: targetSchema.optional().describe("Complete live target for the explicit parent."),
}).strict();
/** Where a session moves to (session-move.ts): a harness with its account, model and effort, as a dispatch names them. */
export const moveToSchema = z.object({
computer: computerName.optional().describe("Verified destination computer; omitted means this computer."),
harness: z.enum(DISPATCH_HARNESSES).describe("Harness to continue in: claude, codex, opencode or copilot."),
account: z.string().refine(isAccountSlug, "Account must be default or a lowercase slug.").optional()
.describe("Claude account id (default, or a slug from `phren bridge accounts`); only for claude."),
model: text(200).optional().describe("Model for the new agent, otherwise the harness default."),
effort: z.enum(launchEfforts).optional().describe("Reasoning effort for the new agent, otherwise the harness default."),
}).strict().superRefine((value, context) => {
if (value.account && value.harness !== "claude")
context.addIssue({ code: "custom", message: "Only a Claude target takes an account." });
});
const remoteTarget = z.union([targetSchema, startingTargetSchema]);
/** The local pane that asked for the dispatch, where return notices go. */
export const originPaneSchema = z.object({ server: serverName, workspace: id, tab: id, pane: id }).strict();
export const workerStates = ["working", "done", "needs-you", "failed", "blocked", "stalled", "gone"];
const timestamp = z.string().datetime();
const receiptSchema = dispatchSchema.omit({ prompt: true }).extend({
id: z.string().uuid(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
computerId: z.string().uuid().optional(),
state: z.enum(["launching", "sending", "accepted", "uncertain", "failed"]),
target: remoteTarget.optional(), error: z.string().max(500).optional(),
brief: z.enum(["launch", "typed"]).optional()
.describe("How the brief reached the worker: as its first prompt at launch (confirmed by the worker's hook), or typed into its pane."),
accountChoice: z.string().max(400).optional().describe("Why the receiving Hook ran the worker under `account` when the dispatch named none."),
continued: z.object({ id: z.string().uuid().optional(), computer: z.string().max(200).optional(), account: z.string().max(64).optional(),
at: timestamp, error: z.string().max(500).optional() }).strict().optional()
.describe("The worker hit its Claude usage limit: the dispatch that continues it on another account, or why none could."),
continues: z.string().uuid().optional().describe("The dispatch this one continues after its worker hit its usage limit."),
moves: z.array(z.object({ id: z.string().uuid(), at: timestamp, handoff: z.string().max(4096).optional(),
computerFingerprint: z.string().regex(/^[a-f0-9]{64}$/).optional(), computerId: z.string().uuid().optional(),
from: z.object({ harness: provider, account: z.string().max(64).optional() }).strict(), to: moveToSchema }).strict()).max(16).optional()
.describe("Each move of this worker to another agent (session-move.ts): from, to, when, and the hand-off file on the worker's computer. `target`, `harness`, `account`, `model` and `effort` are the current agent's."),
granted: z.string().max(200).optional().describe("Scope of the conductor grant that allowed this call."),
authority: z.string().max(600).optional().describe("The release authority policy's line for this project, as a conductor quotes it."),
authorityConfirmed: z.string().datetime().optional().describe("When the owner confirmed the ask-first release actions this agent dispatch used."),
skipped: z.array(z.object({ computer: computerName, reason: z.string().max(200) }).strict()).max(32).optional()
.describe("Computers left out of anywhere placement, with the reason each could not report capacity."),
origin: originPaneSchema.extend({ agent: provider, terminal: z.string().min(1).max(200) }).strict().optional()
.describe("The local agent pane that placed this dispatch; return notices go there."),
closedAt: timestamp.optional(),
closePending: z.object({ at: timestamp, turn: z.string().optional() }).strict().optional(),
worker: z.object({ state: z.enum(workerStates), since: timestamp, checkedAt: timestamp, sawWorking: z.boolean(),
background: z.number().int().min(0).max(999).optional().describe("The most background tasks seen running while the worker was working."),
waitingSince: timestamp.optional().describe("When the dispatching Hook first saw the worker's finished turn waiting on background tasks; bounds that wait.") }).strict().optional()
.describe("The worker pane's last observed state."),
returned: z.object({
state: z.enum(["done", "needs-you", "failed", "blocked", "stalled", "gone"]), at: timestamp,
reply: z.string().max(4000).optional(), error: z.string().max(500).optional(), truncated: z.boolean().optional(), question: z.string().max(200).optional(),
checkout: z.object({ path: z.string().max(4096), branch: z.string().max(256).optional() }).strict().optional()
.describe("Where a worker that failed was working, for the dispatch that continues it."),
prs: prsSchema.optional(),
integratorDelivery: z.object({ deliveryId: z.string(), state: z.enum(["pending", "queued", "delivered", "uncertain", "failed"]), at: timestamp, integrator: integratorSchema.optional() }).strict().optional(),
stalledSince: timestamp.optional(), stallFor: z.number().nonnegative().optional(),
turn: z.string().regex(/^[a-f0-9]{16}$/).optional(), read: z.boolean(), notifiedAt: timestamp.optional(),
background: z.number().int().min(1).max(999).optional().describe("Background tasks the worker left running when it was counted done."),
waited: z.number().int().min(1).max(999).optional().describe("The most background tasks the worker waited on before it finished."),
}).strict().optional().describe("The latest return: the worker finished, needs the owner, failed, is blocked or is gone."),
approval: z.object({
actionId: z.string().min(1).max(200), tool: z.string().max(200), title: z.string().max(200).optional(), request: z.string().max(500).optional(),
requestKind: z.enum(["command", "tool", "edit", "question", "other"]).optional(), terminal: z.boolean().optional(),
conductor: z.object({ action: z.enum(["dispatch", "hand_off"]), project: z.string().max(200).optional(), computer: z.string().max(200).optional() }).strict().optional(),
expiresAt: z.string().max(40).optional(), at: timestamp, pushed: z.boolean().optional(),
}).strict().optional().describe("A permission request the worker is waiting on, forwarded by its Hook; answer it with dispatch_approve."),
});
/** Where a checkout usually sits when the store names none for this computer. */
const CHECKOUT_ROOTS = ["Projects", "projects", "Sites", "Code", "code", "dev", "src", "repos"];
async function directoryAt(source) {
if (typeof source !== "string" || !path.isAbsolute(source) || /[\x00-\x1f\x7f]/.test(source))
return undefined;
const directory = await realpath(source).catch(() => undefined);
return directory && (await stat(directory)).isDirectory() ? directory : undefined;
}
/**
* The project's folder on this computer. The store syncs between computers,
* so its shared `sourcePath` is often another machine's folder: this
* machine's `sourcePaths` entry wins, as everywhere else in phren. With
* neither here, a git checkout named after the project in a usual project
* root is taken (the owner's layout is `~/Projects/<name>` on every computer).
*/
export async function dispatchProjectDirectory(project, home = homeDir()) {
const name = projectName.parse(project);
const configured = await directoryAt(getProjectSourcePath(phrenStoreRoot(), name));
if (configured)
return configured;
for (const root of [...(process.env.PROJECTS_DIR ? [process.env.PROJECTS_DIR] : []), ...CHECKOUT_ROOTS.map(folder => path.join(home, folder))]) {
const checkout = await directoryAt(path.join(root, name));
if (checkout && await stat(path.join(checkout, ".git")).catch(() => undefined))
return checkout;
}
throw new BridgeError(404, `Project ${name} is not on this computer: the store names no folder for ${getMachineName()} that exists here, and there is no ~/Projects/${name} checkout.`);
}
function save(receipt, originWindow) {
return serializeReceiptUpdate(async () => {
// Only this already-running dispatch can inherit a hand-over alias.
// Resolve it under the receipt lock, after any role change invalidation.
const origin = originWindow.rewrite(receipt.origin);
if (origin !== receipt.origin && !receipt.closedAt) {
receipt.origin = origin;
if (receipt.returned && !receipt.returned.read)
delete receipt.returned.notifiedAt;
}
await saveReceipt(receipt);
});
}
async function saveReceipt(receipt) {
const root = path.join(bridgeRoot(), "dispatches");
await mkdir(root, { recursive: true, mode: 0o700 });
await atomic(path.join(root, `${receipt.id}.json`), receipt);
}
const IGNORED_MODE = "The receiving Hook is older and ignored permissionMode; the worker runs in that computer's default mode. Update its Hook.";
const MAX_RECEIPT_BYTES = 65_536;
let receiptUpdates = Promise.resolve();
/**
* Change one settled receipt: read it, let `change` edit it, and write it back
* when `change` returns true. Updates run one at a time in this process, and a
* receipt still being placed (launching or sending) is never touched.
*/
export function updateReceipt(receiptID, change) {
return serializeReceiptUpdate(() => changeReceipt(receiptID, change));
}
function serializeReceiptUpdate(change) {
const run = receiptUpdates.then(change);
receiptUpdates = run.catch(() => undefined);
return run;
}
async function readReceipt(receiptID) {
const file = path.join(bridgeRoot(), "dispatches", `${z.string().uuid().parse(receiptID)}.json`);
const info = await lstat(file).catch(error => {
if (error.code !== "ENOENT")
throw error;
return undefined;
});
if (!info?.isFile() || info.isSymbolicLink() || info.size > MAX_RECEIPT_BYTES)
return undefined;
return receiptSchema.parse(JSON.parse(await readFile(file, "utf8")));
}
/** Caller holds receiptUpdates; ordinary polling never edits a placing receipt. */
async function changeReceipt(receiptID, change) {
const receipt = await readReceipt(receiptID);
if (!receipt || ["launching", "sending"].includes(receipt.state))
return undefined;
if (!change(receipt))
return receipt;
receipt.updatedAt = new Date().toISOString();
await saveReceipt(receiptSchema.parse(receipt));
return receipt;
}
/** Hand over all open worker routes using the receipt writer's own lock and
* update path. Commit the role last; any failure restores the old origins.
* Placing receipts are refused because placement can still overwrite them. */
export function repointDispatchOrigins(from, to, commit) {
return serializeReceiptUpdate(async () => {
const seal = prepareDispatchOriginHandover(from, to);
const names = await readdir(path.join(bridgeRoot(), "dispatches")).catch(error => {
if (error.code !== "ENOENT")
throw error;
return [];
});
const matches = (origin) => origin?.server === from.server && origin.pane === from.pane
&& (from.terminal === undefined || origin.terminal === from.terminal);
const selected = [];
for (const name of names.filter(name => /^[a-f0-9-]{36}\.json$/.test(name))) {
const receipt = await readReceipt(name.slice(0, -5));
if (!receipt || receipt.closedAt || !matches(receipt.origin))
continue;
if (["launching", "sending"].includes(receipt.state))
throw new BridgeError(409, "A conductor dispatch is still being placed; retry the move after it settles.");
selected.push(receipt);
}
const changed = [];
try {
for (const before of selected) {
await changeReceipt(before.id, receipt => {
if (receipt.closedAt || !matches(receipt.origin))
return false;
receipt.origin = to;
// Unread returns already announced to the old chat need a new notice.
if (receipt.returned && !receipt.returned.read)
delete receipt.returned.notifiedAt;
return true;
});
changed.push(before);
}
await commit();
seal();
}
catch (error) {
const failures = [];
for (const before of changed.reverse()) {
try {
await changeReceipt(before.id, receipt => {
receipt.origin = before.origin;
if (receipt.returned && receipt.returned.at === before.returned?.at) {
if (before.returned.notifiedAt)
receipt.returned.notifiedAt = before.returned.notifiedAt;
else
delete receipt.returned.notifiedAt;
}
return true;
});
}
catch (rollback) {
failures.push(`${before.id}: ${failureReason(rollback)}`);
}
}
if (failures.length)
throw new Error(`${failureReason(error)} Receipt routing rollback failed: ${failures.join("; ")}`);
throw error;
}
});
}
/** No ledger yet is normal; any other read failure is logged before it reads as empty. */
async function receiptNames(root) {
try {
return await readdir(root);
}
catch (error) {
if (error.code !== "ENOENT")
logger.warn("dispatch", `Could not list dispatch receipts: ${failureReason(error)}`);
return [];
}
}
/** One bounded line for a log or a receipt: a Bridge message, an errno code, or the error's first line. */
export function failureReason(error) {
if (error instanceof z.ZodError)
return "The remote Hook sent an unexpected reply (protocol mismatch).";
const code = error?.code;
const message = error instanceof Error ? error.message.split("\n")[0] : String(error);
return `${typeof code === "string" && !(error instanceof BridgeError) ? `${code}: ` : ""}${message}`.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 200);
}
export function dispatchStatus() {
return serializeReceiptUpdate(readDispatchStatus);
}
/** Caller holds receiptUpdates, including for the entire routing snapshot. */
async function readDispatchStatus() {
const root = path.join(bridgeRoot(), "dispatches");
const names = (await receiptNames(root)).filter(name => /^[a-f0-9-]{36}\.json$/.test(name)).slice(0, 1024);
const receipts = [];
for (const name of names) {
try {
const file = path.join(root, name);
const info = await lstat(file);
if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_RECEIPT_BYTES)
continue;
const receipt = receiptSchema.parse(JSON.parse(await readFile(file, "utf8")));
// A service restart cannot prove whether an in-flight mutation arrived.
if (["launching", "sending"].includes(receipt.state))
receipt.state = "uncertain";
receipts.push(receipt);
}
catch { /* Interrupted or old receipts do not become live dispatches. */ }
}
return receipts.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
const windowRoom = z.object({ leftPercent: z.number().min(0).max(100).optional(), resetsAt: z.string().max(40).optional() }).passthrough().optional();
async function capacity(host, caller) {
const value = await host.request(caller && !host.local ? `/v1/dispatch/capacity?${caller}` : "/v1/dispatch/capacity");
const result = z.object({ product: z.literal("phren-hook"), protocol: z.literal(PROTOCOL), working: z.number().int().nonnegative(),
servers: z.array(z.string()), computer: z.object({ id: z.string().uuid() }).passthrough(),
// Missing from an older Hook, or when its inventory was not ready in time: unknown, not unavailable.
harnesses: z.array(z.object({ source: z.string(), installed: z.boolean(), usable: z.boolean(), reason: z.string().optional(),
accounts: z.array(z.object({ id: z.string(), usable: z.boolean(), reason: z.string().optional() }).passthrough()).optional() }).passthrough()).optional(),
// Missing from an older Hook or a slow read: that computer's quota is unknown, so it is never ruled out by it.
usage: z.array(z.object({ source: z.string(), account: z.string().optional(), leftPercent: z.number().min(0).max(100).optional(),
exhausted: z.boolean().optional(), until: z.string().max(40).optional(), fiveHour: windowRoom, week: windowRoom }).passthrough()).max(64).optional(),
knowsCaller: z.boolean().optional() }).parse(value);
// This computer places on whichever Herdr server its own Hook runs.
if (host.local && !result.servers.includes(host.server) && result.servers[0])
host.server = result.servers[0];
if (!result.servers.includes(host.server))
throw new BridgeError(503, "Herdr is not running on the selected computer. Headless dispatch is not installed yet.");
return { working: result.working, computerId: result.computer.id, ...(result.harnesses ? { harnesses: result.harnesses } : {}),
...(result.usage ? { usage: result.usage } : {}),
...(caller && result.knowsCaller === false ? { outside: true } : {}) };
}
/** The account this dispatch would run under there, as its capacity probe reports it: Codex's one, or the named Claude home. */
export function roomFor(data, usage) {
const account = data.account ?? "default";
return usage?.find(item => item.source === data.harness && (data.harness !== "claude" || (item.account ?? "default") === account));
}
/** " for about 3 more hours" until `until`, or nothing when it is unknown. */
function backIn(until, now) {
const at = until ? Date.parse(until) : NaN;
const minutes = Number.isFinite(at) ? Math.max(1, Math.round((at - now) / 60_000)) : undefined;
return minutes === undefined ? "" : minutes >= 1440 ? ` for about ${Math.round(minutes / 1440)} more days` : minutes >= 60 ? ` for about ${Math.round(minutes / 60)} more hours` : ` for about ${minutes} more minutes`;
}
/** Why `anywhere` must not pick a computer for quota: only an account with none left (at 100% or refusing requests).
* Low quota is not a reason; the owner often wants it used before it resets. A Claude dispatch that names no
* account runs under whichever has room there, so with several accounts that computer is out only when all are. */
export function outOfQuota(data, usage, now = Date.now()) {
const claude = usage?.filter(item => item.source === "claude") ?? [];
if (data.harness === "claude" && !data.account && claude.length > 1) {
if (claude.some(item => !item.exhausted))
return undefined;
return `Its claude accounts all have no quota left${backIn(claude.map(item => item.until).filter((value) => Boolean(value)).sort()[0], now)}.`;
}
const room = roomFor(data, usage);
if (!room?.exhausted)
return undefined;
return `Its ${data.harness}${data.harness === "claude" ? ` account ${data.account ?? "default"}` : ""} has no quota left${backIn(room.until, now)}.`;
}
/** A computer's signed-in Claude accounts with their room, from its capacity probe. */
export function capacityClaudeRooms(harnesses, usage) {
const accounts = harnesses?.find(entry => entry.source === "claude")?.accounts ?? [];
return accounts.map(account => {
const room = usage?.find(item => item.source === "claude" && (item.account ?? "default") === account.id);
return { account: account.id, ...(account.key ? { key: account.key } : {}), usable: account.usable,
...(room?.fiveHour ? { fiveHour: room.fiveHour } : {}), ...(room?.week ? { week: room.week } : {}),
...(room?.exhausted ? { exhausted: true, ...(room.until ? { until: room.until } : {}) } : {}) };
});
}
/** Why a computer cannot run this dispatch's harness and account, or undefined when it can or cannot yet be told.
* `unknown` (no `harnesses`) only counts against a computer when a non-default account was asked for. */
function unusable(data, harnesses, strictUnknown) {
if (!harnesses)
return strictUnknown && data.account && data.account !== "default" ? "Its Hook does not report harnesses or accounts (update it)." : undefined;
// No account named: any signed-in Claude account there will do (the launch picks one).
if (data.harness === "claude" && !data.account) {
const usable = harnesses.find(entry => entry.source === "claude")?.accounts?.find(account => account.usable);
if (usable)
return unusable({ harness: "claude", account: usable.id }, harnesses, strictUnknown);
}
const availability = hasUsable({ harnesses }, data.harness, data.account);
return availability.ok ? undefined : availability.reason;
}
/**
* A freshly started agent may not have written its session yet when the launch
* returns, so the launch carries no target. Ask the remote pane for a few
* seconds; the pane ids from the launch are enough to find it. Until its first
* prompt a new Codex or Claude has no conversation at all, only a starting
* binding, and that is the target its brief goes to (as the phone's first
* message does). `status` is the pane's last status when neither appeared.
*/
async function settledTarget(peer, launched, harness, intervalMs = 1_000) {
const workspace = launched.workspaceId, tab = launched.tabId, pane = launched.paneId;
if (typeof workspace !== "string" || typeof tab !== "string" || typeof pane !== "string")
return {};
let status;
for (let attempt = 0; attempt < 15; attempt++) {
await new Promise(resolve => setTimeout(resolve, intervalMs));
try {
const result = await peer.request(`/v1/workspaces/panes?server=${encodeURIComponent(peer.server)}&groupId=${encodeURIComponent(workspace)}&childId=${encodeURIComponent(tab)}`);
const found = (Array.isArray(result.panes) ? result.panes : []).find((p) => p && typeof p === "object" && p.id === pane);
if (typeof found?.agentStatus === "string")
status = found.agentStatus;
const binding = { server: peer.server, workspace, tab, pane, source: harness };
const session = found?.sessionId;
if (typeof session === "string" && session)
return { target: { ...binding, session } };
if (found?.starting === true && typeof found.startingToken === "string")
return { target: { ...binding, starting: true, startingToken: found.startingToken } };
}
catch { /* The pane list can lag the launch; try again. */ }
}
return { status };
}
/** The new agent is holding a screen of its own before it takes any prompt:
* Claude's folder trust or a sign-in. Only the owner answers those. */
class StartupScreen extends Error {
status;
constructor(status) {
super(`The agent is ${status} on a startup screen.`);
this.status = status;
}
}
/**
* Sends the brief. A fresh agent can read `unknown` to Herdr for a while (long
* on a loaded machine), and the Hook refuses a prompt to such a pane before
* typing anything. So that refusal is retried while the pane is still
* unclassified; a pane that is blocked or waiting really needs its terminal,
* and that refusal stands.
*/
async function sendBrief(peer, target, text, deliveryId) {
for (let attempt = 0;; attempt++) {
// One delivery id for every attempt: the receiving Hook types it once.
try {
return await peer.request("/v1/prompt", { target, text, deliveryId });
}
catch (error) {
const unsettled = error instanceof BridgeError && error.status === 409 && /needs input in the terminal first/.test(error.message);
if (!unsettled || attempt >= 20)
throw error;
const panes = await peer.request(`/v1/workspaces/panes?server=${encodeURIComponent(peer.server)}&groupId=${encodeURIComponent(String(target.workspace))}&childId=${encodeURIComponent(String(target.tab))}`).catch(() => undefined);
const pane = (Array.isArray(panes?.panes) ? panes.panes : []).find((p) => p && typeof p === "object" && p.id === target.pane);
const status = pane?.agentStatus;
// Nothing was typed: the refusal came before any text reached the pane.
if (typeof status === "string" && ["blocked", "waiting"].includes(status))
throw new StartupScreen(status);
await new Promise(resolve => setTimeout(resolve, 1_000));
}
}
}
/** The pane as the remote Hook lists it now, or undefined when it does not list it. */
async function listedPane(peer, place) {
const panes = await peer.request(`/v1/workspaces/panes?server=${encodeURIComponent(peer.server)}&groupId=${encodeURIComponent(String(place.workspace))}&childId=${encodeURIComponent(String(place.tab))}`).catch(() => undefined);
return (Array.isArray(panes?.panes) ? panes.panes : []).find((p) => p && typeof p === "object" && p.id === place.pane);
}
/** Tries of a brief typed into a starting pane before the dispatch fails. */
const BRIEF_TRIES = 2;
/** Looks, one settle interval apart, for a starting pane to read ready, and for a typed brief to show. */
const READY_LOOKS = 15;
const LANDED_LOOKS = 20;
/**
* A starting agent's terminal takes typed text only once its prompt is drawn,
* which Herdr reads as idle: text typed while it is still `unknown` can be
* swallowed with nothing to show for it (Mini w4E, 2026-09-30). Waits for that,
* at most READY_LOOKS. A startup screen is the owner's to answer; a pane the
* Hook does not list is not waited on.
*/
async function readyForBrief(peer, target, intervalMs) {
for (let look = 0; look < READY_LOOKS; look++) {
const pane = await listedPane(peer, target);
const status = pane?.agentStatus;
if (!pane || ["idle", "done", "working"].includes(String(status)))
return;
if (status === "blocked" || status === "waiting")
throw new StartupScreen(String(status));
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
}
/**
* Before a brief is typed again. The first try can reach the agent after its
* landed window (a loaded machine starts the turn late), and a second copy
* would hand the worker its task twice, so the pane is read first. It is typed
* again only into a pane that still sits idle on the same starting binding with
* no conversation. A conversation, or the agent working, blocked, waiting or
* done, means it took the first brief; a pane the Hook does not list, on
* another binding or never classified, cannot be told, and nothing is typed.
*/
async function readyForRetry(peer, target, intervalMs) {
for (let look = 0; look < READY_LOOKS; look++) {
if (look)
await new Promise(resolve => setTimeout(resolve, intervalMs));
const pane = await listedPane(peer, target);
if (!pane)
return { state: "unknown" };
if (typeof pane.sessionId === "string" && pane.sessionId)
return { state: "arrived", session: pane.sessionId };
if (pane.starting === true && pane.startingToken !== target.startingToken)
return { state: "unknown" };
const status = String(pane.agentStatus);
if (status === "idle")
return { state: "ready" };
if (["working", "blocked", "waiting", "done"].includes(status))
return { state: "arrived" };
}
return { state: "unknown" };
}
/**
* Whether a brief typed into a starting pane reached its agent: a conversation
* appeared, or the agent went to work. Lost when the pane still sits idle on
* the same starting binding after LANDED_LOOKS; unknown when the Hook does not
* list the pane, or it no longer holds that binding.
*/
async function briefLanded(peer, target, intervalMs) {
for (let look = 0; look < LANDED_LOOKS; look++) {
await new Promise(resolve => setTimeout(resolve, intervalMs));
const pane = await listedPane(peer, target);
if (!pane)
return { state: "unknown" };
if (typeof pane.sessionId === "string" && pane.sessionId)
return { state: "arrived", session: pane.sessionId };
if (pane.starting === true && pane.startingToken !== target.startingToken)
return { state: "unknown" };
if (["working", "blocked", "waiting"].includes(String(pane.agentStatus)))
return { state: "arrived" };
}
return { state: "lost" };
}
/** What the worker's own hooks reported for a brief that went with its
* launch, or undefined when the receiving Hook has no such brief. */
export async function arrivalOf(peer, id) {
const answer = await peer.request(`/v1/dispatch/arrival?id=${encodeURIComponent(id)}`);
const arrival = answer.arrival === null ? undefined : arrivalSchema.parse(answer.arrival);
return arrival;
}
/**
* A brief that went with the launch is the new agent's first prompt; its
* UserPromptSubmit hook echoes the dispatch id when the harness submits it.
* Waits for that echo, and returns the conversation it named.
*/
async function awaitArrival(peer, id, intervalMs) {
let last = {};
for (let attempt = 0; attempt < ARRIVAL_ATTEMPTS; attempt++) {
await new Promise(resolve => setTimeout(resolve, intervalMs));
last = await arrivalOf(peer, id).catch(() => undefined) ?? last;
if (last.accepted)
break;
}
return last;
}
/** Claude and Codex take a few seconds to start, load their MCP servers and submit the prompt. */
const ARRIVAL_ATTEMPTS = 30;
/** The pane's status as the remote Hook lists it now. */
async function paneStatus(peer, launched) {
const panes = await peer.request(`/v1/workspaces/panes?server=${encodeURIComponent(peer.server)}&groupId=${encodeURIComponent(String(launched.workspaceId))}&childId=${encodeURIComponent(String(launched.tabId))}`).catch(() => undefined);
const pane = (Array.isArray(panes?.panes) ? panes.panes : []).find((p) => p && typeof p === "object" && p.id === launched.paneId);
return typeof pane?.agentStatus === "string" ? pane.agentStatus : undefined;
}
export class DispatchService {
identity;
local;
settleIntervalMs;
/** One placement at a time: choosing a computer, saving the receipt and the
* launch request. Confirming the brief afterwards (up to a minute and a half
* for a slow starting pane) runs outside it, so other dispatches are not
* refused meanwhile; `placing` counts those launches toward their
* computer's load for the next `anywhere` choice. */
active = false;
placing = new Map();
/** `local` is this computer as a dispatch destination (its own Hook's
* socket); tests replace it so they never reach a real Hook. */
constructor(identity, local = () => localHost(), settleIntervalMs = 1_000) {
this.identity = identity;
this.local = local;
this.settleIntervalMs = settleIntervalMs;
}
/** `originValue` is the local pane the request came from, as its agent's
* Herdr variables name it; a pane without a running agent is left out.
* `continues` names the dispatch whose worker hit its usage limit (account-failover.ts). */
async dispatch(input, originValue, continues) {
const data = dispatchSchema.parse(input);
if (data.permissionMode && data.harness === "opencode")
throw new BridgeError(400, "OpenCode takes its permissions from its own config; permissionMode is for Claude, Codex and Copilot workers.");
if (this.active)
throw new BridgeError(429, "A dispatch is already being placed. Try again after its receipt arrives.");
this.active = true;
const originWindow = trackDispatchOrigin();
let holding = true, placed;
const release = () => { if (holding) {
holding = false;
this.active = false;
} };
try {
if (data.parent !== undefined || data.parentTarget !== undefined) {
if (!this.identity)
throw new BridgeError(503, "This Hook cannot validate a dispatch parent.");
await validateDispatchParent(data, this.identity.computerID, this.identity.validateParentTarget);
}
if ((await receiptNames(path.join(bridgeRoot(), "dispatches"))).length >= 1024)
throw new BridgeError(429, "Dispatch history is full. Archive old receipts before dispatching again.");
// This computer needs no hooks.yaml entry, so a missing file only
// matters when the dispatch names another computer.
const here = this.local();
// An alias or hostname (`Mac`, `Sams-Mac-mini.local`) names the same computer as its hooks.yaml name.
const named = data.computer === "anywhere" || isLocalComputer(data.computer, here.names) ? undefined : await linkedComputer(data.computer).catch(() => undefined);
const toLocal = data.computer !== "anywhere" && (isLocalComputer(data.computer, here.names) || (named !== undefined && "local" in named));
const peerName = named && "peer" in named ? named.peer : data.computer;
const enrolled = await hookPeers().catch(error => { if (toLocal || data.computer === "anywhere")
return []; throw error; });
// A conductor dispatches only within its set: each peer says whether it links this computer back.
const caller = await this.conductorCaller(originValue);
const peers = [...enrolled.map(candidate => peerHost(candidate)), here];
let peer;
let remoteComputerID;
const skipped = [];
let incapable = false, spentSeen = false;
if (data.computer === "anywhere") {
const available = await Promise.all(peers.map(async (candidate) => {
try {
return { peer: candidate, ...await capacity(candidate, caller) };
}
catch (error) {
// A peer that cannot report capacity sits out this placement, and the receipt says why.
skipped.push({ computer: candidate.name, reason: failureReason(error) });
return undefined;
}
}));
skipped.sort((a, b) => a.computer.localeCompare(b.computer));
const capable = available.filter((item) => !!item).filter(item => {
if (item.outside) {
skipped.push({ computer: item.peer.name, reason: oneWayHint(item.peer.name) });
return false;
}
const reason = unusable(data, item.harnesses, true);
if (reason)
incapable = true;
if (reason)
skipped.push({ computer: item.peer.name, reason: reason.slice(0, 200) });
if (reason)
return false;
// The one quota rule: never place a worker on an account with none left. Low quota still counts as room.
const spent = outOfQuota(data, item.usage);
if (spent) {
spentSeen = true;
skipped.push({ computer: item.peer.name, reason: spent });
}
return !spent;
});
skipped.sort((a, b) => a.computer.localeCompare(b.computer));
const selected = capable
.sort((a, b) => a.working + this.inFlight(a.peer.name) - b.working - this.inFlight(b.peer.name) || a.peer.name.localeCompare(b.peer.name))[0];
peer = selected?.peer;
remoteComputerID = selected?.computerId;
if (!peer && spentSeen)
throw new BridgeError(503, `No connected computer has ${data.harness}${data.harness === "claude" ? ` account ${data.account ?? "default"}` : ""} quota left right now.`, { code: "out_of_quota", skipped });
if (!peer)
throw new BridgeError(503, incapable ? `No enrolled computer with a running Herdr can run ${data.harness}${data.account ? ` account ${data.account}` : ""}.` : "No enrolled computer with a running Herdr is connected.", skipped.length ? { skipped } : undefined);
}
else {
peer = toLocal ? peers.find(candidate => candidate.local) : peers.find(candidate => !candidate.local && candidate.name === peerName);
if (!peer)
throw new BridgeError(404, "Unknown computer. Add its verified connection to hooks.yaml.");
const reported = await capacity(peer, caller);
if (reported.outside)
throw new BridgeError(409, `${peer.name} is not in this conductor's set. ${oneWayHint(peer.name)}`, { code: "outside_set" });
remoteComputerID = reported.computerId;
// A Hook that reports what it can run is believed. An older one that does not is left to answer the launch
// itself, except for a non-default account: it would ignore `account` and launch under its default login.
const reason = unusable(data, reported.harnesses, true);
if (reason)
throw new BridgeError(409, `${peer.name} cannot run ${data.harness}${data.account ? ` account ${data.account}` : ""}: ${reason}`);
}
const grant = await findGrant({ action: "dispatch", project: data.project, computer: peer.name });
// An agent (a call that names its pane) may start a worker only up to its grant's
// permission ceiling; the owner, calling from the phone or the CLI without a pane, is not capped.
const agent = originPaneSchema.safeParse(originValue).success;
if (data.permissionMode && agent && !permissionModeAllowed(data.permissionMode, grant)) {
throw new BridgeError(403, `No standing grant lets an agent start a worker in ${data.permissionMode} on ${peer.name}. Add maxPermissionMode: ${data.permissionMode} to a grant in conductor.yaml, or dispatch it yourself.`);
}
// The owner's release authority policy binds an agent's dispatch (and may
// lower its mode); the owner's own dispatch only carries the policy's line.
const checked = agent ? await checkAgentDispatch(data, grant?.maxPermissionMode ?? DEFAULT_MAX_PERMISSION_MODE) : undefined;
if (checked?.permissionMode)
data.permissionMode = checked.permissionMode;
const authority = checked?.authority ?? await readAuthority().then(policy => projectAuthority(policy, data.project)).catch(() => undefined);
const origin = await this.origin(originValue);
// Prompts are sent over the pipe, never stored in the dispatch ledger.
const { prompt: originalPrompt, ...metadata } = data;
const prompt = peer.local ? originalPrompt : redactPeerHandoff(originalPrompt);
const receipt = { ...metadata, computer: peer.name, computerId: remoteComputerID, id: randomUUID(),
createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), state: "launching",
...(grant ? { granted: grantLabel(grant) } : {}), ...(authority?.listed ? { authority: authority.line.slice(0, 600) } : {}),
...(checked?.confirmation ? { authorityConfirmed: checked.confirmation.confirmedAt } : {}),
...(skipped.length ? { skipped } : {}), ...(origin ? { origin } : {}), ...(continues ? { continues } : {}) };
await save(receipt, originWindow);
try {
// The brief goes with the launch: a Hook that can start the harness
// with it says so, and any other types it below.
const launched = await peer.request(`/v1/workspaces/launch?server=${encodeURIComponent(peer.server)}`, { project: data.project, kind: data.harness, model: data.model, ...(data.effort ? { effort: data.effort } : {}), ...(data.account ? { account: data.account } : {}), ...(data.permissionMode ? { permissionMode: data.permissionMode } : {}), label: peer.local ? data.label : redactPeerHandoff(data.label), brief: { id: receipt.id, text: prompt } });
// Placed: the brief is confirmed outside the lock.
placed = peer.name;
this.placing.set(placed, this.inFlight(placed) + 1);
// The receiving Hook chose the Claude account (the one with the most room) when none was named.
if (!data.account && typeof launched.account === "string" && isAccountSlug(launched.account)) {
receipt.account = launched.account;
if (typeof launched.accountChoice === "string")
receipt.accountChoice = launched.accountChoice.slice(0, 400);
logger.info("dispatch", `${receipt.label} on ${peer.name} runs under Claude account ${launched.account}${receipt.accountChoice ? `: ${receipt.accountChoice}` : ""}.`);
}
release();
// An older Hook ignores the field and starts the worker in its own default mode.
const ignored = data.permissionMode && launched.permissionMode !== data.permissionMode ? IGNORED_MODE : undefined;
if (launched.briefLaunched === true) {
receipt.brief = "launch";
await this.confirmLaunched(peer, receipt, launched, originWindow);
if (ignored)
receipt.error = [receipt.error, ignored].filter(Boolean).join(" ").slice(0, 500);
receipt.updatedAt = new Date().toISOString();
await save(receipt, originWindow);
return { ok: receipt.state === "accepted", ...receipt };
}
receipt.brief = "typed";
const settled = launched.target ? { target: launched.target } : await settledTarget(peer, launched, data.harness, this.settleIntervalMs);
if (!settled.target) {
if (["blocked", "waiting"].includes(String(settled.status ?? launched.agentStatus)))
throw new StartupScreen(String(settled.status ?? launched.agentStatus));
throw new BridgeError(504, `${data.harness} started in the new "${data.label}" pane on ${peer.name} but never showed a conversation or a starting pane (last status ${settled.status ?? "unknown"}), so the brief was not sent. The pane is still open.`);
}
const target = remoteTarget.parse(settled.target);
if (target.source !== data.harness || target.server !== peer.server)
throw new BridgeError(502, "The remote Hook returned a different launch target.");
receipt.target = target;
receipt.state = "sending";
receipt.updatedAt = new Date().toISOString();
await save(receipt, originWindow);
if (!("starting" in target)) {
const result = await sendBrief(peer, target, prompt, `dispatch-${receipt.id}`);
receipt.state = result.ok === true && result.deliveryUncertain !== true ? "accepted" : "uncertain";
}
else
await this.typeIntoStarting(peer, receipt, target, prompt);
if (ignored)
receipt.error = [receipt.error, ignored].filter(Boolean).join(" ").slice(0, 500);
}
catch (error) {
if (error instanceof StartupScreen) {
// Known, not uncertain: the brief never reached the pane. Say where
// the owner answers, and let the returns loop tell the conductor once.
const question = `${data.harness} in "${data.label}" on ${peer.name} is waiting on a startup screen (folder trust or sign-in).`.slice(0, 200);
receipt.state = "failed";
receipt.error = `${question} The brief was not sent. Answer that screen in the pane (the phone can), then hand the brief off to the new session.`.slice(0, 500);
receipt.returned = { state: "needs-you", at: new Date().toISOString(), question, read: false };
}
else {
receipt.state = receipt.state === "launching" && error instanceof BridgeError && [400, 404, 429, 504].includes(error.status) ? "failed" : "uncertain";
receipt.error = error instanceof BridgeError ? error.message.slice(0, 500) : "Remote delivery was not confirmed. Inspect this dispatch before retrying.";
}
}
receipt.updatedAt = new Date().toISOString();
await save(receipt, originWindow);
return { ok: receipt.state === "accepted", ...receipt };
}
finally {
originWindow.finish();
release();
if (placed) {
const left = this.inFlight(placed) - 1;
if (left > 0)
this.placing.set(placed, left);
else
this.placing.delete(placed);
}
}
}
/** Owner continuation: use the same pinned transport and launch/arrival
* protocol as dispatch, without turning the new chat into a worker. */
async continueChat(peer, project, to, brief, label, placed, computerId, worker = false, workerDispatch) {
if (to.harness === "copilot")
throw new BridgeError(409, PEER_COPILOT_REASON);
const { computer: _computer, ...destination } = to;
const readPlacement = async () => {
const saved = await peer.request(`/v1/sessions/move/launch?id=${encodeURIComponent(brief.id)}`, undefined, 5_000).catch(() => undefined);
if (saved?.paneTarget)
await placed?.({ paneTarget: saved.paneTarget });
if (object(saved?.result).target)
await placed?.(object(saved?.result).target);
};
let complete = false;
const pendingLaunch = peer.request(`/v1/sessions/move/launch?server=${encodeURIComponent(peer.server)}`, { computerId, project, to: destination, label, brief, worker, ...(workerDispatch ? { workerDispatch } : {}) }, 65_000).then(result => { complete = true; return { result }; }, error => { complete = true; return { error }; });
// Track a created pane while its harness is still starting. Every probe
// is bounded, and completion stops the monitor before any later move.
const monitor = (async () => {
while (!complete) {
await new Promise(resolve => setTimeout(resolve, this.settleIntervalMs));
if (!complete)
await readPlacement();
}
})().catch(() => undefined);
const outcome = await pendingLaunch;
await monitor;
if ("error" in outcome) {
await readPlacement();
throw outcome.error;
}
const launched = outcome.result;
if (launched.target)
await placed?.(launched.target);
const settled = launched.target ? { target: launched.target } : await settledTarget(peer, launched, to.harness, this.settleIntervalMs);
const target = remoteTarget.parse(settled.target);
await placed?.(target);
if (target.server !== peer.server || target.source !== to.harness)
throw new BridgeError(502, "The peer returned a different launch target.");
if (launched.briefLaunched !== true) {
if (!("session" in target))
throw new BridgeError(409, "The peer chat is not ready to receive its hand-off; the old chat remains open.");
const sent = await sendBrief(peer, target, brief.text, `move-${brief.id}-brief`);
if (sent.ok !== true || sent.deliveryUncertain === true || sent.unsubmitted === true || (sent.delivered !== true && sent.queued !== true)) {
throw new BridgeError(409, "The peer did not confirm the hand-off; both chats remain open.");
}
}
const arrival = await awaitArrival(peer, brief.id, this.settleIntervalMs);
const confirmed = targetSchema.parse(arrival.accepted?.target);
if (confirmed.server !== peer.server || confirmed.source !== to.harness || confirmed.pane !== target.pane
|| confirmed.workspace !== target.workspace || confirmed.tab !== target.tab
|| ("session" in target && confirmed.session !== target.session))
throw new BridgeError(409, "The peer did not confirm the launched conversation.");
const listed = await listedPane(peer, confirmed);
if (!listed || !["idle", "working"].includes(String(listed.agentStatus)))
throw new BridgeError(409, "The peer chat is not confirmed running; the old chat remains open.");
return confirmed;
}
/** Launches placed on `computer` whose brief is still being confirmed. */
inFlight(computer) { return this.placing.get(computer) ?? 0; }
/**
* A brief typed into a pane that has no conversation yet: typed once the
* pane reads ready, then confirmed by the conversation it starts. A brief
* the pane swallowed (still idle on its starting binding) is typed again,
* under its own delivery id, but only after `readyForRetry` finds the pane
* still idle there: a first brief that landed late is never typed twice.
* After BRIEF_TRIES the receipt carries a failed return yet stays watched
* (uncertain), so a brief that lands later still brings the worker's return.
* A Claude prompt left typed but unsubmitted is not typed again.
*/
async typeIntoStarting(peer, receipt, target, prompt) {
const arrived = (session) => {
const full = session ? targetSchema.safeParse({ server: target.server, workspace: target.workspace, tab: target.tab, pane: target.pane, source: target.source, session }) : undefined;
if (full?.success)
receipt.target = full.data;
receipt.state = "accepted";
};
for (let attempt = 1;; attempt++) {
if (attempt === 1)
await readyForBrief(peer, target, this.settleIntervalMs);
else {
const before = await readyForRetry(peer, target, this.settleIntervalMs);
if (before.state === "arrived") {
arrived(before.session);
return;
}
if (before.state === "unknown") {
receipt.state = "uncertain";
receipt.error = `The brief typed into the new "${receipt.label}" pane on ${receipt.computer} did not show in time, and the pane can no longer be read as waiting for it, so it was not typed again. The Hook keeps watching the pane.`.slice(0, 500);
return;
}
}
const result = await sendBrief(peer, target, prompt, attempt === 1 ? `dispatch-${receipt.id}` : `dispatch-${receipt.id}-${attempt}`);
const sent = result.ok === true && result.deliveryUncertain !== true;
const landed = await briefLanded(peer, target, this.settleIntervalMs);
if (landed.state === "arrived") {
arrived(landed.session);
return;
}
if (landed.state === "unknown" || result.unsubmitted === true) {
receipt.state = sent ? "accepted" : "uncertain";
return;
}
logger.info("dispatch", `The brief for ${receipt.computer} ${receipt.label} did not reach the starting pane (try ${attempt} of ${BRIEF_TRIES}).`);
if (attempt < BRIEF_TRIES)
continue;
const error = `The brief typed into the new "${receipt.label}" pane on ${receipt.computer} never reached ${receipt.harness}: after ${BRIEF_TRIES} tries the pane still sits idle with no conversation. The pane is still open; hand the brief off to it or close it.`.slice(0, 500);
// Watched still: a brief that lands after all brings the worker's own return.
receipt.state = "uncertain";
receipt.error = error;
receipt.returned = { state: "failed", at: new Date().toISOString(), error, read: false };
return;
}
}
/**
* A brief that went with the launch: wait for the worker's hook to confirm
* it by dispatch id. A startup screen (folder trust, sign-in) holds the
* prompt until the owner answers it, and the harness then submits it by
* itself, so that is not a failure. Neither confirmed nor held reads
* uncertain, and the returns loop keeps asking.
*/
async confirmLaunched(peer, receipt, launched, originWindow) {
const known = remoteTarget.safeParse(launched.target);
if (known.success && known.data.source === receipt.harness && known.data.server === peer.server)
receipt.target = known.data;
receipt.state = "sending";
receipt.updatedAt = new Date().toISOString();
await save(receipt, originWindow);
const held = (status) => ["blocked", "waiting"].includes(String(status));
const arrival = held(launched.agentStatus) ? await arrivalOf(peer, receipt.id).catch(() => undefined) ?? {}
: await awaitArrival(peer, receipt.id, this.settleIntervalMs);
const named = arrival.accepted?.target ?? arrival.started?.target;
if (named && named.source === receipt.harness && named.server === peer.server)
receipt.target = named;
if (arrival.accepted) {
receipt.state = "accepted";
return;
}
const status = held(launched.agentStatus) ? String(launched.agentStatus) : await paneStatus(peer, launched);
receipt.state = "uncertain";
if (held(status)) {
const question = `${receipt.harness} in "${receipt.label}" on ${receipt.computer} is waiting on a startup screen (folder trust or sign-in).`.slice(0, 200);
receipt.error = `${question} The brief is queued as its first prompt and starts once that screen is answered in the pane (the phone can).`.slice(0, 500);
receipt.returned = { state: "needs-you", at: new Date().toISOString(), question, read: false };
return;
}
receipt.error = `${receipt.harness} started with the brief on ${receipt.computer} but has not confirmed it yet (last status ${status ?? "unknown"}). The Hook keeps checking.`.slice(0, 500);
}
/** Every connected computer's signed-in Claude accounts and their room, this computer first:
* where a worker that hit its usage limit can continue (account-failover.ts). */
async claudeRooms() {
const here = this.local();
const hosts = [here, ...(await hookPeers().catch(() => [])).map(candidate => peerHost(candidate))];
const found = await Promise.all(hosts.map(async (host) => {
try {
const reported = await capacity(host);
return { computer: host.name, local: host.local, rooms: capacityClaudeRooms(reported.harnesses, reported.usage) };
}
catch {
return undefined;
}
}));
return found.filter((item) => !!item);
}
/** The query a conductor's dispatch sends with each capacity probe; undefined for any other caller. */
async conductorCaller(value) {
const pane = originPaneSchema.safeParse(value);
if (!pane.success)
return undefined;
let conductor = await recordedConductor().catch(() => undefined);
const delegated = (await readRoleState())?.succession;
if (!conductor && delegated?.side === "origin" && delegated.phase === "committed")
conductor = delegated.previous;
if (!conductor || conductor.server !== pane.data.server || conductor.pane !== pane.data.pane)
return undefined;
return callerQuery(await localCaller());
}
async origin(value) {
const pane = originPaneSchema.safeParse(value);
if (!pane.success || !this.identity?.originAgent)
return undefined;
const running = await this.identity.originAgent(pane.data).catch(() => undefined);
return running ? { ...pane.data, ...running } : undefined;
}
}