UNPKG

@phren/cli

Version:

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

189 lines (188 loc) • 9.39 kB
// Agent status for panes whose terminal does not report one (tmux). Herdr // watches its agents and says "working", "idle" or "blocked"; tmux knows only // processes. Claude, Codex and phren-agent tell the Hook themselves through // their lifecycle hooks (agent-hooks.ts), so the last event a pane's agent // sent is its status: a submitted prompt or a tool call means working, a // finished turn or a fresh session idle, a permission request blocked. The // event is also kept in the pane's binding file, so a restarted Hook does not // forget a pane's status. OpenCode and Copilot say it elsewhere // (harness-status.ts); the snapshot notes what they say here, so every change // gets a sequence number too. // // A harness that draws a permission dialog in its terminal with no hook // behind it (Codex, OpenCode, Copilot, Claude's auto-mode fallback) still // looks "working" to all of that. While a pane is working its screen is read, // at most once per DIALOG_READ_MS, and a dialog there makes the pane blocked // until the dialog is gone. // // Codex holds its SessionStart hook until the first turn, so a Codex that has // just opened has sent nothing yet and would stay "unknown", which refuses // the phone's first message. Until its first event its screen says instead: // a menu (folder trust, hooks to review) is blocked, its composer idle. import { readFile } from "node:fs/promises"; import { stripTerminal } from "../terminal-text.js"; import { bindingPath } from "./agent-hook-stores.js"; import { claudeQuestionDialog } from "./claude-question-dialog.js"; import { intervalFromEnv } from "./limits.js"; import { object } from "./protocol.js"; import { numberedDialog, opencodePermissionDialog, visibleTerminalChoice } from "./terminal-choice.js"; /** A working pane's screen is read for a dialog at most once per this window. */ const DIALOG_READ_MS = intervalFromEnv("PHREN_DIALOG_THROTTLE_MS", 3_000); const EVENT_STATUS = { SessionStart: "idle", UserPromptSubmit: "working", PreToolUse: "working", PostToolUse: "working", PreCompact: "working", Stop: "idle", PermissionRequest: "blocked", }; /** The status a lifecycle event leaves the agent in; undefined for events that say nothing about it. */ export function eventStatus(event) { return typeof event === "string" && Object.hasOwn(EVENT_STATUS, event) ? EVENT_STATUS[event] : undefined; } const entries = new Map(); /** Climbs with every change, like Herdr's state_change_seq, so the phone can order "just finished" first. */ let sequence = 0; const key = (server, pane) => `${server}\0${pane}`; /** Records the status of the agent in `pane`, which runs in terminal instance * `terminal`, and answers it with its sequence number. */ export function notePaneStatus(server, pane, terminal, status, at = Date.now()) { const id = key(server, pane), current = entries.get(id); if (current && current.terminal === terminal && current.status === status) { current.at = at; return { status, seq: current.seq }; } entries.delete(id); while (entries.size >= 512) entries.delete(entries.keys().next().value); const entry = { terminal, status, seq: ++sequence, at }; entries.set(id, entry); return { status, seq: entry.seq }; } /** A blocked pane whose dialog is gone was answered in the terminal: the * agent went back to work. Only after `minimumMs`, so a request whose * dialog is still being drawn is not settled early. */ export function settleBlockedPane(server, pane, minimumMs = 3_000) { const id = key(server, pane), current = entries.get(id), dialog = dialogs.get(id); if (current?.status === "blocked" && Date.now() - current.at >= minimumMs) notePaneStatus(server, pane, current.terminal, "working"); if (dialog?.shown && Date.now() - dialog.shown.at >= minimumMs) { // The screen can show the answered dialog a moment longer: the next read // waits a whole window. dialog.shown = undefined; dialog.cleared = ++sequence; dialog.readAt = Date.now(); } } /** The pane's last known status while `terminal` still runs there: from * memory, else from the binding file the last lifecycle event wrote. */ export async function paneStatus(server, pane, terminal) { const current = entries.get(key(server, pane)); if (current && current.terminal === terminal) return { status: current.status, seq: current.seq }; if (current) return undefined; try { const value = object(JSON.parse(await readFile(bindingPath(server, pane), "utf8"))); const status = eventStatus(value.event); if (value.terminal !== terminal || !status) return undefined; const at = Date.parse(String(value.at)); return notePaneStatus(server, pane, terminal, status, Number.isFinite(at) ? at : Date.now()); } catch { return undefined; } } /** Whether `screen` (a pane's visible lines, with or without colors) shows a * dialog waiting for `agent`'s owner. Stricter than the readers that answer * a dialog, because it runs while the agent is still writing: Claude's and * phren-agent's numbered rows count only with their "Esc to cancel" footer, * OpenCode's only as its own permission prompt. */ export function screenDialog(agent, screen) { const text = stripTerminal(screen); if (agent === "opencode") return !!opencodePermissionDialog(screen); if (agent === "codex" || agent === "copilot") return !!visibleTerminalChoice(text); if (agent === "claude" && claudeQuestionDialog(text)) return true; return /Esc to cancel/i.test(text) && !!numberedDialog(text); } const dialogs = new Map(); /** The status of a pane after a look at its screen: blocked while a working * agent shows a dialog there (`screenDialog`), else `base` unchanged. `read` * answers the pane's visible lines; it runs only for a working pane, at most * once per DIALOG_READ_MS per pane. */ export async function dialogStatus(server, pane, terminal, agent, base, read) { const id = key(server, pane); if (base?.status !== "working") { dialogs.delete(id); return base; } let entry = dialogs.get(id); if (!entry || entry.terminal !== terminal) { entry = { terminal, readAt: 0 }; dialogs.delete(id); while (dialogs.size >= 512) dialogs.delete(dialogs.keys().next().value); dialogs.set(id, entry); } const now = Date.now(); if (now - entry.readAt >= DIALOG_READ_MS) { entry.readAt = now; // A failed read says nothing: the pane keeps what it had. const shown = await read().then(screen => screenDialog(agent, screen), () => undefined); if (shown === true && !entry.shown) entry.shown = { at: now, seq: ++sequence }; else if (shown === false && entry.shown) { entry.shown = undefined; entry.cleared = ++sequence; } } if (entry.shown) return { status: "blocked", seq: entry.shown.seq }; return { status: base.status, seq: Math.max(base.seq, entry.cleared ?? 0) }; } /** A Codex that has not sent its first lifecycle event, read from its * screen: blocked on a startup menu (folder trust, hooks to review), working * while it shows its interrupt hint (a turn whose hooks did not run), idle * once its composer and footer are drawn, else undefined (still starting). */ export function codexStartupStatus(screen) { const text = stripTerminal(screen); if (visibleTerminalChoice(text)) return "blocked"; if (/\besc to interrupt\b/i.test(text)) return "working"; const lines = text.split(/\r?\n/).map(line => line.trimEnd()).filter(line => line.trim()); let composer = lines.length - 1; while (composer >= 0 && !/^\u203a(?:\s|$)/.test(lines[composer])) composer -= 1; // The composer sits above a footer or two: "? for shortcuts" (0.158), or // just the model line (0.155). A "\u203a" row higher up is an earlier prompt. return composer >= 0 && composer >= lines.length - 3 && lines.length - composer > 1 ? "idle" : undefined; } const startups = new Map(); /** The status of a hooked agent in `pane` that has sent no lifecycle event * yet: Codex's screen read (`codexStartupStatus`) at most once per * DIALOG_READ_MS, else undefined. Its first event takes over for good. */ export async function startupStatus(server, pane, terminal, agent, read) { if (agent !== "codex") return undefined; const id = key(server, pane); let entry = startups.get(id); if (!entry || entry.terminal !== terminal) { entry = { terminal, readAt: 0 }; startups.delete(id); while (startups.size >= 512) startups.delete(startups.keys().next().value); startups.set(id, entry); } const now = Date.now(); if (now - entry.readAt >= DIALOG_READ_MS) { entry.readAt = now; // A failed read says nothing: the pane keeps what it had. const seen = await read().then(codexStartupStatus, () => entry.status?.status); if (seen !== entry.status?.status) entry.status = seen ? { status: seen, seq: ++sequence } : undefined; } return entry.status; } /** For tests. */ export function resetPaneStatus() { entries.clear(); dialogs.clear(); startups.clear(); }