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.
- package/LICENSE +21 -0
- package/README.md +177 -2
- package/bin/polygraph.js +261 -0
- package/package.json +38 -4
- package/src/analyze.js +611 -0
- package/src/ci.js +204 -0
- package/src/classify.js +506 -0
- package/src/demo.js +120 -0
- package/src/guard.js +216 -0
- package/src/jsonl.js +23 -0
- package/src/record.js +238 -0
- package/src/report/format.js +87 -0
- package/src/report/html.js +101 -0
- package/src/report/terminal.js +160 -0
- package/src/scan.js +60 -0
- package/src/shell/record.sh +61 -0
- package/src/sources/claude.js +300 -0
- package/src/sources/codex.js +476 -0
|
@@ -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(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/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
|
+
}
|