@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
189 lines (188 loc) • 9.39 kB
JavaScript
// 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(); }