import type { Component, KeyId, TUI } from "@earendil-works/pi-tui"; import { isKeyRelease, matchesKey } from "@earendil-works/pi-tui"; import type { ExtensionAPI, ExtensionContext, KeybindingsManager } from "@earendil-works/pi-coding-agent"; import type { WorkerConfig } from "../worker/config.ts"; import { elapsed, latestRunEntries, loadAllRuns, type RegistryEntry } from "../worker/registry.ts"; import { STATUS_POLL_MS, readEvents } from "../worker/status.ts"; import type { Supervisor } from "../worker/supervisor.ts"; import { classifyWorkerOutcome, readableResultAvailable } from "../worker/outcome.ts"; type InspectorAction = "steer" | "interrupt" | "stop" | "resume" | "close"; export interface InspectorResult { action: InspectorAction; runId?: string; } export interface InspectorDeps { supervisor: () => Supervisor | undefined; config: (ctx: ExtensionContext) => WorkerConfig; readOnlySession: () => boolean; haltAll: (ctx: ExtensionContext, reason: string) => Promise; } interface ThemeLike { fg(name: string, text: string): string; bold(text: string): string; } function keyId(value: string): KeyId { return value as KeyId; } function crop(text: string, width: number): string { if (text.length <= width) return text; return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`; } function eventDetail(event: Record): string { for (const field of ["detail", "preview", "target", "signal"]) { const value = event[field]; if (typeof value === "string" && value.length > 0) return value; } return ""; } export class WorkerInspector implements Component { private tui: TUI; private theme: ThemeLike; private keybindings: KeybindingsManager; private cwd: string; private bindings: WorkerConfig["inspectorBindings"]; private done: (result: InspectorResult) => void; private entries: RegistryEntry[] = []; private selected = 0; /** * R-UI-12. The armed action carries the run it was armed **for**. * * Binding only the action and re-reading the target at the second press meant the * `STATUS_POLL_MS` refresh could re-sort the registry underneath the cursor, so a * newly delegated run inherited a confirmation the user had aimed at something * else — a terminal, unresumable stop on the wrong run, behind a dialog that * looked correct. */ private armed: { action: "stop" | "resume"; runId: string } | undefined; private showEvents = false; private timer: ReturnType; private signature = ""; private now: () => number; constructor( tui: TUI, theme: ThemeLike, keybindings: KeybindingsManager, cwd: string, bindings: WorkerConfig["inspectorBindings"], done: (result: InspectorResult) => void, pollMs: number, now: () => number = Date.now, ) { this.tui = tui; this.theme = theme; this.keybindings = keybindings; this.cwd = cwd; this.bindings = bindings; this.done = done; this.now = now; this.refresh(false); this.timer = setInterval(() => this.refresh(true), pollMs); this.timer.unref?.(); } invalidate(): void { this.signature = ""; } dispose(): void { clearInterval(this.timer); } refresh(render = true): boolean { const previous = this.entries[this.selected]?.runId; const entries = latestRunEntries(loadAllRuns(this.cwd).entries); this.entries = entries; // The cursor follows the *run*, not the row number: rows are re-sorted on every // poll as runs appear and settle, and an index-based cursor silently slides onto // a different run between a keypress and its confirmation. const moved = previous === undefined ? -1 : entries.findIndex((entry) => entry.runId === previous); this.selected = moved >= 0 ? moved : Math.min(this.selected, Math.max(0, entries.length - 1)); if (this.armed !== undefined && entries[this.selected]?.runId !== this.armed.runId) this.armed = undefined; const selected = entries[this.selected]; const signature = JSON.stringify({ rows: entries.map((entry) => ({ runId: entry.runId, name: entry.status.name, agent: entry.status.agent, state: entry.status.state, elapsed: elapsed(entry.status, this.now()), model: entry.status.model, thinking: entry.status.thinking, counters: entry.status.counters, context: entry.status.context, usage: entry.status.usage, steering: entry.status.steering, activity: entry.status.activity, assistantEvidence: entry.status.assistantEvidence, lateToolResults: entry.status.lateToolResults, resultPath: entry.status.resultPath, resultAvailable: readableResultAvailable(entry.paths.result), exitCode: entry.status.exitCode, processSignal: entry.status.processSignal, expectedTeardown: entry.status.expectedTeardown, error: entry.status.error, stop: entry.status.stop, processGroupAnchor: entry.status.processGroupAnchor, reportError: entry.status.reportError, reportDiagnostic: entry.status.reportDiagnostic, })), events: this.showEvents && selected !== undefined ? readEvents(selected.paths, 8) : [], }); if (signature === this.signature) return false; this.signature = signature; if (render) this.tui.requestRender(); return true; } private choose(action: InspectorAction): void { const selected = this.entries[this.selected]; if (selected === undefined) return; this.done({ action, runId: selected.runId }); } /** * Two-press confirm (R-UI-12), bound to the run under the cursor at *both* * presses. If the cursor is no longer on the armed run the press re-arms for the * run actually selected rather than acting on it. */ private confirm(action: "stop" | "resume"): void { const selected = this.entries[this.selected]; if (selected === undefined) return; if (this.armed?.action === action && this.armed.runId === selected.runId) { this.armed = undefined; this.done({ action, runId: selected.runId }); return; } this.armed = { action, runId: selected.runId }; this.tui.requestRender(); } handleInput(data: string): void { if (isKeyRelease(data)) return; if (this.keybindings.matches(data, "tui.select.cancel")) { this.done({ action: "close" }); return; } if (this.keybindings.matches(data, "tui.select.up")) { this.armed = undefined; this.selected = Math.max(0, this.selected - 1); this.tui.requestRender(); return; } if (this.keybindings.matches(data, "tui.select.down")) { this.armed = undefined; this.selected = Math.min(Math.max(0, this.entries.length - 1), this.selected + 1); this.tui.requestRender(); return; } if (this.matches(data, "stop")) { this.confirm("stop"); return; } if (this.matches(data, "resume")) { this.confirm("resume"); return; } this.armed = undefined; if (this.matches(data, "steer")) this.choose("steer"); else if (this.matches(data, "interrupt")) this.choose("interrupt"); else if (this.matches(data, "events")) { this.showEvents = !this.showEvents; this.tui.requestRender(); } } /** * A configured binding is an arbitrary string cast to `KeyId`, so an unparseable * one would silently never match. `resolveInspectorBindings` has already replaced * anything invalid or colliding with the default, and `matchesKey` is still * guarded because a throw here would take the overlay down with it. */ private matches(data: string, action: keyof WorkerConfig["inspectorBindings"]): boolean { try { return matchesKey(data, keyId(this.bindings[action])); } catch { return false; } } render(width: number): string[] { const inner = Math.max(30, width - 4); const lines = [this.theme.bold("AGI workers")]; if (this.entries.length === 0) lines.push(this.theme.fg("muted", "No worker runs.")); for (let index = 0; index < this.entries.length; index++) { const entry = this.entries[index]; if (entry === undefined) continue; const marker = index === this.selected ? "▸" : " "; lines.push(crop(`${marker} ${entry.status.name} ${entry.status.agent} ${entry.status.state} ${elapsed(entry.status, this.now())}`, inner)); } const selected = this.entries[this.selected]; if (selected !== undefined) { const status = selected.status; const resultAvailable = readableResultAvailable(selected.paths.result); const outcome = classifyWorkerOutcome(status, resultAvailable); lines.push("", crop(`${status.name} · ${status.agent} · ${status.state}`, inner)); lines.push(crop(`outcome evidence: ${outcome.qualifier}`, inner)); lines.push(crop(`model ${status.model ?? "(inherited)"}${status.thinking === null ? "" : `:${status.thinking}`} · ${status.counters.turns} turns · ctx ${status.context === null ? "—" : `${status.context.tokens}/${status.context.contextWindow} (${status.context.percent}%)`}`, inner)); lines.push(crop(`${status.counters.toolCalls} tool calls (${status.counters.toolErrors} errors) · ${status.counters.compactions} compactions · $${status.usage.costUsd.toFixed(2)} · steers ${status.steering.delivered} delivered`, inner)); if (status.stop !== undefined && status.stop !== null) { lines.push(crop(`stopped by ${status.stop.source} at ${status.stop.requestedAt}: ${status.stop.reason}`, inner)); } if (resultAvailable) { lines.push(crop(`result: ${selected.paths.result}${status.resultConsumed ? " (read)" : " (unread)"}`, inner)); } else if (status.resultPath !== null) { lines.push(crop("result: unavailable (recorded metadata is stale or unreadable)", inner)); } if (status.exitCode !== null || status.processSignal !== null) { lines.push(crop(`exit: code ${status.exitCode ?? "—"} · signal ${status.processSignal ?? "—"}`, inner)); } if (status.processGroupAnchor !== undefined && status.processGroupAnchor !== null) { lines.push(crop(`anchor: pid ${status.processGroupAnchor.pid} · pgid ${status.processGroupAnchor.pgid} · sid ${status.processGroupAnchor.sid}`, inner)); } if (status.assistantEvidence !== undefined && status.assistantEvidence !== null) { lines.push(crop(`assistant evidence: ${status.assistantEvidence.source}${status.assistantEvidence.observedDuringTerminalization ? " during terminalization" : ""}`, inner)); } if ((status.lateToolResults?.length ?? 0) > 0) { const latest = status.lateToolResults?.at(-1); lines.push(crop(`late tool results: ${status.lateToolResults?.length ?? 0}${latest?.tool === null || latest?.tool === undefined ? "" : ` · latest ${latest.tool}`}`, inner)); } if (status.exitCode !== null || status.processSignal !== null || status.expectedTeardown) { lines.push(crop(`process: exit ${status.exitCode ?? "—"} · signal ${status.processSignal ?? "—"}${status.expectedTeardown ? " · harness teardown" : ""}`, inner)); } if (status.reportError !== undefined) lines.push(crop(`report error: ${status.reportError}`, inner)); if (status.reportDiagnostic !== undefined) lines.push(crop(`report: ${status.reportDiagnostic}`, inner)); if (status.activity.lastAssistantPreview !== null) lines.push(crop(`last said: ${status.activity.lastAssistantPreview}`, inner)); if ((status.activity.activeTools?.length ?? 0) > 0) lines.push(crop(`active tools: ${status.activity.activeTools?.length ?? 0}`, inner)); if (this.showEvents) { for (const event of readEvents(selected.paths, 8)) lines.push(crop(`${event.ts.slice(11, 19)} ${event.kind} ${eventDetail(event)}`, inner)); } else if (status.activity.currentTool !== null) { lines.push(crop(`live: ${status.activity.currentTool} ${status.activity.currentPath ?? ""}`, inner)); } } const footer = this.armed?.action === "stop" ? this.theme.fg("error", `${this.bindings.stop} again to STOP ${this.entries.find((entry) => entry.runId === this.armed?.runId)?.status.name ?? "selected agent"}`) : this.armed?.action === "resume" ? this.theme.fg("warning", `${this.bindings.resume} again to RESUME ${this.entries.find((entry) => entry.runId === this.armed?.runId)?.status.name ?? "selected agent"}`) : `↑↓ select · ${this.bindings.steer} steer · ${this.bindings.interrupt} interrupt · ${this.bindings.stop} ${this.bindings.stop} stop · ${this.bindings.resume} ${this.bindings.resume} resume · ${this.bindings.events} events · Esc close`; lines.push("", crop(footer, inner)); return lines; } } async function runInspector(ctx: ExtensionContext, deps: InspectorDeps): Promise { if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify("/agi-workers requires the interactive TUI.", "warning"); return; } while (true) { const config = deps.config(ctx); const result = await ctx.ui.custom( (tui, theme, keybindings, done) => new WorkerInspector(tui, theme, keybindings, ctx.cwd, config.inspectorBindings, done, STATUS_POLL_MS), { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%", anchor: "center" } }, ); if (result.action === "close" || result.runId === undefined) return; const supervisor = deps.supervisor(); if (supervisor === undefined || deps.readOnlySession()) { ctx.ui.notify("Worker control is unavailable in this session.", "warning"); return; } try { if (result.action === "steer") { const selected = loadAllRuns(ctx.cwd).entries.find((entry) => entry.runId === result.runId); const label = selected?.status.name ?? "selected agent"; const message = await ctx.ui.editor(`Steer ${label}`, ""); if (message === undefined || message.trim().length === 0) continue; const outcome = await supervisor.steer(result.runId, message, true, "user", false); ctx.ui.notify(`Steer ${outcome.state}${outcome.detail === undefined ? "" : `: ${outcome.detail}`}.`, outcome.state === "failed" ? "error" : "info"); } else if (result.action === "interrupt") { supervisor.interrupt(result.runId, "user"); } else if (result.action === "stop") { supervisor.stopImmediately(result.runId, "stopped from the worker inspector", "user"); } else if (result.action === "resume") { const selected = loadAllRuns(ctx.cwd).entries.find((entry) => entry.runId === result.runId); const label = selected?.status.name ?? "selected agent"; const message = await ctx.ui.editor(`Resume ${label}`, "Continue from where you left off."); if (message === undefined || message.trim().length === 0) continue; const resumed = supervisor.resume(result.runId, message); const acceptance = await resumed.acceptance; if (!acceptance.ok) throw new Error(`Agent ${label} could not be resumed (${acceptance.reason}).`); } } catch (error) { ctx.ui.notify((error as Error).message, "error"); } } } export function registerInspector(pi: ExtensionAPI, deps: InspectorDeps): void { pi.registerCommand("agi-workers", { description: "Open the live AGI worker inspector", handler: async (_args, ctx) => runInspector(ctx, deps), }); pi.registerShortcut("ctrl+alt+w", { description: "Open AGI worker inspector", handler: async (ctx) => runInspector(ctx, deps), }); pi.registerCommand("agi-stop", { description: "Stop the entire AGI run with confirmation", handler: async (args, ctx) => { if (deps.readOnlySession()) throw new Error("AGI observe mode is read-only."); const reason = args.trim() || "stopped by the user with /agi-stop"; await deps.haltAll(ctx, reason); }, }); }