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/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, '&amp;')
15
+ .replace(/</g, '&lt;')
16
+ .replace(/>/g, '&gt;')
17
+ .replace(/"/g, '&quot;');
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 &quot; 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
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 };