agentbox-flight-recorder 0.2.1
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 +306 -0
- package/action.yml +52 -0
- package/bin/agentbox.js +3 -0
- package/docs/index.html +308 -0
- package/examples/fake-agent.js +56 -0
- package/examples/fake-mcp-server.js +92 -0
- package/package.json +53 -0
- package/scripts/preflight.sh +324 -0
- package/src/adapters/claude.js +308 -0
- package/src/adapters/mcp.js +346 -0
- package/src/chain.js +286 -0
- package/src/cli.js +262 -0
- package/src/clip.js +196 -0
- package/src/parse.js +265 -0
- package/src/receipt.js +176 -0
- package/src/redact.js +231 -0
- package/src/replay.js +412 -0
- package/src/wrap.js +310 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* agentbox — cli.js
|
|
4
|
+
* Zero-dependency argv router. Every command works offline, no accounts,
|
|
5
|
+
* no telemetry. Sessions live in ./.agentbox/sessions/ next to your repo.
|
|
6
|
+
*/
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { VERSION, verifyChain, sessionsDir } = require('./chain');
|
|
10
|
+
const { wrap } = require('./wrap');
|
|
11
|
+
const { replay, renderStatic } = require('./replay');
|
|
12
|
+
const { receipt } = require('./receipt');
|
|
13
|
+
const { clip } = require('./clip');
|
|
14
|
+
const { summarize, fmtDuration, stripAnsi } = require('./parse');
|
|
15
|
+
const { runHook, initClaude } = require('./adapters/claude');
|
|
16
|
+
const { runMcpProxy, initMcp } = require('./adapters/mcp');
|
|
17
|
+
|
|
18
|
+
const CYAN = '\x1b[36m';
|
|
19
|
+
const BOLD = '\x1b[1m';
|
|
20
|
+
const DIM = '\x1b[2m';
|
|
21
|
+
const GREEN = '\x1b[32m';
|
|
22
|
+
const RED = '\x1b[31m';
|
|
23
|
+
const RESET = '\x1b[0m';
|
|
24
|
+
const safeTerminal = (s) => stripAnsi(String(s == null ? '' : s)).replace(/[\x00-\x1f\x7f-\x9f]/g, '');
|
|
25
|
+
|
|
26
|
+
const HELP = `
|
|
27
|
+
${CYAN}${BOLD}⬢ agentbox v${VERSION}${RESET} — the black-box flight recorder for AI agents
|
|
28
|
+
|
|
29
|
+
${BOLD}usage${RESET}: agentbox <command> [options]
|
|
30
|
+
|
|
31
|
+
${BOLD}wrap${RESET} [--name n] -- <cmd…> record a command/agent session (black box on)
|
|
32
|
+
${BOLD}init${RESET} claude [--local] passive mode: claude code hooks (one command)
|
|
33
|
+
${BOLD}init${RESET} mcp -- <server cmd> print MCP wire-tap config for your client
|
|
34
|
+
${BOLD}mcp${RESET} [--name n] -- <server…> run an MCP server behind the recording proxy
|
|
35
|
+
${BOLD}hook${RESET} (internal) record one claude code hook event from stdin
|
|
36
|
+
${BOLD}demo${RESET} record a scripted demo agent, then explore it
|
|
37
|
+
${BOLD}list${RESET} list recorded sessions
|
|
38
|
+
${BOLD}replay${RESET} [file] scrub through a session like security footage
|
|
39
|
+
${BOLD}receipt${RESET} [file] [--md|--json] one-page summary of what happened
|
|
40
|
+
${BOLD}clip${RESET} [file] [--from s --to s] export a shareable, self-contained HTML clip
|
|
41
|
+
${BOLD}verify${RESET} [file] check the local sha256 hash chain
|
|
42
|
+
${BOLD}help${RESET} show this help
|
|
43
|
+
|
|
44
|
+
${DIM}sessions live in ./.agentbox/sessions/ · zero deps · 100% local · no telemetry
|
|
45
|
+
your agent has root. who's watching?${RESET}
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
function parseFlags(args) {
|
|
49
|
+
const flags = { _: [] };
|
|
50
|
+
const valueFlags = new Set(['name', 'from', 'to', 'out', 'tail']);
|
|
51
|
+
for (let i = 0; i < args.length; i++) {
|
|
52
|
+
const a = args[i];
|
|
53
|
+
if (a === '--') { flags._.push(...args.slice(i + 1)); break; }
|
|
54
|
+
if (a.startsWith('--')) {
|
|
55
|
+
const key = a.slice(2);
|
|
56
|
+
const next = args[i + 1];
|
|
57
|
+
if (valueFlags.has(key) && next != null && !next.startsWith('--')) { flags[key] = next; i++; }
|
|
58
|
+
else flags[key] = true;
|
|
59
|
+
} else if (a === '-md') { flags.md = true; }
|
|
60
|
+
else if (a === '-json') { flags.json = true; }
|
|
61
|
+
else flags._.push(a);
|
|
62
|
+
}
|
|
63
|
+
return flags;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** newest-first list of session files */
|
|
67
|
+
function findSessions() {
|
|
68
|
+
const dir = sessionsDir();
|
|
69
|
+
if (!fs.existsSync(dir)) return [];
|
|
70
|
+
return fs.readdirSync(dir)
|
|
71
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
72
|
+
.map((f) => path.join(dir, f))
|
|
73
|
+
.sort()
|
|
74
|
+
.reverse();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveSession(fileArg) {
|
|
78
|
+
if (fileArg) {
|
|
79
|
+
if (fs.existsSync(fileArg)) return fileArg;
|
|
80
|
+
const inSessions = path.join(sessionsDir(), path.basename(fileArg));
|
|
81
|
+
if (fs.existsSync(inSessions)) return inSessions;
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const sessions = findSessions();
|
|
85
|
+
if (!sessions.length) return null;
|
|
86
|
+
return sessions[0]; // most recent
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cmdList() {
|
|
90
|
+
const files = findSessions();
|
|
91
|
+
if (!files.length) {
|
|
92
|
+
process.stdout.write(`${DIM}no sessions yet — try: ${RESET}agentbox demo\n`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
process.stdout.write(`${BOLD}⬢ recorded sessions${RESET} ${DIM}(.agentbox/sessions/)${RESET}\n\n`);
|
|
96
|
+
for (const f of files) {
|
|
97
|
+
let meta = null;
|
|
98
|
+
let dur = '?';
|
|
99
|
+
let code = '?';
|
|
100
|
+
let n = 0;
|
|
101
|
+
let chainOk = false;
|
|
102
|
+
let chainReason = '';
|
|
103
|
+
try {
|
|
104
|
+
const res = verifyChain(f);
|
|
105
|
+
chainOk = res.ok && res.complete;
|
|
106
|
+
chainReason = res.reason || (res.complete ? '' : 'incomplete session');
|
|
107
|
+
const stats = summarize(res.events);
|
|
108
|
+
meta = safeTerminal(stats.name);
|
|
109
|
+
dur = fmtDuration(stats.durationMs);
|
|
110
|
+
code = String(stats.exitCode);
|
|
111
|
+
n = res.events.length;
|
|
112
|
+
} catch { /* skip details */ }
|
|
113
|
+
const chain = chainOk ? `${GREEN}✓${RESET}` : `${RED}BROKEN${RESET}${chainReason ? ` ${DIM}(${chainReason})${RESET}` : ''}`;
|
|
114
|
+
process.stdout.write(` ${DIM}${safeTerminal(path.basename(f))}${RESET}\n ${CYAN}${BOLD}${meta || '?'}${RESET} · ${n} events · ${dur} · exit ${code === '0' ? GREEN + '0 ✓' : RED + code + RESET} · chain ${chain}\n`);
|
|
115
|
+
}
|
|
116
|
+
process.stdout.write(`\n${DIM}replay one: agentbox replay <file>${RESET}\n`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function cmdVerify(fileArg) {
|
|
120
|
+
const file = resolveSession(fileArg);
|
|
121
|
+
if (!file) { process.stderr.write('no session file found\n'); process.exitCode = 1; return; }
|
|
122
|
+
const res = verifyChain(file);
|
|
123
|
+
if (res.ok && res.complete) {
|
|
124
|
+
process.stdout.write(`${GREEN}✓ local chain intact${RESET} — ${res.count} events, sha256 from genesis to tip\n ${DIM}${safeTerminal(file)}${RESET}\n`);
|
|
125
|
+
} else {
|
|
126
|
+
process.stdout.write(`${RED}✗ ${res.reason || 'session is incomplete (missing exit event)'}${RESET}\n ${DIM}${file}${RESET}\n`);
|
|
127
|
+
process.exitCode = 1;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function cmdDemo() {
|
|
132
|
+
const demoScript = path.join(__dirname, '..', 'examples', 'fake-agent.js');
|
|
133
|
+
// prefer a repo-relative path in receipts when run from inside the repo
|
|
134
|
+
const rel = path.relative(process.cwd(), demoScript);
|
|
135
|
+
const demoArg = rel && !rel.startsWith('..') ? rel : demoScript;
|
|
136
|
+
process.stdout.write(`${CYAN}${BOLD}⬢ agentbox demo${RESET} — strapping a black box to a scripted agent\n\n`);
|
|
137
|
+
wrap(['node', demoArg], { name: 'demo-deploy' })
|
|
138
|
+
.then(({ file, exitCode }) => {
|
|
139
|
+
process.stdout.write('\n');
|
|
140
|
+
receipt(file, { format: 'text' });
|
|
141
|
+
process.stdout.write(` ${DIM}now try:${RESET} agentbox replay ${path.basename(file)}\n`);
|
|
142
|
+
process.stdout.write(` ${DIM}share a clip:${RESET} agentbox clip ${path.basename(file)}\n`);
|
|
143
|
+
// explicit exit — wrap used to leave stdin resumed, which kept the
|
|
144
|
+
// process alive after the agent finished (preflight / CI hang).
|
|
145
|
+
process.exit(exitCode !== 0 ? (exitCode > 0 ? exitCode : 1) : 0);
|
|
146
|
+
})
|
|
147
|
+
.catch((e) => {
|
|
148
|
+
process.stderr.write(`demo failed: ${e.message}\n`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function main() {
|
|
154
|
+
const argv = process.argv.slice(2);
|
|
155
|
+
const cmd = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'help';
|
|
156
|
+
const flags = parseFlags(argv.slice(1));
|
|
157
|
+
const fileArg = flags._.find((a) => a.endsWith('.jsonl')) || flags._[0];
|
|
158
|
+
|
|
159
|
+
switch (cmd) {
|
|
160
|
+
case 'wrap': {
|
|
161
|
+
const cmdArgs = flags._.length && fs.existsSync(flags._[0]) === false && flags['--'] === undefined
|
|
162
|
+
? flags._
|
|
163
|
+
: flags._;
|
|
164
|
+
// `agentbox wrap --name x -- node agent.js` → after flag parse, remaining positional args ARE the command
|
|
165
|
+
const target = cmdArgs.length ? cmdArgs : null;
|
|
166
|
+
if (!target) {
|
|
167
|
+
process.stderr.write('usage: agentbox wrap [--name n] -- <command> [args…]\n');
|
|
168
|
+
process.exitCode = 1;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
wrap(target, { name: flags.name, quiet: flags.quiet })
|
|
172
|
+
.then(({ exitCode }) => {
|
|
173
|
+
process.exit(exitCode !== 0 ? (exitCode > 0 ? exitCode : 1) : 0);
|
|
174
|
+
})
|
|
175
|
+
.catch((e) => {
|
|
176
|
+
process.stderr.write(`wrap failed: ${e.message}\n`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
case 'demo': return cmdDemo();
|
|
182
|
+
case 'hook':
|
|
183
|
+
// never fails, never prints — the agent's flight continues regardless
|
|
184
|
+
runHook().catch(() => process.exit(0));
|
|
185
|
+
return;
|
|
186
|
+
case 'mcp': {
|
|
187
|
+
const raw = argv.slice(1);
|
|
188
|
+
const dd = raw.indexOf('--');
|
|
189
|
+
const serverArgs = dd >= 0 ? raw.slice(dd + 1) : flags._;
|
|
190
|
+
if (!serverArgs.length) {
|
|
191
|
+
process.stderr.write('usage: agentbox mcp [--name n] -- <server command> [args…]\nexample: agentbox mcp -- npx -y @modelcontextprotocol/server-everything\n');
|
|
192
|
+
process.exitCode = 1;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
runMcpProxy(serverArgs, { name: flags.name, quiet: flags.quiet })
|
|
196
|
+
.then(({ exitCode }) => { if (exitCode) process.exitCode = exitCode; })
|
|
197
|
+
.catch((e) => { process.stderr.write(`mcp failed: ${e.message}\n`); process.exitCode = 1; });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
case 'init': {
|
|
201
|
+
const sub = flags._[0];
|
|
202
|
+
if (sub === 'claude') {
|
|
203
|
+
initClaude({ local: !!flags.local, remove: !!flags.remove });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (sub === 'mcp') {
|
|
207
|
+
const raw = argv.slice(1);
|
|
208
|
+
const dd = raw.indexOf('--');
|
|
209
|
+
const serverArgs = dd >= 0 ? raw.slice(dd + 1) : flags._.slice(1);
|
|
210
|
+
initMcp(serverArgs);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
process.stderr.write('usage: agentbox init claude [--local] [--remove]\n agentbox init mcp -- <server command>\n');
|
|
214
|
+
process.exitCode = 1;
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
case 'list': case 'ls': return cmdList();
|
|
218
|
+
case 'verify': return cmdVerify(fileArg);
|
|
219
|
+
case 'receipt': {
|
|
220
|
+
const file = resolveSession(fileArg);
|
|
221
|
+
if (!file) { process.stderr.write('no session file found — record one first: agentbox demo\n'); process.exitCode = 1; return; }
|
|
222
|
+
const format = flags.json ? 'json' : (flags.md || flags.markdown ? 'markdown' : 'text');
|
|
223
|
+
const r = receipt(file, { format, force: flags.force });
|
|
224
|
+
if (r.ok && !flags.quiet && format === 'text') {
|
|
225
|
+
process.stdout.write(` ${DIM}full tape:${RESET} agentbox replay ${path.basename(file)}\n`);
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
case 'clip': {
|
|
230
|
+
const file = resolveSession(fileArg);
|
|
231
|
+
if (!file) { process.stderr.write('no session file found — record one first: agentbox demo\n'); process.exitCode = 1; return; }
|
|
232
|
+
const out = clip(file, {
|
|
233
|
+
from: flags.from != null ? Number(flags.from) : undefined,
|
|
234
|
+
to: flags.to != null ? Number(flags.to) : undefined,
|
|
235
|
+
out: flags.out,
|
|
236
|
+
force: flags.force,
|
|
237
|
+
overwrite: flags.overwrite,
|
|
238
|
+
});
|
|
239
|
+
if (out) process.stdout.write(`${GREEN}✓ clip saved${RESET} ${DIM}${out}${RESET} — open it, or drop it straight into a PR\n`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
case 'replay': {
|
|
243
|
+
const file = resolveSession(fileArg);
|
|
244
|
+
if (!file) { process.stderr.write('no session file found — record one first: agentbox demo\n'); process.exitCode = 1; return; }
|
|
245
|
+
replay(file, { headless: flags.headless, force: flags.force, tail: flags.tail ? Number(flags.tail) : undefined });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
case 'version': case '--version': case '-v':
|
|
249
|
+
process.stdout.write(`agentbox v${VERSION}\n`);
|
|
250
|
+
return;
|
|
251
|
+
case 'help': case '--help': case '-h':
|
|
252
|
+
default:
|
|
253
|
+
process.stdout.write(HELP);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
module.exports = main;
|
|
259
|
+
module.exports.parseFlags = parseFlags;
|
|
260
|
+
module.exports.cmdList = cmdList;
|
|
261
|
+
|
|
262
|
+
if (require.main === module) main();
|
package/src/clip.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* agentbox — clip.js
|
|
4
|
+
* Export a time range of a session as ONE self-contained HTML file —
|
|
5
|
+
* a playable "clip" you can drop into a PR, an issue, or a group chat.
|
|
6
|
+
* No server, no assets, no JS framework. Works offline forever.
|
|
7
|
+
*/
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const { verifyChain, assertNotSymlink } = require('./chain');
|
|
11
|
+
|
|
12
|
+
function esc(s) {
|
|
13
|
+
return String(s)
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function clip(file, opts = {}) {
|
|
21
|
+
const res = verifyChain(file);
|
|
22
|
+
if ((!res.ok || !res.complete) && !opts.force) {
|
|
23
|
+
process.stderr.write(`\x1b[31m⬢ agentbox: chain verification FAILED — ${res.reason || 'session is incomplete'}\x1b[0m\n`);
|
|
24
|
+
process.exitCode = 1;
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const events = res.events;
|
|
28
|
+
if (!events.length) { process.stderr.write('⬢ agentbox: cannot clip an empty tape\n'); process.exitCode = 1; return null; }
|
|
29
|
+
const meta = events.find((e) => e.type === 'meta') || { data: {} };
|
|
30
|
+
const t0 = events[0].t;
|
|
31
|
+
const tN = events[events.length - 1].t;
|
|
32
|
+
const from = opts.from != null ? t0 + opts.from * 1000 : t0;
|
|
33
|
+
const to = opts.to != null ? t0 + opts.to * 1000 : tN;
|
|
34
|
+
if (!Number.isFinite(from) || !Number.isFinite(to) || from < t0 || to < from) {
|
|
35
|
+
process.stderr.write('⬢ agentbox: invalid clip range (use finite seconds with --from <= --to)\n');
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const slim = [];
|
|
41
|
+
for (const ev of events) {
|
|
42
|
+
if (ev.t < from || ev.t > to) continue;
|
|
43
|
+
const d = ev.data || {};
|
|
44
|
+
let kind = ev.type;
|
|
45
|
+
let text = '';
|
|
46
|
+
if (ev.type === 'out') { kind = d.stream === 'stderr' ? 'stderr' : (d.kind || 'out'); text = d.text != null ? d.text : String(d.detail || ''); }
|
|
47
|
+
else if (ev.type === 'in') { kind = 'in'; text = d.text || ''; }
|
|
48
|
+
else if (ev.type === 'exit') { kind = 'exit'; text = `exit code ${d.code}`; }
|
|
49
|
+
else if (ev.type === 'meta') { kind = 'meta'; text = d.cmd || ''; }
|
|
50
|
+
else if (ev.type === 'signal') { kind = 'signal'; text = d.signal || ''; }
|
|
51
|
+
else if (ev.type === 'tool_call') { kind = 'tool'; text = `${d.name || 'tool'}${d.phase === 'end' ? ` → ${d.status || 'ok'}` : `(${JSON.stringify(d.input || {})})`}`; }
|
|
52
|
+
else if (ev.type === 'prompt') { kind = 'in'; text = d.text || ''; }
|
|
53
|
+
else if (ev.type === 'notification') { kind = 'notification'; text = d.message || ''; }
|
|
54
|
+
else if (ev.type === 'note') { kind = 'note'; text = d.message || ''; }
|
|
55
|
+
else if (ev.type === 'mcp_msg') { kind = 'mcp'; text = `${d.dir || ''} ${d.method || 'message'}`; }
|
|
56
|
+
kind = String(kind).replace(/[^a-z0-9_-]/gi, '').slice(0, 32) || 'out';
|
|
57
|
+
slim.push({ rt: ev.t - t0, k: kind, x: String(text).slice(0, 2000) });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const outPath = opts.out || file.replace(/\.jsonl$/, '') + '.clip.html';
|
|
61
|
+
assertNotSymlink(outPath);
|
|
62
|
+
if (fs.existsSync(outPath) && !opts.overwrite) {
|
|
63
|
+
process.stderr.write(`⬢ agentbox: refusing to overwrite ${outPath} (pass --overwrite)\n`);
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
// Script elements are raw-text nodes: HTML entities such as " are not
|
|
68
|
+
// decoded there. Keep this as valid JSON and neutralize closing tags.
|
|
69
|
+
const payload = JSON.stringify(slim)
|
|
70
|
+
.replace(/</g, '\\u003c')
|
|
71
|
+
.replace(/\u2028/g, '\\u2028')
|
|
72
|
+
.replace(/\u2029/g, '\\u2029');
|
|
73
|
+
|
|
74
|
+
const html = `<!doctype html>
|
|
75
|
+
<html lang="en">
|
|
76
|
+
<head>
|
|
77
|
+
<meta charset="utf-8">
|
|
78
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
79
|
+
<title>⬢ agentbox clip — ${esc(meta.data.name || 'session')}</title>
|
|
80
|
+
<style>
|
|
81
|
+
:root { color-scheme: dark; }
|
|
82
|
+
* { box-sizing: border-box; }
|
|
83
|
+
body { margin:0; background:#0a0e14; color:#c9d1d9; font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
|
|
84
|
+
.wrap { max-width: 960px; margin: 0 auto; padding: 24px 16px 60px; }
|
|
85
|
+
h1 { font-size: 16px; color:#5eead4; margin: 0 0 4px; }
|
|
86
|
+
.sub { color:#56606c; font-size:12px; margin-bottom:16px; }
|
|
87
|
+
.term { border:1px solid #1c2530; border-radius:10px; background:#0d1117; overflow:hidden; }
|
|
88
|
+
.bar { display:flex; gap:6px; align-items:center; padding:8px 12px; background:#11161d; border-bottom:1px solid #1c2530; }
|
|
89
|
+
.dot { width:10px; height:10px; border-radius:50%; }
|
|
90
|
+
.r{background:#ff5f56}.y{background:#ffbd2e}.g{background:#27c93f}
|
|
91
|
+
.bar .t { margin-left:8px; color:#56606c; font-size:12px; }
|
|
92
|
+
#feed { height: 420px; overflow-y:auto; padding:12px 14px; }
|
|
93
|
+
.ln { white-space:pre-wrap; word-break:break-word; padding:1px 0; }
|
|
94
|
+
.ts { color:#3b4654; margin-right:10px; }
|
|
95
|
+
.tag { display:inline-block; width:52px; font-weight:700; margin-right:8px; }
|
|
96
|
+
.k-tool{color:#fb923c}.k-cmd{color:#4ade80}.k-file{color:#e879f9}.k-net{color:#22d3ee}
|
|
97
|
+
.k-in{color:#67e8f9}.k-stderr{color:#f87171}.k-signal{color:#f87171;font-weight:700}
|
|
98
|
+
.k-exit{color:#fff;font-weight:700}.k-meta{color:#56606c}.k-out{color:#c9d1d9}
|
|
99
|
+
.ctl { display:flex; gap:10px; align-items:center; padding:10px 12px; border-top:1px solid #1c2530; background:#11161d; }
|
|
100
|
+
button { background:#5eead4; color:#042f2e; border:0; font-weight:700; padding:6px 14px; border-radius:6px; cursor:pointer; font-family:inherit; }
|
|
101
|
+
button:hover { filter:brightness(1.1); }
|
|
102
|
+
input[type=range] { flex:1; accent-color:#5eead4; }
|
|
103
|
+
.time { color:#56606c; min-width:110px; text-align:right; }
|
|
104
|
+
.foot { color:#3b4654; font-size:11px; margin-top:14px; }
|
|
105
|
+
.chip { border:1px solid #1c2530; color:#56606c; border-radius:99px; padding:2px 10px; font-size:11px; cursor:pointer; user-select:none; }
|
|
106
|
+
.chip.on { color:#042f2e; background:#5eead4; border-color:#5eead4; }
|
|
107
|
+
</style>
|
|
108
|
+
</head>
|
|
109
|
+
<body>
|
|
110
|
+
<div class="wrap">
|
|
111
|
+
<h1>⬢ agentbox clip</h1>
|
|
112
|
+
<div class="sub">session <b>${esc(meta.data.name || 'session')}</b> · command <code>${esc(meta.data.cmd || '?')}</code> · ${slim.length} events · recorded ${new Date(t0).toISOString()}</div>
|
|
113
|
+
<div class="term">
|
|
114
|
+
<div class="bar"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span><span class="t">black box tape — local integrity chain ${res.ok && res.complete ? 'complete ✓' : 'INCOMPLETE/BROKEN ✗'}</span></div>
|
|
115
|
+
<div id="feed"></div>
|
|
116
|
+
<div class="ctl">
|
|
117
|
+
<button id="play">▶ play</button>
|
|
118
|
+
<input id="scrub" type="range" min="0" value="0">
|
|
119
|
+
<span class="time" id="time">00:00.0</span>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
<div style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap" id="filters">
|
|
123
|
+
<span class="chip on" data-k="all">all</span>
|
|
124
|
+
<span class="chip" data-k="tool">▲ tool</span>
|
|
125
|
+
<span class="chip" data-k="cmd">$ shell</span>
|
|
126
|
+
<span class="chip" data-k="file">✎ file</span>
|
|
127
|
+
<span class="chip" data-k="in">i human</span>
|
|
128
|
+
<span class="chip" data-k="stderr">stderr</span>
|
|
129
|
+
</div>
|
|
130
|
+
<div class="foot">generated by agentbox — the flight recorder for AI agents · this file is self-contained; share it anywhere</div>
|
|
131
|
+
</div>
|
|
132
|
+
<script type="application/json" id="payload-json">${payload}</script>
|
|
133
|
+
<script>
|
|
134
|
+
const EVENTS = JSON.parse(document.getElementById('payload-json').textContent);
|
|
135
|
+
var t0 = EVENTS.length ? EVENTS[0].rt : 0;
|
|
136
|
+
var tN = EVENTS.length ? EVENTS[EVENTS.length-1].rt : 1000;
|
|
137
|
+
var cursor = 0, drawnCursor = -1, playing = false, filter = 'all', raf = null, startedAt = 0, base = 0;
|
|
138
|
+
var feed = document.getElementById('feed'), scrub = document.getElementById('scrub'), time = document.getElementById('time');
|
|
139
|
+
document.getElementById('payload-json').remove();
|
|
140
|
+
scrub.max = tN - t0;
|
|
141
|
+
function mmss(ms){var s=Math.max(0,ms)/1000;var m=Math.floor(s/60);var r=s-m*60;return String(m).padStart(2,'0')+':'+r.toFixed(1).padStart(4,'0');}
|
|
142
|
+
function fmt(k,txt){var d=document.createElement('div');d.className='ln k-'+k;
|
|
143
|
+
var ts='<span class="ts">'+mmss(EVENTS[cursor].rt)+'</span>';
|
|
144
|
+
d.innerHTML=ts+'<span class="tag">'+k.toUpperCase()+'</span>'+escapeHtml(txt||'·');return d;}
|
|
145
|
+
function escapeHtml(s){return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
|
146
|
+
function visible(i){var e=EVENTS[i];if(filter==='all')return true;var k=e.k==='in'?'in':e.k;return k===filter;}
|
|
147
|
+
function drawTo(idx){
|
|
148
|
+
feed.innerHTML='';
|
|
149
|
+
var selected=[];
|
|
150
|
+
for(var i=idx;i>=0&&selected.length<400;i--){if(visible(i))selected.push(i);}
|
|
151
|
+
selected.reverse();
|
|
152
|
+
for(var n=0;n<selected.length;n++){
|
|
153
|
+
var i=selected[n];
|
|
154
|
+
var e=EVENTS[i];
|
|
155
|
+
var d=document.createElement('div');d.className='ln k-'+e.k;
|
|
156
|
+
d.innerHTML='<span class="ts">'+mmss(e.rt)+'</span><span class="tag">'+e.k.toUpperCase()+'</span>'+escapeHtml(e.x||'·');
|
|
157
|
+
feed.appendChild(d);
|
|
158
|
+
}
|
|
159
|
+
drawnCursor=idx;
|
|
160
|
+
feed.scrollTop=feed.scrollHeight;
|
|
161
|
+
}
|
|
162
|
+
function appendTo(idx){
|
|
163
|
+
for(var i=drawnCursor+1;i<=idx;i++){
|
|
164
|
+
if(!visible(i))continue;
|
|
165
|
+
var e=EVENTS[i],d=document.createElement('div');d.className='ln k-'+e.k;
|
|
166
|
+
d.innerHTML='<span class="ts">'+mmss(e.rt)+'</span><span class="tag">'+e.k.toUpperCase()+'</span>'+escapeHtml(e.x||'·');
|
|
167
|
+
feed.appendChild(d);
|
|
168
|
+
}
|
|
169
|
+
while(feed.children.length>400)feed.removeChild(feed.firstChild);
|
|
170
|
+
drawnCursor=idx;feed.scrollTop=feed.scrollHeight;
|
|
171
|
+
}
|
|
172
|
+
function setCursor(i){cursor=Math.max(0,Math.min(EVENTS.length-1,i));scrub.value=EVENTS[cursor].rt-t0;time.textContent=mmss(EVENTS[cursor].rt-t0);drawTo(cursor);}
|
|
173
|
+
function tick(){var now=performance.now();var target=base+(now-startedAt);var idx=cursor;
|
|
174
|
+
while(idx<EVENTS.length-1&&EVENTS[idx+1].rt-t0<=target)idx++;
|
|
175
|
+
if(idx!==cursor){cursor=idx;scrub.value=EVENTS[cursor].rt-t0;time.textContent=mmss(EVENTS[cursor].rt-t0);appendTo(cursor);}
|
|
176
|
+
if(cursor>=EVENTS.length-1){stop();return;}
|
|
177
|
+
raf=requestAnimationFrame(tick);}
|
|
178
|
+
function play(){if(playing)return;playing=true;document.getElementById('play').textContent='⏸ pause';startedAt=performance.now();base=EVENTS[cursor].rt-t0;raf=requestAnimationFrame(tick);}
|
|
179
|
+
function stop(){playing=false;document.getElementById('play').textContent='▶ play';if(raf)cancelAnimationFrame(raf);}
|
|
180
|
+
document.getElementById('play').onclick=function(){playing?stop():play();};
|
|
181
|
+
scrub.oninput=function(){stop();var t=+scrub.value+t0,lo=0,hi=EVENTS.length-1,j=0;
|
|
182
|
+
while(lo<=hi){var mid=(lo+hi)>>1;if(EVENTS[mid].rt<=t){j=mid;lo=mid+1;}else hi=mid-1;}setCursor(j);};
|
|
183
|
+
document.getElementById('filters').onclick=function(e){var c=e.target.closest('.chip');if(!c)return;
|
|
184
|
+
document.querySelectorAll('.chip').forEach(function(x){x.classList.remove('on')});c.classList.add('on');filter=c.dataset.k;setCursor(cursor);};
|
|
185
|
+
document.addEventListener('keydown',function(e){if(e.key===' '){e.preventDefault();playing?stop():play();}});
|
|
186
|
+
setCursor(0);
|
|
187
|
+
</script>
|
|
188
|
+
</body>
|
|
189
|
+
</html>
|
|
190
|
+
`;
|
|
191
|
+
|
|
192
|
+
fs.writeFileSync(outPath, html, { flag: opts.overwrite ? 'w' : 'wx' });
|
|
193
|
+
return outPath;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = { clip };
|