UNPKG

@phren/cli

Version:

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

96 lines (95 loc) • 6.04 kB
import { createHash } from "node:crypto"; import { lstat, readFile, rm } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import { atomicInPrivateDir, BridgeError, bridgeRoot, targetSchema, startingTargetSchema } from "./protocol.js"; import { validateTarget, paneChatState } from "./herdr.js"; import { terminalProvider } from "./terminal.js"; import { paneTurn, workerStates, workerTurnKey } from "./dispatch-returns.js"; import { originPaneSchema } from "./dispatch.js"; const closedTargetSchema = z.union([targetSchema, startingTargetSchema]); const file = (target) => path.join(bridgeRoot(), "closed-workers", `${createHash("sha256").update(JSON.stringify([target.server, target.workspace, target.tab, target.pane, target.source, "session" in target ? target.session : target.startingToken])).digest("hex")}.json`); export async function markWorkerClosed(target, terminal) { await atomicInPrivateDir(file(target), { target, terminal, at: new Date().toISOString() }); } export async function intentionallyClosed(target) { try { const info = await lstat(file(target)); if (!info.isFile() || info.isSymbolicLink() || info.size > 4096) return false; const row = JSON.parse(await readFile(file(target), "utf8")); return closedTargetSchema.safeParse(row.target).success; } catch (error) { if (error.code !== "ENOENT") throw error; return false; } } export async function markPaneClosed(server, pane) { const chat = await paneChatState(server, pane); const target = closedTargetSchema.safeParse({ server, workspace: pane.workspace_id, tab: pane.tab_id, pane: pane.pane_id, source: pane.agent, ...(chat.sessionId ? { session: chat.sessionId } : { starting: true, startingToken: chat.startingToken }) }); if (target.success) await markWorkerClosed(target.data, String(pane.terminal_id)); // A dispatch may still hold the launch token before its first identity poll. const starting = startingTargetSchema.safeParse({ server, workspace: pane.workspace_id, tab: pane.tab_id, pane: pane.pane_id, source: pane.agent, starting: true, startingToken: chat.startingToken }); if (starting.success) await markWorkerClosed(starting.data, String(pane.terminal_id)); } /** Called on the worker's computer only after the sender read the done return. * Recheck this exact ended turn and skip any new work, background work or inbox. */ export async function closeFinishedWorker(input, queue) { const data = z.object({ target: targetSchema, turn: z.string().regex(/^[a-f0-9]{16}$/), dispatch: z.string().uuid() }).strict().parse(input); if (queue) return queue.whenNoPending(data.target, () => closeFinishedWorker(input)); const { snapshot, findPane } = await import("./herdr.js"); if (await intentionallyClosed(data.target) && !findPane(await snapshot(data.target.server), data.target)) return { ok: true, closed: true, replayed: true }; const pane = await validateTarget(data.target, false, true); if (!["idle", "done"].includes(String(pane.agent_status))) return { ok: true, closed: false }; const seen = (await workerStates({ targets: [{ ...data.target, dispatch: data.dispatch }] })).workers[0]; const turn = workerTurnKey(seen); // A turn still working, one that stopped mid-task or with background work // running, or whose checkout could not be read, keeps its pane. if ((seen.state !== "done" && seen.state !== "idle") || !seen.completed || seen.background || seen.error || seen.unfinished || seen.unchecked || turn !== data.turn) return { ok: true, closed: false }; // One final fresh binding before the terminal mutation. const current = await validateTarget(data.target, false, true); if (current.terminal_id !== pane.terminal_id || !["idle", "done"].includes(String(current.agent_status))) throw new BridgeError(409, "The worker resumed before it could close."); const record = await paneTurn(data.target.server, current, data.target.source); if (record?.prompt && (!record.stop || record.stop.seq <= record.prompt.seq || record.terminal !== current.terminal_id || record.session !== data.target.session || workerTurnKey({ endedAt: record.stop.at, stopSeq: record.stop.seq }) !== data.turn)) return { ok: true, closed: false }; await markWorkerClosed(data.target, String(pane.terminal_id)); try { await terminalProvider().closePane(data.target.server, data.target.pane); } catch (error) { // A refused close must not suppress a later genuine disappearance. Keep // the marker on an ambiguous transport failure if the pane is now gone. const remaining = await snapshot(data.target.server).catch(() => undefined); if (remaining && findPane(remaining, data.target)?.terminal_id === pane.terminal_id) await rm(file(data.target), { force: true }); throw error; } return { ok: true, closed: true }; } /** An agent closing the pane it runs in, once its work is committed and reported: * the sanctioned form of `herdr pane close`, which an agent's auto mode refuses. * Only the caller's own pane (named by its terminal variables) is ever closed, and * after the reply, so the agent's `phren` call still returns. */ export async function closeOwnPane(input, delayMs = 500) { const data = z.object({ origin: originPaneSchema }).strict().parse(input); const { snapshot, findPane } = await import("./herdr.js"); const pane = findPane(await snapshot(data.origin.server), data.origin); if (!pane) return { ok: true, closed: false, reason: "The pane is already gone." }; await markPaneClosed(data.origin.server, pane); const timer = setTimeout(() => { void terminalProvider().closePane(data.origin.server, data.origin.pane).catch(() => undefined); }, delayMs); timer.unref(); return { ok: true, closed: true, pane: data.origin.pane }; }