#!/usr/bin/env node // progress.mjs — render a `pi --mode json` event stream as human-readable // progress on stdout, and write the final assistant message to a report file. // // Usage: pi --mode json "prompt" | node progress.mjs [--quiet] // // Quiet mode still writes the report file (for harness logs); it only // suppresses the progress display. // // Shows: tool calls, assistant text, thinking indicators, context // compaction, and provider auto-retries. // // Exit code: 0 for a clean run (agent_end seen, no model errors, final // assistant text received). 1 when the stream ended without agent_end // (child crashed or was killed), the model reported an error, or no final // assistant text was produced. `pi --mode json` itself exits 0 even on // model errors, so the harness relies on this exit code to detect failures. import { writeFileSync } from "node:fs"; const args = process.argv.slice(2); const reportPath = args.find((a) => !a.startsWith("--")); const quiet = args.includes("--quiet"); if (!reportPath) { console.error("usage: progress.mjs [--quiet]"); process.exit(2); } const useColor = process.stdout.isTTY && !process.env.NO_COLOR; const c = { dim: (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s), cyan: (s) => (useColor ? `\x1b[36m${s}\x1b[0m` : s), red: (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s), yellow: (s) => (useColor ? `\x1b[33m${s}\x1b[0m` : s), green: (s) => (useColor ? `\x1b[32m${s}\x1b[0m` : s), }; const out = (s = "") => { if (!quiet) process.stdout.write(s + "\n"); }; function truncate(s, n) { s = String(s ?? "").replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n - 1) + "…" : s; } function summarizeTool(name, a = {}) { switch (name) { case "bash": return "$ " + truncate(a.command ?? a.cmd, 110); case "read": return truncate(a.path, 90) + (a.offset ? `:${a.offset}` : ""); case "write": return truncate(a.path, 90); case "edit": return truncate(a.path, 90); case "grep": return `${truncate(a.pattern, 40)} in ${truncate(a.path ?? ".", 60)}`; case "find": return truncate(a.pattern, 40) + (a.path ? " in " + truncate(a.path, 60) : ""); case "ls": return truncate(a.path ?? ".", 90); default: { let s = ""; try { s = JSON.stringify(a); } catch { s = String(a); } return truncate(s, 100); } } } function textOf(message) { if (!message) return ""; if (typeof message.content === "string") return message.content; if (Array.isArray(message.content)) return message.content.filter((b) => b.type === "text").map((b) => b.text).join(""); return ""; } function wrap(text, indent, width = 100) { const pad = " ".repeat(indent); const lines = []; for (const raw of text.split("\n")) { if (!raw) { lines.push(""); continue; } let line = raw; while (line.length > width - indent) { let cut = line.lastIndexOf(" ", width - indent); if (cut < 20) cut = width - indent; lines.push(pad + line.slice(0, cut).trimEnd()); line = line.slice(cut).trimStart(); } lines.push(pad + line); } return lines.join("\n"); } let sessionId = ""; let lastUsage = null; let lastAssistantText = ""; let sawError = false; let sawAgentEnd = false; let thinkingSince = null; const failures = []; // Streaming deltas (text / thinking / tool-call args) arrive inside // message_update.assistantMessageEvent; only thinking is surfaced live. function handleUpdate(ame) { if (!ame || typeof ame !== "object") return; switch (ame.type) { case "thinking_start": thinkingSince = Date.now(); out(" " + c.dim("◐ thinking…")); break; case "thinking_end": if (thinkingSince !== null) { const secs = Math.max(1, Math.round((Date.now() - thinkingSince) / 1000)); out(c.dim(` ◑ thought for ${secs}s`)); thinkingSince = null; } break; default: break; } } function handle(ev) { switch (ev.type) { case "session": sessionId = ev.id ?? ""; break; case "agent_end": sawAgentEnd = true; break; case "tool_execution_start": out(" " + c.cyan("⏺ " + ev.toolName) + " " + c.dim(summarizeTool(ev.toolName, ev.args))); break; case "tool_execution_end": if (ev.isError) out(" " + c.red("✗ " + ev.toolName + " failed")); break; case "message_update": if (ev.usage) lastUsage = ev.usage; handleUpdate(ev.assistantMessageEvent); break; case "message_end": { const m = ev.message ?? {}; if (m.role === "assistant") { // Trim so trailing newlines in model output don't render as blank // lines after the message. const text = textOf(m).trim(); if (text) { lastAssistantText = text; out(wrap(text, 2)); } if (m.stopReason === "error" || m.stopReason === "aborted") { sawError = true; failures.push(`assistant ${m.stopReason}: ${m.errorMessage ?? ""}`); out(c.red(" ✗ " + (m.errorMessage ?? `assistant ${m.stopReason}`))); } } break; } case "compaction_start": out(" " + c.yellow("⧉ compacting context (" + (ev.reason ?? "auto") + ")…")); break; case "compaction_end": if (ev.aborted || ev.errorMessage) { sawError = true; failures.push(`compaction ${ev.aborted ? "aborted" : "failed"}: ${ev.errorMessage ?? ""}`); out( c.red( " ✗ compaction " + (ev.aborted ? "aborted" : "failed") + (ev.errorMessage ? ": " + ev.errorMessage : ""), ), ); } else { out(c.dim(" ✓ context compacted")); } break; case "auto_retry_start": out( c.yellow( ` ↻ provider error, retrying ${ev.attempt}/${ev.maxAttempts} in ${Math.round((ev.delayMs ?? 0) / 1000)}s: ${truncate( ev.errorMessage ?? "", 100, )}`, ), ); break; case "auto_retry_end": if (ev.success) { out(c.dim(" ✓ retry succeeded")); } else { sawError = true; failures.push(`retries exhausted: ${ev.finalError ?? ""}`); out(c.red(" ✗ retries exhausted: " + truncate(ev.finalError ?? "", 120))); } break; default: break; } } function processLine(line) { line = line.trim(); if (!line) return; let ev; try { ev = JSON.parse(line); } catch { out(c.yellow(" ? " + truncate(line, 120))); return; } handle(ev); } let buf = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { buf += chunk; let idx; while ((idx = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, idx); buf = buf.slice(idx + 1); processLine(line); } }); process.stdin.on("end", () => { // A trailing line without a final newline (child killed mid-flush) still // counts — process whatever is left in the buffer. if (buf.trim()) { processLine(buf); buf = ""; } writeFileSync( reportPath, lastAssistantText ? lastAssistantText + "\n" : "(no final assistant message — see the .err log)\n", ); const u = lastUsage ?? {}; const bits = []; if (u.input) bits.push(`↑${u.input}`); if (u.output) bits.push(`↓${u.output}`); if (u.cacheRead) bits.push(`R${u.cacheRead}`); if (sessionId) bits.push("session " + String(sessionId).slice(0, 8)); if (bits.length) out(c.dim(" · " + bits.join(" "))); if (!sawAgentEnd) { failures.push("stream ended without agent_end (child crashed or was killed)"); out(c.red(" ✗ stream ended without agent_end — child crashed or was killed (see .err log)")); } else if (sawError) { out(c.red(" ✗ child finished with errors: " + failures.join("; "))); } else if (!lastAssistantText) { failures.push("no final assistant text"); out(c.red(" ✗ no final assistant text — no report available (see .err log)")); } else { out(c.green(" · child done")); } process.exitCode = sawError || !sawAgentEnd || !lastAssistantText ? 1 : 0; });