/** * Fleet widget (§12.5). At most `widgetMaxRows` rows above the editor. */ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { activeRunEntries, latestRunEntries, type RegistryEntry, elapsed } from "./registry.ts"; import { isTerminalState } from "./status.ts"; export const WIDGET_KEY = "agi-fleet"; export const WIDGET_LINGER_MS = 6_000; export const WIDGET_ERROR_LINGER_MS = 20_000; const ERRORISH = new Set(["failed", "timedOut", "orphaned", "stopped", "unknown"]); /** * R-UI-17. Tool names become gerunds, `bash` becomes the command itself, and the * fallback chain is: current tool, then the last assistant text, then "thinking…". * A row that never changes is not scannable, which is the point of the humanization. */ export function humanizeActivity(entry: RegistryEntry, now = Date.now()): string { const status = entry.status; if (isTerminalState(status.state)) return ""; const tool = status.activity.currentTool; if (tool !== null) { const seconds = status.activity.currentToolStartedAt === null ? undefined : Math.max(0, Math.round((now - Date.parse(status.activity.currentToolStartedAt)) / 1000)); const suffix = seconds === undefined || seconds < 5 ? "" : ` ${seconds}s`; const target = status.activity.currentPath; switch (tool) { case "read": return `reading ${target ?? ""}${suffix}`.trim(); case "grep": return `searching ${target ?? ""}${suffix}`.trim(); case "find": return `finding ${target ?? ""}${suffix}`.trim(); case "ls": return `listing ${target ?? ""}${suffix}`.trim(); case "edit": return `editing ${target ?? ""}${suffix}`.trim(); case "write": return `writing ${target ?? ""}${suffix}`.trim(); case "bash": return `${(target ?? "bash").slice(0, 40)}${suffix}`; default: return `${tool}${suffix}`; } } if (status.activity.lastAssistantPreview !== null) return status.activity.lastAssistantPreview.slice(0, 40); return "thinking…"; } function contextCell(entry: RegistryEntry, ctx: ExtensionContext): string { const context = entry.status.context; if (context === null) return ""; const text = `${context.percent}%`; // R-UI-16: encoding urgency in colour is what makes a dense row scannable. const colour = context.percent >= 85 ? "error" : context.percent >= 70 ? "warning" : "muted"; const compactions = entry.status.counters.compactions > 0 ? ` ⇊${entry.status.counters.compactions}` : ""; return `${ctx.ui.theme.fg(colour, text)}${compactions}`; } function terminalCell(entry: RegistryEntry, ctx: ExtensionContext): string { const state = entry.status.state; if (state === "complete") { return entry.status.suspicious === undefined ? ctx.ui.theme.fg("success", "✓ complete") : ctx.ui.theme.fg("warning", "✓ complete (suspicious)"); } if (ERRORISH.has(state)) return ctx.ui.theme.fg("error", `✗ ${state}`); return state; } /** * R-UI-15. Order: attention, then running, then recently terminal. Terminal runs * linger so a completion is visible; error-ish runs linger far longer so a failure * is not missed while the user was reading something else. */ export function selectRows(entries: RegistryEntry[], now = Date.now()): RegistryEntry[] { entries = latestRunEntries(entries); const attention: RegistryEntry[] = []; const running: RegistryEntry[] = []; const recent: RegistryEntry[] = []; const activeRunIds = new Set(activeRunEntries(entries).map((entry) => entry.runId)); for (const entry of entries) { const status = entry.status; if (isTerminalState(status.state)) { const ended = status.endedAt === null ? 0 : Date.parse(status.endedAt); if (Number.isNaN(ended)) continue; const linger = ERRORISH.has(status.state) ? WIDGET_ERROR_LINGER_MS : WIDGET_LINGER_MS; if (now - ended <= linger) recent.push(entry); continue; } if (!activeRunIds.has(entry.runId)) continue; if (status.attention !== null) attention.push(entry); else running.push(entry); } return [...attention, ...running, ...recent]; } /** * R-UI-13 / R-PROMPT-2: the `## Fleet` section of the per-turn context block. * * Rebuilt from `status.json` on every turn rather than remembered, so the * orchestrator's model of the fleet cannot silently diverge from reality. Unread * terminal runs are named explicitly, because a completion the orchestrator never * reads is work silently thrown away. */ export function renderFleetDigest(entries: RegistryEntry[], now = Date.now()): string { void now; entries = latestRunEntries(entries); const active = activeRunEntries(entries); const unread = entries.filter((entry) => entry.status.state === "complete" && !entry.status.resultConsumed); if (active.length === 0 && unread.length === 0) return "## Fleet\n(no workers running)"; const lines = ["## Fleet"]; for (const entry of active) { const status = entry.status; const detail = status.state === "queued" ? "queued" : status.activity.currentTool !== null ? `${status.activity.currentTool}${status.activity.currentPath === null ? "" : ` ${status.activity.currentPath}`}` : (status.activity.lastAssistantPreview ?? "thinking…"); lines.push( `- ${status.name} (${status.agent}) — ${status.state}` + `${detail.length === 0 ? "" : `, ${detail}`}${status.detached === true ? ", adopted (filesystem control only)" : ""}`, ); } for (const entry of unread) { const status = entry.status; lines.push( `- ${status.name} (${status.agent}) — ${status.state}, result UNREAD` + `${status.error === null ? "" : " [worker recorded a harness warning; inspect trace.log]"}`, ); } if (unread.length > 0) { lines.push(`Read unread results with agi_worker({name, view:"result"}), then verify before updating plan.md.`); } return lines.join("\n"); } export interface FleetSummary { lines: string[] | undefined; /** Signature for the R-UI-14 change check, so a render is only requested on change. */ signature: string; } export function renderFleetWidget( entries: RegistryEntry[], ctx: ExtensionContext, options: { maxRows: number; headline?: string; now?: number }, ): FleetSummary { const now = options.now ?? Date.now(); const rows = selectRows(entries, now); if (rows.length === 0) { // R-UI-18: no rows means clear the widget, not render an empty one — an empty // widget leaves a blank line above the editor forever. return { lines: undefined, signature: "" }; } const runningCount = rows.filter((entry) => !isTerminalState(entry.status.state)).length; const header = `${ctx.ui.theme.fg("accent", "●")} AGI${options.headline === undefined ? "" : ` · ${options.headline}`} · ${runningCount} running`; const bodyBudget = Math.max(1, options.maxRows - 1); const shown = rows.slice(0, bodyBudget); const hidden = rows.slice(bodyBudget); const lines = [header]; shown.forEach((entry, index) => { const last = index === shown.length - 1 && hidden.length === 0; const prefix = last ? "└─" : "├─"; const status = entry.status; const cells = isTerminalState(status.state) ? [status.name, status.agent, elapsed(status, now), terminalCell(entry, ctx)] : [ status.name, status.agent, elapsed(status, now), `${status.counters.turns}t`, contextCell(entry, ctx), humanizeActivity(entry, now), ]; lines.push(`${prefix} ${cells.filter((cell) => cell.length > 0).join(" ")}`); }); if (hidden.length > 0) { // R-UI-15 overflow: the collapsed line still names the states, because "+3 more" // alone hides exactly the failure the linger rules exist to surface. const counts = new Map(); for (const entry of hidden) counts.set(entry.status.state, (counts.get(entry.status.state) ?? 0) + 1); const detail = [...counts.entries()].map(([state, count]) => `${count} ${state}`).join(", "); lines.push(` +${hidden.length} more (${detail})`); } return { lines, signature: lines.join("\u0000") }; }