UNPKG

@phren/cli

Version:

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

193 lines (192 loc) • 10.7 kB
import * as fs from "node:fs"; import * as path from "node:path"; import { atomicWriteText } from "../phren-paths.js"; /** How long a worker waits for the owner to answer a permission before it is * told nobody did. A test shortens it. */ function approvalWindow() { const value = Number(process.env.PHREN_FANOUT_APPROVAL_MS); return Number.isFinite(value) && value >= 100 && value <= 24 * 3_600_000 ? Math.floor(value) : 3_600_000; } const ANSWER_POLL_MS = 250; const LISTEN_TIMEOUT_MS = 30_000; const SESSION = /^ses_[0-9A-Za-z]{1,64}$/; /** The same rules `opencode run` gives a session it creates: nobody is there * to answer a question or approve a plan switch. */ const HEADLESS_RULES = ["question", "plan_enter", "plan_exit"].map(permission => ({ permission, action: "deny", pattern: "*" })); export const DENIED_FEEDBACK = "The owner denied this request from the phone. Do not retry it; continue without it and say in your final report what you could not do because of it."; export const UNANSWERED_FEEDBACK = "Nobody answered this permission request in time. Do not retry it; continue without it and say in your final report what you could not do because of it."; /** Waits for `opencode serve` to print its address. */ function listening(child) { return new Promise((resolve, reject) => { let buffered = ""; const timer = setTimeout(() => done(new Error("opencode serve did not start listening.")), LISTEN_TIMEOUT_MS); const done = (error, url) => { clearTimeout(timer); child.stdout?.off("data", data); child.off("exit", exit); if (error) reject(error); else resolve(url); }; const data = (chunk) => { buffered = (buffered + chunk.toString("utf8")).slice(-8_192); const match = /listening on (http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d+)/.exec(buffered); if (match) done(undefined, match[1]); }; const exit = () => done(new Error("opencode serve exited before listening.")); child.stdout?.on("data", data); child.once("exit", exit); }); } /** Drives one fan-out worker through `opencode serve` and its HTTP API rather * than `opencode run`, which rejects every permission ask on its own. The * events it writes are the ones `opencode run --format json` prints, so the * Hook's transcript readers and the loop watchdog read them unchanged. A * permission ask becomes the same request file the Phren plugin writes for a * pane, the Hook shows it on the phone under the worker's parent, and the * owner's answer resumes the same session: Allow runs the call, Deny rejects it * with feedback so the worker carries on and reports the refusal. */ export async function driveOpencode(child, o) { const base = await listening(child); const auth = "Basic " + Buffer.from(`opencode:${o.password}`).toString("base64"); const query = `directory=${encodeURIComponent(o.worktree)}`; const call = async (method, route, body) => { const response = await fetch(`${base}${route}${route.includes("?") ? "&" : "?"}${query}`, { method, headers: { Authorization: auth, ...(body === undefined ? {} : { "Content-Type": "application/json" }) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }); const text = await response.text(); if (!response.ok) throw new Error(`opencode ${method} ${route.split("?")[0]} failed with ${response.status}: ${text.slice(0, 500)}`); return text ? JSON.parse(text) : {}; }; const events = await fetch(`${base}/event?${query}`, { headers: { Authorization: auth, Accept: "text/event-stream" } }); if (!events.ok || !events.body) throw new Error(`opencode event stream failed with ${events.status}.`); const session = o.resume ? (await call("GET", `/session/${encodeURIComponent(o.resume)}`)).id : (await call("POST", "/session", { permission: HEADLESS_RULES })).id; if (typeof session !== "string" || !SESSION.test(session)) throw new Error("opencode did not return a session."); o.session(session); const line = (type, fields) => o.emit(JSON.stringify({ type, timestamp: Date.now(), sessionID: session, ...fields }) + "\n"); const family = new Set([session]); let error, busy = false, asks = Promise.resolve(); const stop = new AbortController(); const ask = async (asked) => { // OpenCode's own loop detector asks too; the launcher's watchdog stops a // real loop and says why, which is clearer than a refused permission. if (asked.permission === "doom_loop") { await call("POST", `/permission/${encodeURIComponent(asked.id)}/reply`, { reply: "once" }); return; } const decision = await relay(o, session, asked, stop.signal); if (decision === "stopped") return; line("phren/permission", { permission: asked.permission, patterns: asked.patterns, decision }); await call("POST", `/permission/${encodeURIComponent(asked.id)}/reply`, decision === "approve" ? { reply: "once" } : decision === "always" ? { reply: "always" } : { reply: "reject", message: decision === "deny" ? DENIED_FEEDBACK : UNANSWERED_FEEDBACK }); }; const reader = events.body.getReader(), decoder = new TextDecoder(); let pending = ""; const read = async () => { for (;;) { const { value, done } = await reader.read(); if (done) throw new Error("opencode closed its event stream."); pending += decoder.decode(value, { stream: true }); let boundary; while ((boundary = pending.indexOf("\n\n")) >= 0) { const block = pending.slice(0, boundary); pending = pending.slice(boundary + 2); const data = block.split("\n").filter(row => row.startsWith("data:")).map(row => row.slice(5).trimStart()).join("\n"); if (!data) continue; let event; try { event = JSON.parse(data); } catch { continue; } const p = event.properties ?? {}; if (event.type === "session.created" && p.info?.parentID && family.has(p.info.parentID) && p.info.id) family.add(p.info.id); else if (event.type === "message.part.updated" && p.part?.sessionID === session) { const part = p.part; busy = true; if (part.type === "tool" && ["completed", "error"].includes(String(part.state?.status))) line("tool_use", { part }); else if (part.type === "step-start") line("step_start", { part }); else if (part.type === "step-finish") line("step_finish", { part }); else if (part.type === "text" && part.time?.end) line("text", { part }); } else if (event.type === "session.error" && p.sessionID === session && p.error) { const message = String(p.error.data?.message ?? p.error.name); error = error ? `${error}\n${message}` : message; line("error", { error: p.error }); } else if (event.type === "session.status" && p.sessionID === session) { if (p.status?.type === "busy") busy = true; else if (p.status?.type === "idle" && busy) return; } else if (event.type === "permission.asked" && p.sessionID && family.has(p.sessionID) && typeof p.id === "string") { const asked = { id: p.id, sessionID: p.sessionID, permission: String(p.permission ?? "action"), patterns: Array.isArray(p.patterns) ? p.patterns.filter((value) => typeof value === "string") : [] }; // One question on the phone at a time, in the order they came. asks = asks.then(() => ask(asked)).catch(failure => { error = String(failure); }); } } } }; const loop = read(); await call("POST", `/session/${session}/prompt_async`, { agent: o.review ? "plan" : "build", parts: [{ type: "text", text: o.prompt }], ...(o.model ? { model: { providerID: o.model.split("/")[0], modelID: o.model.split("/").slice(1).join("/") } } : {}), ...(o.variant ? { variant: o.variant } : {}) }); try { await loop; } finally { stop.abort(); await reader.cancel().catch(() => { }); } await asks; return error ? 1 : 0; } /** Publishes one ask where the Hook looks for OpenCode approvals and waits * for the phone's answer. The request carries the fan-out job so the Hook * can find the worker's parent conversation. */ async function relay(o, session, asked, signal) { const directory = path.join(o.store, ".runtime", "approvals"); const request = path.join(directory, `opencode-${session}.request.json`), answer = path.join(directory, `opencode-${session}.answer.json`); fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); fs.rmSync(answer, { force: true }); const created = Date.now(), deadline = created + approvalWindow(), pattern = asked.patterns.join(", "); atomicWriteText(request, JSON.stringify({ id: asked.id, sessionID: session, type: asked.permission, title: `Allow ${asked.permission} for ${o.label}?`.slice(0, 300), message: (pattern ? `${asked.permission}: ${pattern}` : `${o.label} asks to use ${asked.permission}.`).slice(0, 2000), fanout: o.jobId, createdAt: new Date(created).toISOString(), expiresAt: new Date(deadline).toISOString() }), { mode: 0o600 }); try { while (Date.now() < deadline) { await new Promise(resolve => setTimeout(resolve, ANSWER_POLL_MS)); if (signal.aborted) return "stopped"; try { const value = JSON.parse(fs.readFileSync(answer, "utf8")); if (value.id === asked.id && (value.decision === "approve" || value.decision === "deny" || value.decision === "always")) return value.decision; } catch { /* Not answered yet. */ } } return "timeout"; } finally { fs.rmSync(answer, { force: true }); fs.rmSync(request, { force: true }); } }