UNPKG

@phren/cli

Version:

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

206 lines (205 loc) • 11.7 kB
import { briefLabel } from "./launch-brief.js"; import { provider } from "./protocol.js"; import { backgroundLeft, readTurn, turnPhase } from "./turn-records.js"; /** * What a live session's row says about itself beyond Herdr's own status and * title, from the pane's turn record (turn-records.ts) and the dispatch label * (launch-brief.ts): * * - A session whose turn ended while work it started still runs is still * working: running sub-agents, teammates, workflow agents and fan-out jobs * (its child tree, Claude and Codex alike), and for Claude the background * shells and monitors started since the owner's last prompt that are not * endless streams (`FinalTurn.awaited`, bounded by its Stop's count less the * tasks finished since, for at most BACKGROUND_STALE_MS). A log tail, a * watcher or a shell left over from an earlier exchange runs on without * anyone waiting for it, so it does not count. Herdr reports such a session * idle, so the phone listed it under IDLE with a moon badge. The tab keeps * `agentStatus: "working"` and gains `backgroundTasks`, the count. * - A dispatched worker is named by its dispatch label. Claude and Codex title * a session after its first prompt, which for a brief launch is * "Read and follow the brief in <path>" (or "Brief d35c6189" once they * shorten it), so those titles are never shown. */ /** The record, only when it belongs to this pane's terminal and conversation * (the guard dispatch returns use): a leftover record of an earlier terminal * or session in the same pane must not speak for this one. */ export function ownRecord(record, pane, source, session) { return record && session && record.terminal === pane.terminal_id && record.source === source && record.session === session ? record : undefined; } /** The pane's own turn record. OpenCode has no Stop hook to report background work or a dispatch id here. */ export async function paneRecord(server, pane, session) { if (!session || pane.agent === "opencode" || !provider.safeParse(pane.agent).success) return undefined; return ownRecord(await readTurn(server, String(pane.pane_id)).catch(() => undefined), pane, pane.agent, session); } /** How long after its Stop a background count may keep a row working. A task * whose finish the transcript never shows (a dev server left running, a * persistent monitor, a notification the tail no longer holds) would * otherwise keep it working forever. Matches dispatch returns' wait. */ export const BACKGROUND_STALE_MS = 2 * 60 * 60 * 1000; /** Background tasks the recorded turn ended with that are still running: * the Stop's count less those `finishedTasks` (FinalTurn's) says finished * after it. Undefined once none are left or the Stop is BACKGROUND_STALE_MS old. */ export function recordedBackground(record, finishedTasks, now = Date.now()) { if (!record) return undefined; const phase = turnPhase(record); if (phase.phase !== "ended" || !(now - Date.parse(phase.at) < BACKGROUND_STALE_MS)) return undefined; return backgroundLeft(phase.background, phase.at, finishedTasks) || undefined; } /** The background shells and monitors a turn still awaits, from its transcript * final turn. A recorded ended turn bounds `FinalTurn.awaited` by the Stop's * count less the tasks that finished since, and by the 2-hour cap; a Stop that * named no count leaves the transcript's own awaited shells, and a Stop the * Hook never saw falls back to them too. Undefined once none is awaited. A log * tail, a watcher or a shell left from an earlier exchange is not awaited, so * it never holds a turn open. */ export function awaitedShells(record, final, now = Date.now()) { const phase = record ? turnPhase(record) : undefined; if (phase?.phase === "ended") { if (!(now - Date.parse(phase.at) < BACKGROUND_STALE_MS)) return undefined; const recorded = backgroundLeft(phase.background, phase.at, final?.finishedTasks); const shells = phase.background !== undefined ? recorded : final?.awaited ?? 0; return shells && final?.awaited ? Math.min(shells, final.awaited) : undefined; } return final?.completed ? final.awaited || undefined : undefined; } /** The live work a finished turn still waits on: its awaited shells and * monitors plus the running children (sub-agents, teammates, workflows, * fan-out jobs) the caller counted. Leftover shells are awaited by neither, so * they do not hold a session or a dispatched worker open. */ export function liveWork(record, final, runningChildren = 0, now = Date.now()) { const total = (awaitedShells(record, final, now) ?? 0) + runningChildren; return total || undefined; } /** Every background task the finished turn left running, awaited or not, less * the endless streams (a log tail, a dev server): the Stop's count less the * tasks finished since, else the transcript's own count, and nothing once the * Stop is BACKGROUND_STALE_MS old. A turn whose reply says it waits on such a * task is still working, and a pane running one is not closed. */ export function runningTasks(record, final, now = Date.now()) { const phase = record ? turnPhase(record) : undefined, endless = final?.endless ?? 0; if (phase?.phase === "ended" && phase.background !== undefined) { if (!(now - Date.parse(phase.at) < BACKGROUND_STALE_MS)) return 0; return Math.max(0, backgroundLeft(phase.background, phase.at, final?.finishedTasks) - endless); } if (phase?.phase === "ended" && !(now - Date.parse(phase.at) < BACKGROUND_STALE_MS)) return 0; return final?.completed ? Math.max(0, (final.background ?? 0) - endless) : 0; } /** The background shells and monitors a pane's ended Claude turn is waiting * on (`FinalTurn.awaited`), at most `recordedBackground`. Reads the transcript * (`readFinalTurn`, passed in: schedule-watch imports herdr, which imports * this) only when the Stop left background work; only Claude's Stop reports * a count. Sub-agents are not in it: the overview adds its running children. * An unreadable transcript counts nothing, since the Stop's count alone * cannot tell a build from a log tail. */ export async function liveBackground(record, readFinal, now = Date.now()) { if (!record || record.source !== "claude" || !recordedBackground(record, [], now)) return undefined; const final = await readFinal("claude", record.session).catch(() => undefined); return awaitedShells(record, final, now); } /** * Marks a tab whose main turn ended but whose background work continues: * an idle or done tab becomes `working` with `backgroundTasks: count`. Called * again with a fuller count (the awaited shells plus the running children, * once the child tree is read), it keeps the larger. A tab that is working, * blocked or waiting on its own is left alone, so `backgroundTasks` never * sits on a tab with a live turn. */ export function markBackground(tab, count) { if (!count || count < 1) return; if (tab.agentStatus === "idle" || tab.agentStatus === "done") { tab.agentStatus = "working"; tab.backgroundTasks = count; } else if (typeof tab.backgroundTasks === "number") tab.backgroundTasks = Math.max(tab.backgroundTasks, count); } /** How much of a turn's last reply a session row carries. */ export const REPLY_EXCERPT_CHARS = 240; /** * The ended turn's last reply as one short line for a session row * (`lastReply`): what the agent said before it stopped, so a waiting row can * say what it waits for. Markdown emphasis, headings and code fences are * dropped and whitespace collapsed. Undefined while a turn runs (its prompt * is newer than the Stop) or when the Stop carried no reply. */ export function replyExcerpt(record) { if (!record) return undefined; const phase = turnPhase(record); if (phase.phase !== "ended" || !phase.reply) return undefined; const line = phase.reply .replace(/```[\s\S]*?(```|$)/g, " ") .replace(/(^|\n)\s*(#{1,6}|>|[-*+]|\d+\.)\s+/g, "$1") .replace(/(\*\*|__|\*|`)/g, "") .replace(/\s+/g, " ") .trim(); if (!line) return undefined; return line.length > REPLY_EXCERPT_CHARS ? `${line.slice(0, REPLY_EXCERPT_CHARS - 1).trimEnd()}…` : line; } /** A tab or workspace label worth showing as a name: not empty, not Herdr's bare numbering, not an id. */ export function meaningfulLabel(value) { if (typeof value !== "string") return undefined; const text = value.trim(); return text && !/^\d+$/.test(text) && !/^\S*:\S*$/.test(text) && !/^[A-Za-z]{1,2}[_-]?\d+[A-Za-z]?$/.test(text) ? text : undefined; } /** Spinner and status glyphs a harness puts around its terminal title while it * works: Braille spinner frames (Codex), dingbat stars and checks (Claude's * ✳), bullets, circles and hourglasses. */ const GLYPHS_AROUND = /^[\u2800-\u28ff\u2700-\u27bf\u25a0-\u25ff\u23f0-\u23ff\u2022\u00b7\u2219\u22c5\u2605\u2606\s]+|[\s\u2800-\u28ff\u2700-\u27bf\u25a0-\u25ff\u23f0-\u23ff\u2022\u00b7\u2219\u22c5\u2605\u2606]+$/gu; /** A terminal title without the spinner or status glyphs around it, nor the * separator they leave dangling (Codex's "⠸ | phren" before its thread has a * title); undefined when nothing else is left. */ export function plainTitle(value) { if (typeof value !== "string") return undefined; const text = value.replace(GLYPHS_AROUND, "").replace(/^\|\s+|\s+\|$/g, "").replace(GLYPHS_AROUND, "").trim(); return text || undefined; } /** A harness title made from the brief launch's first prompt rather than the work: the prompt itself, the brief path, or "Brief <id>". */ export function isBriefTitle(title) { return /^\s*read and follow the brief/i.test(title) || title.includes("/briefs/") || /^\s*brief\s+[0-9a-f]{8}[0-9a-f-]*\s*(…|\.\.\.)?\s*$/i.test(title); } /** * The name a session is shown under, first that applies: * 1. the agent pane's own label, when meaningful (a rename from the phone or * from Herdr or tmux itself; a pane has none until someone sets one); * 2. the dispatch label its dispatcher gave it; * 3. for a dispatch with no stored label (sent before labels were kept), the * tab label, else the workspace label, when meaningful; * 4. the harness's own title, unless it is brief boilerplate, which is * replaced by a meaningful tab or workspace label or dropped (the phone * then falls back to the label). * Never leaves a row blank: when none applies and `fallbackLabel` (passed, even * if undefined) is the label the phone shows without a title and is blank, the raw harness title stands. */ export function sessionTitle(input) { const renamed = meaningfulLabel(input.paneLabel); if (renamed) return renamed; if (input.dispatchLabel) return input.dispatchLabel; const named = meaningfulLabel(input.tabLabel) ?? meaningfulLabel(input.workspaceLabel); const title = plainTitle(input.harnessTitle); const chosen = input.dispatched ? named : !title ? undefined : isBriefTitle(title) ? named : title; const label = input.fallbackLabel; const blank = "fallbackLabel" in input && (typeof label !== "string" || !label.trim()); return chosen ?? (blank ? title : undefined); } /** `sessionTitle` for a pane, given its own turn record (the guarded one from `paneRecord`). */ export async function recordTitle(record, input) { const dispatch = record?.dispatch; return sessionTitle({ ...input, dispatched: !!dispatch, dispatchLabel: dispatch ? await briefLabel(dispatch) : undefined }); }