/** * Background-run UX, mirroring Claude Code: * - A live task panel below the input lists in-progress runs while you keep working. * It is informational; run /workflows to open the full navigator. * - When a background run finishes, its result is delivered back into the * conversation so the paused task continues with the outcome. */ import { join } from "node:path"; import type { ExtensionAPI, ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent"; import { type Component, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { aggregateAgentUsage, fmtCost, fmtTokenSegment, shorten, statusIcon, tokenFigures, type WorkflowAgentSnapshot, type WorkflowSnapshot, } from "./display.js"; import type { ManagedRun, WorkflowManager } from "./workflow-manager.js"; import type { WorkflowStorage } from "./workflow-saved.js"; import type { WorkflowSettings } from "./workflow-settings.js"; import { shortModel } from "./workflow-ui.js"; // `tokenUsage` is included so the detailed panel's live token/s counter refreshes // as tokens accrue (not only on agent start/end). It is harmless in compact mode — // it redraws identical content. const RUN_EVENTS = [ "agentStart", "agentEnd", "phase", "log", "tokenUsage", "complete", "error", "stopped", "paused", "resumed", "forked", ]; /** Events after which a run is gone and its token-rate samples can be dropped. */ const RUN_END_EVENTS = ["complete", "error", "stopped"] as const; export interface TaskPanelOptions { storage?: WorkflowStorage; cwd?: string; /** * Live settings loader. When provided, the panel reads it fresh (with a short * TTL cache) on each render so `/workflows-progress` takes effect without a * restart. Omitted in tests / minimal hosts → always compact. */ loadSettings?: () => WorkflowSettings; } /** Default cap on the JSON-dump fallback in a delivered result summary. Overridable * via the `deliveredResultMaxChars` setting in ~/.pi/workflows/settings.json. */ const DEFAULT_DELIVERED_MAX_CHARS = 400; /** Human-readable byte size for the dropped-tail hint: 512 B, 3.2 KB, 1.4 MB. */ function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } /** * Pick a clean human-readable summary from a workflow result, in order of * preference: a `verdict`/`report`/`summary`/`synthesis` string field, a bare * string result, else a JSON dump capped at `maxChars`. When the dump is truncated the * dropped size is reported (the full result is still reachable via the pointer * that {@link deliverText} appends). */ function summarizeResult(result: unknown, maxChars: number = DEFAULT_DELIVERED_MAX_CHARS): string { if (typeof result === "string") return result; if (result == null) return "null"; if (typeof result === "object") { const obj = result as Record; // `synthesis` is what the built-in multi-perspective workflow returns. for (const key of ["verdict", "report", "summary", "synthesis"] as const) { const val = obj[key]; if (typeof val === "string" && val.trim()) return val; } } const json = JSON.stringify(result, null, 2); if (json.length <= maxChars) return json; // Slice once (the kept head); derive the dropped size by byte-length subtraction // so we don't also allocate the (potentially large) truncated tail to measure it. const kept = json.slice(0, maxChars); const droppedBytes = Buffer.byteLength(json, "utf8") - Buffer.byteLength(kept, "utf8"); return `${kept}\n…(truncated ${formatBytes(droppedBytes)})`; } function fitLine(line: string, width?: number): string { if (typeof width !== "number" || !Number.isFinite(width)) return line; const maxWidth = Math.max(0, Math.floor(width)); if (visibleWidth(line) <= maxWidth) return line; return truncateToWidth(line, maxWidth); } export function deliverText(run: ManagedRun, opts: { resultPath?: string; maxChars?: number } = {}): string { const summary = summarizeResult(run.result?.result, opts.maxChars); const tu = run.result?.tokenUsage; const cost = tu?.cost ? ` · ${fmtCost(tu.cost)}` : ""; const segment = fmtTokenSegment(tokenFigures(tu), fmtTokensShort); const tokens = `${segment ? ` · ${segment}` : ""}${cost}`; const agents = run.result?.agentCount ?? run.snapshot.agentCount; const duration = run.result?.durationMs ? ` · ${(run.result.durationMs / 1000).toFixed(1)}s` : ""; const revision = run.forkedFromRunId ? ` revision ${run.runId} (forked from ${run.forkedFromRunId}${ run.rootRunId && run.rootRunId !== run.forkedFromRunId ? `; root ${run.rootRunId}` : "" })` : ""; const lines = [ `✓ Background workflow "${run.snapshot.name}"${revision} finished (${agents} agents${tokens}${duration}).`, "", summary, ]; // Always point at the full persisted result so the tail is never lost — even when // the summary above is a complete verdict/summary field or an untruncated dump. if (opts.resultPath) lines.push("", `↳ Full result: ${opts.resultPath}`); return lines.join("\n"); } /** Absolute path to a run's persisted result JSON. Undefined if the persistence * layer can't be resolved — delivery must never throw in the complete handler. */ function persistedResultPath(manager: WorkflowManager, runId: string): string | undefined { try { return join(manager.getPersistence().getRunsDir(), `${runId}.json`); } catch { return undefined; } } /** Delivered JSON-dump truncation threshold from settings (already normalized), * defaulting to 400 when unset or unreadable. */ function deliveredMaxChars(opts: { loadSettings?: () => WorkflowSettings }): number { try { return opts.loadSettings?.().deliveredResultMaxChars ?? DEFAULT_DELIVERED_MAX_CHARS; } catch { return DEFAULT_DELIVERED_MAX_CHARS; } } /** * When a background run finishes (or fails), deliver its result back into the * conversation AND continue the turn so the assistant can act on it — without * blocking the user meanwhile: * * - `triggerTurn: true` starts a fresh turn when the agent is idle, feeding the * result to the model so the paused conversation continues. * - `deliverAs: "followUp"` means that if the user is busy in another turn, the * result is queued and picked up after that turn finishes — never interrupting. * * Set up once per extension; idempotent via an internal guard. */ export function installResultDelivery( pi: ExtensionAPI, manager: WorkflowManager, opts: { loadSettings?: () => WorkflowSettings } = {}, ): void { // Mutable holder on the manager shared by extension generations across /reload. const m = manager as unknown as { __deliveryInstalled?: boolean; __holder?: { pi: ExtensionAPI; loadSettings?: () => WorkflowSettings }; }; if (m.__deliveryInstalled) { // The manager and listeners survive /reload. Refresh every generation-bound // dependency while leaving listener registration exactly-once. if (m.__holder) { m.__holder.pi = pi; m.__holder.loadSettings = opts.loadSettings; } return; } m.__deliveryInstalled = true; m.__holder = { pi, loadSettings: opts.loadSettings }; const deliver = (content: string) => { try { const ret = m.__holder?.pi.sendMessage( { customType: "workflow-result", content, display: true }, { triggerTurn: true, deliverAs: "followUp" }, ); // sendMessage may return a promise; a sync try/catch can't catch its // rejection, so swallow the async path too. A stale ctx after /reload is // the expected failure — the result is still visible via /workflows. void Promise.resolve(ret).catch(() => {}); } catch { // Synchronous failure (e.g. stale ctx) — result still visible via /workflows. } }; manager.on("complete", ({ runId }: { runId: string }) => { const run = manager.getRun(runId); // Only background/resumed runs are delivered: a foreground (sync) run already // returns its result inline as the tool result, so re-delivering would dup it. if (run?.background) { deliver( deliverText(run, { resultPath: persistedResultPath(manager, runId), maxChars: deliveredMaxChars({ loadSettings: m.__holder?.loadSettings }), }), ); } }); manager.on("error", ({ runId, error }: { runId: string; error?: { message?: string } }) => { const run = manager.getRun(runId); if (!run?.background) return; const resumeHint = run.status === "failed" ? ` Resume the same run ID with workflow resumeFromRunId="${runId}", optionally with an edited script.` : ""; deliver(`✗ Background workflow ${runId} failed: ${error?.message ?? "unknown error"}.${resumeHint}`); }); // A provider usage/quota limit checkpoints the run as paused (not failed): tell the // user it is resumable once their budget refills, rather than letting it look dead. // Manual pause() also emits "paused" but with no reason — guard so only the // usage-limit case delivers a message. manager.on( "paused", ({ runId, reason, error, resetHint, }: { runId: string; reason?: string; error?: { message?: string }; resetHint?: string; }) => { if (reason !== "usage_limit") return; if (!manager.getRun(runId)?.background) return; const when = resetHint ? ` (${resetHint})` : ""; const cause = error?.message ?? "provider usage limit reached"; deliver( `⏸ Background workflow ${runId} paused: ${cause}${when}. ` + `Completed steps are saved — run /workflows resume ${runId} once your usage limit resets.`, ); }, ); } export function renderPanel(manager: WorkflowManager, theme: Theme, width?: number): string[] { const all = manager.listRuns(); const active = all.filter((r) => r.status === "running" || r.status === "paused"); if (!active.length) return []; const rows = active.map((r) => { const live = manager.getRun(r.runId); const agents = live?.snapshot.agents ?? r.agents; const done = agents.filter((a) => a.status === "done").length; const icon = r.status === "paused" ? "⏸" : "◆"; const phase = live?.snapshot.currentPhase ? ` · ${live.snapshot.currentPhase}` : ""; const executor = r.defaultExecutor ? ` · ${r.defaultExecutor}` : ""; return ` ${icon} ${r.workflowName} ${done}/${agents.length} agents${executor}${phase}`; }); // Finished runs leave this live panel but are kept in the navigator. Tell the // user so a completed run doesn't look like it vanished. const finished = all.filter((r) => r.status !== "running" && r.status !== "paused").length; const hint = theme.fg( "dim", finished > 0 ? ` /workflows — open navigator (${finished} finished kept in history)` : " /workflows — open navigator", ); return [theme.bold(`Workflows running (${active.length}):`), ...rows, hint].map((line) => fitLine(line, width)); } // ─── Detailed mode: live token rate ──────────────────────────────────────────── /** Rolling window for the token/s rate. Older samples age out so a stall decays to 0. */ const RATE_WINDOW_MS = 10_000; /** Per-run (timestamp, cumulative total) samples, keyed by the persisted runId so * the rolling rate survives pause→resume. Cleared when a run ends. */ const tokenSamples = new Map>(); /** Record a token-total sample for `runId` at time `now` (ms). */ export function sampleTokens(runId: string, total: number, now: number): void { const samples = tokenSamples.get(runId) ?? []; const last = samples[samples.length - 1]; // Collapse repeat renders within the same instant (e.g. width recalcs). if (last && last.ts === now && last.total === total) return; samples.push({ ts: now, total }); // Drop samples beyond the rolling window, always keeping ≥2 so a rate is computable. while (samples.length > 2 && now - samples[0].ts > RATE_WINDOW_MS) samples.shift(); tokenSamples.set(runId, samples); } /** Tokens/second over the rolling window; 0 when too few samples or totals plateau. */ export function tokensPerSecond(runId: string): number { const samples = tokenSamples.get(runId); if (!samples || samples.length < 2) return 0; const oldest = samples[0]; const newest = samples[samples.length - 1]; const elapsedMs = newest.ts - oldest.ts; if (elapsedMs <= 0) return 0; const delta = newest.total - oldest.total; if (delta <= 0) return 0; return (delta / elapsedMs) * 1000; } /** Forget a run's samples (call when it finishes) so the map can't grow unbounded. */ export function clearTokenSamples(runId: string): void { tokenSamples.delete(runId); } /** Compact token count for the space-constrained panel: 980, 12.4K, 1.3M. */ function fmtTokensShort(n: number): string { if (!Number.isFinite(n) || n <= 0) return ""; if (n < 1000) return `${Math.round(n)}`; if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`; return `${(n / 1_000_000).toFixed(1)}M`; } /** Normalize the configured per-phase agent cap to a sane integer (default 8). */ export function clampMaxAgents(value: number | undefined): number { if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return 8; return Math.min(1000, Math.floor(value)); } /** Per-phase + per-agent body for one run in detailed mode (mirrors renderWorkflowLines). */ function renderRunBody( snap: WorkflowSnapshot, agents: WorkflowAgentSnapshot[], maxAgents: number, theme: Theme, ): string[] { const dim = (t: string) => theme.fg("dim", t); const lines: string[] = []; // Group agents by phase, declared order first then discovery order (as the navigator does). const order = snap.phases.length ? [...snap.phases] : []; const byPhase = new Map(); for (const a of agents) { const key = a.phase ?? "(no phase)"; if (!byPhase.has(key)) byPhase.set(key, []); byPhase.get(key)?.push(a); if (!order.includes(key)) order.push(key); } for (const title of order) { const phaseAgents = byPhase.get(title) ?? []; if (!phaseAgents.length) continue; const done = phaseAgents.filter((a) => a.status === "done").length; const running = phaseAgents.filter((a) => a.status === "running").length; const errors = phaseAgents.filter((a) => a.status === "error").length; const skipped = phaseAgents.filter((a) => a.status === "skipped").length; const complete = done + errors + skipped === phaseAgents.length; const marker = running > 0 || (!complete && snap.currentPhase === title) ? "▶" : complete ? "✓" : " "; const phaseMeta = [ `${done}/${phaseAgents.length} agents`, running ? `${running} running` : "", errors ? `${errors} errors` : "", fmtTokenSegment(aggregateAgentUsage(phaseAgents), fmtTokensShort), ] .filter(Boolean) .join(" · "); lines.push(theme.fg("accent", ` ${marker} ${title}`) + dim(` ${phaseMeta}`)); const visible = phaseAgents.slice(-maxAgents); for (const a of visible) { const segment = fmtTokenSegment(tokenFigures(a.tokenUsage, a.tokens), fmtTokensShort); const tok = segment ? dim(` ${segment}`) : ""; const mdl = shortModel(a.model); const model = mdl ? dim(` · ${mdl}`) : ""; const executor = a.executor ? dim(` · ${a.executor}`) : ""; lines.push(` [${a.id}] ${statusIcon(a.status)} ${shorten(a.label, 40)}${tok}${model}${executor}`); } if (phaseAgents.length > visible.length) { lines.push(dim(` … ${phaseAgents.length - visible.length} earlier agents`)); } } return lines; } /** * Detailed variant of {@link renderPanel}: per-run header with aggregate tokens, * cost, and a live token/s rate, followed by per-phase progress and per-agent rows * (capped at `maxAgents` per phase). `now` is injected for testability. */ export function renderPanelDetailed( manager: WorkflowManager, theme: Theme, width: number | undefined, maxAgents: number, now: number, ): string[] { const all = manager.listRuns(); const active = all.filter((r) => r.status === "running" || r.status === "paused"); if (!active.length) return []; const dim = (t: string) => theme.fg("dim", t); const out: string[] = [theme.bold(`Workflows running (${active.length}):`)]; for (const r of active) { const live = manager.getRun(r.runId); const snap = live?.snapshot; const agents = (snap?.agents ?? r.agents) as WorkflowAgentSnapshot[]; const done = agents.filter((a) => a.status === "done").length; const icon = r.status === "paused" ? "⏸" : "◆"; const usage = snap?.tokenUsage ?? r.tokenUsage; // The run-level tokenUsage aggregate is only finalized when the run ends, so // it reads 0 for the whole live run; per-agent figures update on each agent // completion, so aggregate those instead. The rate samples the same // fresh+cacheRead sum the header displays, so tok/s tracks the visible // figures. Tokens land at agent-completion granularity, so the rate reflects // completion throughput — it decays to 0 during a single long-running agent // or a stall (which is the intended signal). Paused runs don't accrue // tokens, so their rate is suppressed (a stalled rate would mislead). const runUsage = aggregateAgentUsage(agents); sampleTokens(r.runId, runUsage.fresh + runUsage.cacheRead, now); const rate = r.status === "running" ? tokensPerSecond(r.runId) : 0; const meta = [ `${done}/${agents.length} agents`, r.defaultExecutor ?? snap?.executor ?? "pi", snap?.currentPhase || "", fmtTokenSegment(runUsage, fmtTokensShort), // (cost is only known once the run finalizes its usage.) usage?.cost ? fmtCost(usage.cost) : "", rate > 0 ? `${Math.round(rate)} tok/s` : "", ] .filter(Boolean) .join(" · "); out.push(` ${icon} ${theme.bold(r.workflowName)} ${dim(meta)}`); if (snap) out.push(...renderRunBody(snap, agents, maxAgents, theme)); } const finished = all.filter((r) => r.status !== "running" && r.status !== "paused").length; out.push( dim( finished > 0 ? ` /workflows — open navigator (${finished} finished kept in history)` : " /workflows — open navigator", ), ); return out.map((line) => fitLine(line, width)); } /** * Install the live "workflows running" panel below the editor. Re-rendered on * every manager event. Informational only — the user opens the navigator with * /workflows. (`_pi` is kept for signature stability.) */ export function installTaskPanel( _pi: ExtensionAPI, manager: WorkflowManager, ui: ExtensionUIContext, opts: TaskPanelOptions = {}, ): void { // Live-read settings with a ~1s TTL: a render-path disk read every frame would // be wasteful, but re-reading at most once a second still makes // /workflows-progress take effect "immediately" (no restart). let cached: WorkflowSettings = {}; let cachedAt = Number.NEGATIVE_INFINITY; const settings = (): WorkflowSettings => { if (!opts.loadSettings) return cached; const now = Date.now(); if (now - cachedAt > 1000) { try { cached = opts.loadSettings() ?? {}; } catch { cached = {}; } cachedAt = now; } return cached; }; const hasActiveRun = () => manager.listRuns().some((r) => r.status === "running" || r.status === "paused"); ui.setWidget( "workflow-tasks", (tui: TUI, theme: Theme) => { const onEvent = () => tui.requestRender(); for (const ev of RUN_EVENTS) manager.on(ev, onEvent); const onRunEnd = ({ runId }: { runId: string }) => clearTokenSamples(runId); for (const ev of RUN_END_EVENTS) manager.on(ev, onRunEnd); // In detailed mode, force a redraw every 2s while a run is active so the // token/s rate keeps updating between sparse token events — and decays to 0 // when an agent stalls. Gated + unref'd so it costs nothing when idle. const timer = setInterval(() => { if (settings().progressPanelMode === "detailed" && hasActiveRun()) tui.requestRender(); }, 2000); (timer as { unref?: () => void }).unref?.(); // Purely informational: it lists running runs and re-renders on events. To // open the navigator, the user runs /workflows (the panel takes no input). const comp: Component & { dispose?(): void } = { render: (width: number) => { const s = settings(); if (s.progressPanelMode === "detailed") { return renderPanelDetailed(manager, theme, width, clampMaxAgents(s.progressPanelMaxAgents), Date.now()); } return renderPanel(manager, theme, width); }, invalidate: () => {}, dispose: () => { clearInterval(timer); for (const ev of RUN_EVENTS) manager.off(ev, onEvent); for (const ev of RUN_END_EVENTS) manager.off(ev, onRunEnd); }, }; return comp; }, { placement: "belowEditor" }, ); }