UNPKG

@phren/cli

Version:

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

85 lines (84 loc) • 4.96 kB
import { createHash } from "node:crypto"; import { lstat, readFile } from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; import { atomicInPrivateDir, BridgeError, bridgeRoot, targetSchema } from "./protocol.js"; import { paneIdentity, findPane, snapshot, validateTarget } from "./herdr.js"; import { originPaneSchema } from "./dispatch.js"; import { paneTurn } from "./dispatch-returns.js"; import { briefArrival, briefId } from "./launch-brief.js"; import { integratorSchema, prsSchema, reportText as text } from "./return-contract.js"; export { integratorSchema, prsSchema, pullRequestSchema } from "./return-contract.js"; // `promptSeq` is absent for a report bound by its dispatch instead of a recorded prompt (`reportWorker`). const reportSchema = z.object({ target: targetSchema, terminal: text(200), promptSeq: z.number().int().optional(), prs: prsSchema, at: z.string().datetime() }).strict(); const key = (target) => createHash("sha256").update(JSON.stringify([target.server, target.workspace, target.tab, target.pane, target.source, target.session])).digest("hex"); const reportFile = (target) => path.join(bridgeRoot(), "worker-reports", `${key(target)}.json`); /** The worker reports PR evidence to its own Hook, without direct messaging or * GitHub comments. Bound to this terminal and submitted turn, not the next one. * * The pane's turn record can miss that turn: a nested agent run in the * worker's own pane (`claude -p` from its shell) inherits the pane's * variables, and its hooks replace the record with its own conversation. A * worker whose dispatch this Hook saw arrive in this pane and conversation * still reports; the evidence binds to its current turn, the one the next * Stop ends, and never to a prompt submitted after the report. */ export async function reportWorker(input) { const data = z.object({ origin: originPaneSchema, prs: prsSchema, dispatch: briefId.optional() }).strict().parse(input); const pane = findPane(await snapshot(data.origin.server), data.origin); if (!pane) throw new BridgeError(409, "The worker pane changed."); const session = await paneIdentity(data.origin.server, pane, true); const target = targetSchema.parse({ ...data.origin, source: pane.agent, session }); await validateTarget(target, false, true); const record = await paneTurn(target.server, pane, target.source); const promptSeq = record?.prompt && record.session === session && record.terminal === pane.terminal_id ? record.prompt.seq : undefined; if (promptSeq === undefined && !(typeof pane.terminal_id === "string" && await arrivedHere(data.dispatch ?? (record && record.session === session ? record.dispatch : undefined), target))) throw new BridgeError(409, "The Hook has not recorded this worker's submitted turn yet, and no dispatch it launched arrived in this pane."); await atomicInPrivateDir(reportFile(target), reportSchema.parse({ target, terminal: pane.terminal_id, ...(promptSeq !== undefined ? { promptSeq } : {}), prs: data.prs, at: new Date().toISOString() })); return { ok: true, target, prs: data.prs }; } /** Whether this Hook launched dispatch `id` and its brief arrived in exactly this pane and conversation. */ async function arrivedHere(id, target) { if (!id) return false; const arrival = await briefArrival(id); return [arrival?.accepted, arrival?.started].some(event => event && key(event.target) === key(target)); } export async function workerPrs(target, record) { try { const file = reportFile(target), info = await lstat(file); if (!info.isFile() || info.isSymbolicLink() || info.size > 131072) return undefined; const report = reportSchema.parse(JSON.parse(await readFile(file, "utf8"))); if (report.terminal !== record.terminal) return undefined; if (report.promptSeq !== undefined) return report.promptSeq === record.prompt?.seq ? report.prs : undefined; return !record.prompt || Date.parse(record.prompt.at) <= Date.parse(report.at) ? report.prs : undefined; } catch (error) { if (error.code !== "ENOENT") throw error; return undefined; } } export async function readIntegrator() { try { return integratorSchema.parse(JSON.parse(await readFile(path.join(bridgeRoot(), "integrator.json"), "utf8"))); } catch (error) { if (error.code !== "ENOENT") throw error; return undefined; } } export async function setIntegrator(input) { if (input === null) { const { rm } = await import("node:fs/promises"); await rm(path.join(bridgeRoot(), "integrator.json"), { force: true }); return { ok: true, integrator: null }; } const data = integratorSchema.parse(input); await atomicInPrivateDir(path.join(bridgeRoot(), "integrator.json"), data); return { ok: true, integrator: data }; }