agent-polygraph 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ import os from "node:os";
2
+ import { AGENT_NAMES, ROWS, TESTED, columns, day, duration, pct, range, versionRange } from "./format.js";
3
+
4
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
5
+ const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
6
+ const bold = paint("1");
7
+ const dim = paint("2");
8
+ const red = paint("31");
9
+ const green = paint("32");
10
+ const yellow = paint("33");
11
+ const cyan = paint("36");
12
+
13
+ const VERDICT_LABEL = {
14
+ "no-run": "no test run in the session",
15
+ stale: "code edited after the last run",
16
+ failed: "the last test run had failed",
17
+ };
18
+
19
+ export function renderTerminal({ sessions, groups, seconds, by = "agent", examples = 5, demo = false }) {
20
+ const out = [];
21
+ const cols = columns(groups, by);
22
+ const all = groups.all;
23
+ const W = 44;
24
+ const CW = Math.max(12, ...cols.map((c) => c.label.length + 2));
25
+
26
+ const header = (title, sub) => {
27
+ out.push("");
28
+ out.push(bold(title) + (sub ? dim(" " + sub) : ""));
29
+ out.push(dim(" ".repeat(W) + cols.map((c) => c.label.padStart(CW)).join("")));
30
+ };
31
+ const row = (label, fn, colorize) => {
32
+ const cells = cols.map((c) => {
33
+ const v = fn(c.g);
34
+ const s = String(v).padStart(CW);
35
+ return colorize ? colorize(v, s) : s;
36
+ });
37
+ const padded = label.padEnd(W - 2);
38
+ out.push(" " + (label.startsWith("…") ? dim(padded) : padded) + cells.join(""));
39
+ };
40
+ const warnIfPositive = (v, s) => (typeof v === "number" && v > 0 ? yellow(s) : s);
41
+
42
+ out.push("");
43
+ if (!all.activeSessions) {
44
+ out.push(bold("agent-polygraph"));
45
+ out.push("");
46
+ out.push("No Claude Code or Codex sessions with commands or edits found.");
47
+ out.push(dim("Looked in ~/.claude/projects and ~/.codex/sessions (override with --claude-dir / --codex-dir)."));
48
+ return out.join("\n");
49
+ }
50
+ out.push(bold("agent-polygraph") + dim(` ${all.activeSessions} working sessions · ${range(all)} · ${seconds.toFixed(1)}s`));
51
+ if (demo) out.push(yellow("DEMO DATA: generated sessions, invented numbers. Run without --demo to check your own."));
52
+
53
+ header('"Tests pass" claims', "was there a passing run after the last code edit?");
54
+ ROWS.claims.forEach(([label, fn], i) => row(label, fn, i === 0 ? (v, s) => green(s) : i < 4 ? warnIfPositive : null));
55
+ // say why, instead of hiding unclear claims behind the receipt rate
56
+ ROWS.unclear.forEach(([label, fn]) => cols.some((c) => fn(c.g) > 0) && row(label, fn, (v, s) => dim(s)));
57
+ row(
58
+ bold("Receipt rate"),
59
+ (g) => pct(g.receiptRate),
60
+ (v, s) => bold(v === "–" ? s : parseInt(v) >= 95 ? green(s) : parseInt(v) >= 80 ? yellow(s) : red(s)),
61
+ );
62
+ // evidence that came from outside the agents' logs (record, --ci)
63
+ const ev = all.evidence || {};
64
+ const used = [
65
+ [ev.snapshot, "settled by working-tree snapshots"],
66
+ [ev.recorded, "read from recorded test output"],
67
+ [ev.terminal, "judged by test runs in your terminal"],
68
+ [ev.ci, "judged with GitHub Actions"],
69
+ ].filter(([n]) => n > 0);
70
+ if (used.length) out.push(dim(" Beyond the logs: " + used.map(([n, what]) => `${n} ${what}`).join(" · ")));
71
+
72
+ header("Test integrity", "worth a second look, not proof of cheating");
73
+ ROWS.integrity.forEach(([label, fn]) => row(label, fn, warnIfPositive));
74
+
75
+ header("Where the time went");
76
+ ROWS.time.forEach(([label, fn]) => row(label, fn));
77
+
78
+ header("You and the agent");
79
+ ROWS.you.forEach(([label, fn]) => row(label, fn));
80
+
81
+ if (sessions.unrecognized?.length) {
82
+ out.push("");
83
+ out.push(yellow(`${sessions.unrecognized.length} session file(s) are in a log format this version doesn't recognize and are not included.`) + dim(" Please report it, with your agent's version."));
84
+ }
85
+ if (sessions.skipped?.length) {
86
+ out.push("");
87
+ out.push(yellow(`${sessions.skipped.length} session file(s) couldn't be read and are not included.`) + dim(" Set POLYGRAPH_DEBUG=1 to see why."));
88
+ }
89
+
90
+ if (examples > 0) {
91
+ const all = sessions.flatMap((s) => s.findings);
92
+ const pick = (type) => all.filter((f) => f.type === type).sort((a, b) => b.ts - a.ts).slice(0, examples);
93
+ const claims = pick("claim");
94
+ const fixes = pick("test-only-fix");
95
+ const weak = pick("weakened-test");
96
+ const loops = pick("retry-loop");
97
+ const risky = pick("risky");
98
+ if (claims.length + fixes.length + weak.length + loops.length + risky.length) {
99
+ out.push("");
100
+ out.push(bold("Most recent examples") + dim(" (stays on your machine; --html never includes these)"));
101
+ }
102
+ for (const f of claims) {
103
+ out.push("");
104
+ out.push(` ${red("✗")} ${bold(VERDICT_LABEL[f.detail.verdict] + (f.detail.ci ? " (CI claim)" : ""))} ${where(f)}`);
105
+ out.push(` ${cyan("“" + f.detail.claim + "”")}`);
106
+ const lt = f.detail.lastTest;
107
+ if (lt) out.push(dim(` last test: ${lt.cmd} → ${lt.outcome ?? "?"}, ${duration(lt.ago)} earlier`));
108
+ if (f.detail.editedAfter) out.push(dim(` then edited: ${f.detail.editedAfter}`));
109
+ out.push(dim(` ${tilde(f.file)}`));
110
+ }
111
+ for (const f of fixes) {
112
+ out.push("");
113
+ out.push(` ${yellow("!")} ${bold("failing test made to pass by editing only tests")} ${where(f)}`);
114
+ out.push(dim(` edited: ${f.detail.tests.join(", ")}`));
115
+ out.push(dim(` then passed: ${f.detail.cmd}`));
116
+ out.push(dim(` ${tilde(f.file)}`));
117
+ }
118
+ for (const f of weak) {
119
+ out.push("");
120
+ out.push(` ${yellow("!")} ${bold(f.detail.reasons.join(", "))}: ${f.detail.path} ${where(f)}`);
121
+ if (f.detail.removed?.length) out.push(dim(` - ${f.detail.removed.slice(0, 2).join("\n - ")}`));
122
+ out.push(dim(` ${tilde(f.file)}`));
123
+ }
124
+ for (const f of loops) {
125
+ out.push("");
126
+ out.push(` ${yellow("↻")} ${bold("same failing command, 3+ times, nothing changed in between")} ${where(f)}`);
127
+ out.push(dim(` ${f.detail.cmd}`));
128
+ }
129
+ for (const f of risky) {
130
+ out.push("");
131
+ out.push(` ${yellow("!")} ${bold(f.detail.label)} ${where(f)}`);
132
+ out.push(dim(` ${f.detail.cmd}`));
133
+ }
134
+ }
135
+
136
+ const seen = ["claude", "codex"].map((a) => [a, versionRange(a, groups[`agent:${a}`]?.versions)]).filter(([, r]) => r);
137
+ if (seen.length) {
138
+ out.push("");
139
+ out.push(dim("Log versions: " + seen.map(([a, r]) => `${AGENT_NAMES[a]} ${r.text}`).join(" · ")));
140
+ for (const [a, r] of seen) if (r.newer) out.push(yellow(`${AGENT_NAMES[a]} logs are newer than the versions this release was tested with (up to ${TESTED[a]}). If numbers look off, please report it.`));
141
+ }
142
+ out.push("");
143
+ out.push(dim("How each number is measured: https://github.com/EleChon-pixel/agent-polygraph#how-it-decides"));
144
+ out.push(dim("A verdict looks wrong? Report it (redacted): https://github.com/EleChon-pixel/agent-polygraph/issues/new/choose"));
145
+ out.push(dim("Share a numbers-only card: npx agent-polygraph --html"));
146
+ out.push(dim("Catch these as they happen: npx agent-polygraph guard --install"));
147
+ if (!used.length) out.push(dim("See what the logs miss: npx agent-polygraph record --install (stays offline), or --ci (asks GitHub)"));
148
+ out.push("");
149
+ return out.join("\n");
150
+ }
151
+
152
+ function where(f) {
153
+ const project = f.cwd ? f.cwd.split("/").filter(Boolean).pop() : "";
154
+ return dim(`· ${AGENT_NAMES[f.agent]} · ${day(f.ts)}${project ? " · " + project : ""}`);
155
+ }
156
+
157
+ function tilde(p) {
158
+ const home = os.homedir();
159
+ return p && p.startsWith(home) ? "~" + p.slice(home.length) : p;
160
+ }
package/src/scan.js ADDED
@@ -0,0 +1,60 @@
1
+ import * as claude from "./sources/claude.js";
2
+ import * as codex from "./sources/codex.js";
3
+ import { analyzeSession } from "./analyze.js";
4
+ import { applyRecords } from "./record.js";
5
+
6
+ export const SOURCES = { claude, codex };
7
+
8
+ /**
9
+ * Discover, parse and analyze sessions.
10
+ * opts: { agents: ["claude","codex"], since: ms timestamp, project: substring, exclude: [id prefix or cwd substring],
11
+ * roots: {claude, codex}, records: false to ignore `record` data, enrich: async (session) => void,
12
+ * onProgress }
13
+ */
14
+ export async function scan(opts = {}) {
15
+ const agents = opts.agents?.length ? opts.agents : Object.keys(SOURCES);
16
+ let entries = [];
17
+ for (const a of agents) {
18
+ const src = SOURCES[a];
19
+ entries.push(...src.discover(opts.roots?.[a] || src.defaultRoot()));
20
+ }
21
+ if (opts.since) entries = entries.filter((e) => e.mtime >= opts.since);
22
+
23
+ const results = [];
24
+ results.skipped = []; // session files we couldn't read: a quiet gap would look like a full audit
25
+ results.unrecognized = []; // files with content in a format we don't know (a new agent version?)
26
+ let done = 0;
27
+ const queue = [...entries];
28
+ const worker = async () => {
29
+ while (queue.length) {
30
+ const entry = queue.shift();
31
+ try {
32
+ const session = await SOURCES[entry.agent].parse(entry);
33
+ if (opts.project && !(session.cwd || "").includes(opts.project)) continue;
34
+ // --exclude: a session id prefix or a piece of its working directory
35
+ if (opts.exclude?.some((x) => entry.id.startsWith(x) || (session.cwd || "").includes(x))) continue;
36
+ if (opts.since && session.end && session.end < opts.since) continue;
37
+ if (session.unrecognized) {
38
+ results.unrecognized.push({ file: entry.files[0], agent: entry.agent });
39
+ continue;
40
+ }
41
+ if (!session.events.length) continue;
42
+ // evidence outside the logs: `record` snapshots and terminal runs (local files),
43
+ // then --ci (GitHub) when asked for
44
+ if (opts.records !== false) applyRecords(session);
45
+ if (opts.enrich) await opts.enrich(session);
46
+ results.push(analyzeSession(session));
47
+ } catch (err) {
48
+ results.skipped.push({ file: entry.files[0], error: err.message });
49
+ if (process.env.POLYGRAPH_DEBUG) console.error(`skip ${entry.files[0]}: ${err.message}`);
50
+ } finally {
51
+ opts.onProgress?.(++done, entries.length);
52
+ }
53
+ }
54
+ };
55
+ await Promise.all(Array.from({ length: Math.min(8, entries.length || 1) }, worker));
56
+ const { skipped, unrecognized } = results;
57
+ results.sort((a, b) => (a.start ?? 0) - (b.start ?? 0));
58
+ Object.assign(results, { skipped, unrecognized });
59
+ return results;
60
+ }
@@ -0,0 +1,61 @@
1
+ # agent-polygraph record: logs test commands you run in this terminal (folder, start and end
2
+ # time, exit code; never their output) to ~/.agent-polygraph/records/terminal.jsonl, so a
3
+ # test you ran yourself counts as evidence. Stays on this machine.
4
+ # Remove with: agent-polygraph record --uninstall --shell
5
+ case $- in *i*) ;; *) return 0 2>/dev/null ;; esac
6
+ # not inside coding agents' own shells: their commands are in their logs already
7
+ [ -n "${CLAUDECODE:-}${CODEX_SANDBOX:-}${CODEX_THREAD_ID:-}${CODEX_MANAGED_BY_NPM:-}" ] && return 0 2>/dev/null
8
+
9
+ __apg_file="${AGENT_POLYGRAPH_HOME:-$HOME/.agent-polygraph}/records/terminal.jsonl"
10
+ # a cheap prefilter; agent-polygraph applies its real test-command rules when it reads the log
11
+ __apg_re='(test|spec|pytest|jest|vitest|mocha|rspec|phpunit|ctest|tox|nox|check|verify)'
12
+ __apg_cmd=
13
+
14
+ __apg_now() {
15
+ if [ -n "${EPOCHREALTIME:-}" ]; then printf '%s' "${EPOCHREALTIME/,/.}"; else date +%s; fi
16
+ }
17
+ __apg_esc() {
18
+ local s=$1
19
+ s=${s//\\/\\\\}; s=${s//\"/\\\"}; s=${s//$'\n'/\\n}; s=${s//$'\t'/\\t}; s=${s//$'\r'/}
20
+ printf '%s' "$s"
21
+ }
22
+ __apg_pre() {
23
+ __apg_cmd=
24
+ [[ $1 =~ $__apg_re ]] || return 0
25
+ __apg_cmd=$1
26
+ __apg_dir=$PWD
27
+ __apg_t0=$(__apg_now)
28
+ }
29
+ __apg_post() {
30
+ local s=$?
31
+ if [ -n "$__apg_cmd" ]; then
32
+ printf '{"v":1,"kind":"terminal","start":%s,"end":%s,"exit":%s,"cwd":"%s","cmd":"%s"}\n' \
33
+ "$__apg_t0" "$(__apg_now)" "$s" "$(__apg_esc "$__apg_dir")" "$(__apg_esc "$__apg_cmd")" >>"$__apg_file" 2>/dev/null
34
+ __apg_cmd=
35
+ fi
36
+ return $s
37
+ }
38
+
39
+ if [ -n "${ZSH_VERSION:-}" ]; then
40
+ autoload -Uz add-zsh-hook && add-zsh-hook preexec __apg_pre && add-zsh-hook precmd __apg_post
41
+ elif [ -n "${BASH_VERSION:-}" ]; then
42
+ if [ -n "${bash_preexec_imported:-}${__bp_imported:-}" ]; then
43
+ # bash-preexec is loaded: use its hooks instead of a DEBUG trap of our own
44
+ preexec_functions+=(__apg_pre)
45
+ precmd_functions=(__apg_post "${precmd_functions[@]}")
46
+ elif [ -z "$(trap -p DEBUG)" ]; then
47
+ # one DEBUG trap call per command line: $HISTCMD changes only when a new line is entered
48
+ __apg_last=$HISTCMD
49
+ __apg_debug() {
50
+ [[ ${BASH_COMMAND:-} == __apg_* ]] && return
51
+ [ -n "${COMP_LINE:-}" ] && return
52
+ [ "$HISTCMD" = "$__apg_last" ] && return
53
+ __apg_last=$HISTCMD
54
+ local h
55
+ h=$(HISTTIMEFORMAT= builtin history 1)
56
+ [[ $h =~ ^[[:space:]]*[0-9]+[*]?[[:space:]]+(.*)$ ]] && __apg_pre "${BASH_REMATCH[1]}"
57
+ }
58
+ trap '__apg_debug' DEBUG
59
+ PROMPT_COMMAND="__apg_post${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
60
+ fi
61
+ fi
@@ -0,0 +1,300 @@
1
+ // Claude Code transcripts: ~/.claude/projects/<project>/<session>.jsonl
2
+ // Subagent transcripts live in <session>/subagents/*.jsonl and are merged into the parent.
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+ import { readJsonl } from "../jsonl.js";
8
+
9
+ export const name = "claude";
10
+
11
+ export function defaultRoot() {
12
+ return path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"), "projects");
13
+ }
14
+
15
+ /** List sessions as { id, files: [main, ...subagents], mtime }. */
16
+ export function discover(root = defaultRoot()) {
17
+ const out = [];
18
+ let projects = [];
19
+ try {
20
+ projects = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory());
21
+ } catch {
22
+ return out;
23
+ }
24
+ for (const proj of projects) {
25
+ const dir = path.join(root, proj.name);
26
+ for (const f of safeReaddir(dir)) {
27
+ if (!f.endsWith(".jsonl")) continue;
28
+ const id = f.slice(0, -6);
29
+ const main = path.join(dir, f);
30
+ const subDir = path.join(dir, id, "subagents");
31
+ const subs = safeReaddir(subDir)
32
+ .filter((s) => s.endsWith(".jsonl"))
33
+ .map((s) => path.join(subDir, s));
34
+ out.push({ agent: name, id, files: [main, ...subs], mtime: mtime(main) });
35
+ }
36
+ }
37
+ return out;
38
+ }
39
+
40
+ // Cheap prefilter before JSON.parse. Whitespace-tolerant: anonymizers and exporters often
41
+ // re-serialize transcripts as `"type": "user"`.
42
+ // Hook runs are recorded as attachments carrying the command, its output and exit code.
43
+ const WANTED = /"type"\s*:\s*"(?:user|assistant|hook_success|hook_non_blocking_error|hook_blocking_error)"/;
44
+
45
+ // Event model (shared with codex.js): a command's start (`cmd`) and what was learned about
46
+ // it (`result`) are separate events, placed where they were observed. Edits are only
47
+ // emitted once their tool result confirms them. Analysis relies on event order.
48
+ export async function parse(entry) {
49
+ const session = { agent: name, id: entry.id, file: entry.files[0], cwd: null, models: new Set(), versions: new Set(), events: [], start: null, end: null };
50
+ const stats = { lines: 0, kept: 0 };
51
+ let userCmd = null; // a command you typed with `!`, waiting for its output
52
+ const pendingCmds = new Map(); // tool_use id -> cmd event awaiting its result
53
+ const pendingEdits = new Map(); // tool_use id -> edit events awaiting confirmation
54
+ const background = new Map(); // tool_use id -> backgrounded cmd event
55
+ const outputFiles = new Map(); // background output file -> cmd event
56
+ const reads = new Map(); // Read tool_use id -> cmd event whose output file it reads
57
+ // seq breaks timestamp ties in the order things were requested or observed
58
+ let seq = 0;
59
+ const push = (ev) => {
60
+ ev.seq ??= seq++;
61
+ session.events.push(ev);
62
+ };
63
+ const result = (of, ts, fields) => push({ kind: "result", of, ts, ...fields });
64
+
65
+ for (const [i, file] of entry.files.entries()) {
66
+ const isSub = i > 0;
67
+ for await (const rec of readJsonl(file, (line) => WANTED.test(line), stats)) {
68
+ const ts = Date.parse(rec.timestamp) || null;
69
+ if (rec.version && !isSub) session.versions.add(rec.version);
70
+ if (ts) {
71
+ session.start = Math.min(session.start ?? ts, ts);
72
+ session.end = Math.max(session.end ?? ts, ts);
73
+ }
74
+ if (!session.cwd && rec.cwd && !isSub) session.cwd = rec.cwd;
75
+ if (rec.gitBranch && rec.gitBranch !== "HEAD" && !isSub) session.branch = rec.gitBranch;
76
+ const msg = rec.message || {};
77
+ const content = msg.content;
78
+
79
+ // a hook (PostToolUse, Stop, …) that ran a command: tests run by hooks count too
80
+ if (rec.type === "attachment") {
81
+ const a = rec.attachment || {};
82
+ if (a.command) {
83
+ const ev = { kind: "cmd", ts, cmd: String(a.command), sub: isSub, hook: a.hookEvent || true };
84
+ push(ev);
85
+ result(ev, ts, { exitCode: typeof a.exitCode === "number" ? a.exitCode : null, isError: null, output: (String(a.stdout ?? "") + "\n" + String(a.stderr ?? "")).slice(-6000), ms: a.durationMs ?? null });
86
+ }
87
+ continue;
88
+ }
89
+
90
+ if (rec.type === "assistant") {
91
+ if (msg.model && !msg.model.startsWith("<")) session.models.add(msg.model);
92
+ if (!Array.isArray(content)) continue;
93
+ for (const block of content) {
94
+ if (block.type === "text" && !isSub) {
95
+ push({ kind: "say", ts, text: block.text });
96
+ } else if (block.type === "tool_use") {
97
+ if (block.name === "Read" && outputFiles.has(block.input?.file_path)) reads.set(block.id, outputFiles.get(block.input.file_path));
98
+ const evs = toolEvents(block, ts, isSub);
99
+ if (evs.length && evs[0].kind === "cmd") {
100
+ evs[0].id = block.id;
101
+ if (rec.gitBranch && rec.gitBranch !== "HEAD") evs[0].branch = rec.gitBranch;
102
+ // `tail -5 …/tasks/x.output` through the shell reads a background run's output too
103
+ const read = [...outputFiles.keys()].find((f) => evs[0].cmd.includes(f));
104
+ if (read) reads.set(block.id, outputFiles.get(read));
105
+ push(evs[0]);
106
+ pendingCmds.set(block.id, evs[0]);
107
+ } else if (evs.length) {
108
+ // an edit takes effect in request order, before any later tool call runs;
109
+ // it is only emitted if its result confirms it
110
+ for (const e of evs) e.seq = seq++;
111
+ pendingEdits.set(block.id, evs);
112
+ }
113
+ }
114
+ }
115
+ } else if (rec.type === "user") {
116
+ if (Array.isArray(content)) {
117
+ for (const block of content) {
118
+ if (block.type === "tool_result") {
119
+ const text = fullText(resultText(block.content));
120
+ const readOf = reads.get(block.tool_use_id);
121
+ if (readOf) {
122
+ // the agent reading a background run's output file: evidence about that run
123
+ reads.delete(block.tool_use_id);
124
+ result(readOf, ts, { exitCode: null, isError: null, output: text.slice(-6000), partial: true });
125
+ if (!pendingCmds.has(block.tool_use_id)) continue; // a Read; a shell read also gets its own result
126
+ }
127
+ const edits = pendingEdits.get(block.tool_use_id);
128
+ if (edits) {
129
+ pendingEdits.delete(block.tool_use_id);
130
+ if (block.is_error === true) continue; // rejected or failed: nothing changed
131
+ for (const e of edits) {
132
+ // Write overwrites as happily as it creates; only its result says which
133
+ if (e.write) e.op = /created successfully/i.test(text) ? "add" : /updated|overwr/i.test(text) ? "update" : "unknown";
134
+ push(e);
135
+ }
136
+ continue;
137
+ }
138
+ const ev = pendingCmds.get(block.tool_use_id);
139
+ if (!ev) continue;
140
+ pendingCmds.delete(block.tool_use_id);
141
+ // refused by a permission prompt, the auto-mode classifier or a hook: it never ran
142
+ if (block.is_error === true && DENIED.test(text)) {
143
+ result(ev, ts, { denied: true, exitCode: null, isError: true, output: "" });
144
+ continue;
145
+ }
146
+ if (/running in background|backgrounded command/i.test(text) && !/^Exit code/.test(text)) {
147
+ // result arrives later through a <task-notification> or a Read of the output file
148
+ background.set(block.tool_use_id, ev);
149
+ const file = /(\/\S+\.output)\b/.exec(text)?.[1];
150
+ if (file) outputFiles.set(file, ev);
151
+ continue;
152
+ }
153
+ const m = /^Exit code (-?\d+)/.exec(text);
154
+ const isError = block.is_error === true;
155
+ result(ev, ts, { exitCode: m ? Number(m[1]) : isError ? null : 0, isError, output: text.slice(-6000), ms: ts && ev.ts ? ts - ev.ts : null });
156
+ } else if (block.type === "text" && !isSub && /^\[Request interrupted by user/.test(block.text || "")) {
157
+ push({ kind: "interrupt", ts });
158
+ }
159
+ }
160
+ }
161
+ if (typeof content === "string" && content.startsWith("<task-notification")) {
162
+ const id = /<tool-use-id>([^<]+)</.exec(content)?.[1];
163
+ const ev = background.get(id);
164
+ if (ev) {
165
+ const code = /exit code (-?\d+)/i.exec(content);
166
+ const status = /<status>(\w+)</.exec(content)?.[1];
167
+ const file = /<output-file>([^<]+)</.exec(content)?.[1];
168
+ if (file) outputFiles.set(file, ev);
169
+ result(ev, ts, {
170
+ exitCode: code ? Number(code[1]) : status === "completed" ? 0 : status === "failed" ? 1 : null,
171
+ isError: null,
172
+ output: "",
173
+ ms: ts && ev.ts ? ts - ev.ts : null,
174
+ });
175
+ }
176
+ }
177
+ // commands you typed yourself with `!`: <bash-input>, then <bash-stdout>/<bash-stderr>
178
+ if (typeof content === "string" && /^\s*<bash-input>/.test(content)) {
179
+ const cmd = unescape(/<bash-input>([\s\S]*?)<\/bash-input>/.exec(content)?.[1] ?? "").trim();
180
+ if (cmd) {
181
+ userCmd = { kind: "cmd", ts, cmd, sub: false, byUser: true };
182
+ push(userCmd);
183
+ }
184
+ continue;
185
+ }
186
+ if (typeof content === "string" && /^\s*<bash-std(?:out|err)>/.test(content) && userCmd) {
187
+ const out = [...content.matchAll(/<bash-std(?:out|err)>([\s\S]*?)<\/bash-std(?:out|err)>/g)].map((m) => unescape(m[1])).join("\n");
188
+ result(userCmd, ts, { exitCode: null, isError: null, output: out.slice(-6000), ms: ts && userCmd.ts ? ts - userCmd.ts : null });
189
+ userCmd = null;
190
+ continue;
191
+ }
192
+ if (!isSub && isHumanPrompt(rec)) {
193
+ push({ kind: "user", ts, text: typeof content === "string" ? content : textOf(content) });
194
+ }
195
+ }
196
+ }
197
+ }
198
+ // Edits whose outcome never arrived may or may not have happened.
199
+ for (const evs of pendingEdits.values()) push({ kind: "uncertain", ts: evs[0].ts, seq: evs[0].seq, reason: "edit with no result" });
200
+ session.versions = [...session.versions];
201
+ // lines in the file but none we recognize: probably a log format we don't know yet
202
+ session.unrecognized = stats.lines > 5 && stats.kept === 0;
203
+ // stable: events with equal timestamps keep the order they were observed in
204
+ session.events.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0) || a.seq - b.seq);
205
+ session.models = [...session.models];
206
+ return session;
207
+ }
208
+
209
+ // Answers the agent gets instead of a command's output when it wasn't allowed to run
210
+ const DENIED = /^(?:Permission for this action was denied|Permission to use \S+ (?:with command .* )?has been denied|The user doesn't want to proceed with this tool use|\S+ operation blocked by hook|.{0,40}hook (?:blocked|denied)|User rejected)/is;
211
+
212
+ /**
213
+ * Output too large for the transcript is saved to a file, and the transcript keeps only
214
+ * a preview (`<persisted-output>… Full output saved to: /path …`). The runner's summary is
215
+ * at the end, so read the end of the saved file when it still exists.
216
+ */
217
+ function fullText(text) {
218
+ if (!text.includes("<persisted-output>")) return text;
219
+ const file = /Full output saved to: (\S+?\.txt)\b/.exec(text)?.[1];
220
+ if (!file) return text;
221
+ try {
222
+ const fd = fs.openSync(file, "r");
223
+ try {
224
+ const size = fs.fstatSync(fd).size;
225
+ const n = Math.min(size, 16384);
226
+ const buf = Buffer.alloc(n);
227
+ fs.readSync(fd, buf, 0, n, size - n);
228
+ return text + "\n" + buf.toString("utf8");
229
+ } finally {
230
+ fs.closeSync(fd);
231
+ }
232
+ } catch {
233
+ return text;
234
+ }
235
+ }
236
+
237
+ function isHumanPrompt(rec) {
238
+ if (rec.isMeta || rec.isSidechain) return false;
239
+ const kind = rec.origin?.kind;
240
+ if (kind) return kind === "human";
241
+ const c = rec.message?.content;
242
+ const text = typeof c === "string" ? c : Array.isArray(c) && c.every((b) => b.type === "text") ? textOf(c) : null;
243
+ if (!text) return false;
244
+ return !/^\s*(?:<[a-z_-]+[\s>]|\[(?:Request interrupted|Image)|Caveat:)/.test(text);
245
+ }
246
+
247
+ function toolEvents(block, ts, isSub) {
248
+ const input = block.input || {};
249
+ switch (block.name) {
250
+ case "Bash":
251
+ case "bash":
252
+ case "PowerShell": // Claude Code on Windows; same result format as Bash
253
+ case "powershell":
254
+ return input.command ? [{ kind: "cmd", ts, cmd: String(input.command), sub: isSub }] : [];
255
+ case "Edit":
256
+ return [edit(input.file_path, input.old_string, input.new_string, ts)];
257
+ case "MultiEdit":
258
+ return (input.edits || []).map((e) => edit(input.file_path, e.old_string, e.new_string, ts));
259
+ case "Write":
260
+ return [{ kind: "edit", ts, path: input.file_path || "", removed: null, added: lines(input.content), write: true }];
261
+ case "NotebookEdit":
262
+ return [{ kind: "edit", ts, path: input.notebook_path || "", removed: [], added: lines(input.new_source) }];
263
+ default:
264
+ return [];
265
+ }
266
+ }
267
+
268
+ function edit(p, oldS, newS, ts) {
269
+ return { kind: "edit", ts, path: p || "", removed: lines(oldS), added: lines(newS) };
270
+ }
271
+
272
+ const lines = (s) => (typeof s === "string" && s.length ? s.split("\n") : []);
273
+
274
+ function resultText(c) {
275
+ if (typeof c === "string") return c;
276
+ if (Array.isArray(c)) return c.map((b) => (typeof b === "string" ? b : b.text || "")).join("\n");
277
+ return "";
278
+ }
279
+
280
+ const unescape = (s) => s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, "&");
281
+
282
+ function textOf(content) {
283
+ return Array.isArray(content) ? content.filter((b) => b.type === "text").map((b) => b.text).join("\n") : "";
284
+ }
285
+
286
+ function safeReaddir(d) {
287
+ try {
288
+ return fs.readdirSync(d);
289
+ } catch {
290
+ return [];
291
+ }
292
+ }
293
+
294
+ function mtime(f) {
295
+ try {
296
+ return fs.statSync(f).mtimeMs;
297
+ } catch {
298
+ return 0;
299
+ }
300
+ }