- retries now resume the failed executor's session (pre/post session tracking replaces the broken mtime-vs-ref check) - report recovery from the session file when the JSON stream loses its tail (child signaled mid-flush) - progress.mjs: thinking indicators (◐ thinking… / ◑ thought for Ns), compaction (⧉ …) and provider auto-retry lines; trims trailing whitespace so no blank lines after LLM text; exits 1 on truncated stream, model error, or missing final text (pi --mode json always exits 0 even on errors) - explicit ✗ ERROR lines + exit codes: 0 ok, 1 phase failed, 130/143 interrupted (INT/TERM traps; post-pipeline check covers the case where bash suppresses the INT trap after a job dies from SIGINT) - PIPESTATUS captured in a single statement (any following command resets it) - skills dir: .gitignore README.md so pi's skill scanner (which honors .gitignore) stops warning 'description is required'
267 lines
8.0 KiB
JavaScript
267 lines
8.0 KiB
JavaScript
#!/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 <report-file> [--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 <report-file> [--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;
|
|
});
|