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/replay.js
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* agentbox — replay.js
|
|
4
|
+
* Scrub through a recorded session like security footage.
|
|
5
|
+
*
|
|
6
|
+
* agentbox replay <file> interactive TUI (TTY)
|
|
7
|
+
* agentbox replay <file> --headless static frame for CI / pipes / GIFs
|
|
8
|
+
*
|
|
9
|
+
* Keys: [space] play/pause [←/→] ±1 event [j/k] ∓/+ 10s
|
|
10
|
+
* [[ / ]] speed [g/G] jump to start/end [q] quit
|
|
11
|
+
*/
|
|
12
|
+
const { verifyChain } = require('./chain');
|
|
13
|
+
const { fmtDuration, inputPreview } = require('./parse');
|
|
14
|
+
|
|
15
|
+
const RESET = '\x1b[0m';
|
|
16
|
+
const DIM = '\x1b[2m';
|
|
17
|
+
const BOLD = '\x1b[1m';
|
|
18
|
+
const CYAN = '\x1b[36m';
|
|
19
|
+
const GREEN = '\x1b[32m';
|
|
20
|
+
const YELLOW = '\x1b[33m';
|
|
21
|
+
const MAGENTA = '\x1b[35m';
|
|
22
|
+
const RED = '\x1b[31m';
|
|
23
|
+
const ORANGE = '\x1b[38;5;208m';
|
|
24
|
+
|
|
25
|
+
const KIND_STYLE = {
|
|
26
|
+
tool: { c: ORANGE, tag: 'TOOL' },
|
|
27
|
+
tool_call: { c: ORANGE, tag: 'TOOL' },
|
|
28
|
+
cmd: { c: GREEN, tag: 'SH$' },
|
|
29
|
+
file: { c: MAGENTA, tag: 'FILE' },
|
|
30
|
+
net: { c: CYAN, tag: 'NET' },
|
|
31
|
+
in: { c: CYAN, tag: 'HUMAN' },
|
|
32
|
+
prompt: { c: CYAN, tag: 'HUMAN' },
|
|
33
|
+
notification: { c: YELLOW, tag: 'NOTE' },
|
|
34
|
+
mcp_msg: { c: CYAN, tag: 'MCP' },
|
|
35
|
+
turn_end: { c: DIM, tag: 'TURN' },
|
|
36
|
+
note: { c: DIM, tag: 'note' },
|
|
37
|
+
stderr: { c: RED, tag: 'ERR!' },
|
|
38
|
+
out: { c: RESET, tag: 'out' },
|
|
39
|
+
meta: { c: DIM, tag: 'meta' },
|
|
40
|
+
exit: { c: BOLD, tag: 'EXIT' },
|
|
41
|
+
signal: { c: RED, tag: 'SIGNAL' },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function evKind(ev) {
|
|
45
|
+
if (ev.type === 'out') {
|
|
46
|
+
if (ev.data && ev.data.stream === 'stderr') return 'stderr';
|
|
47
|
+
return (ev.data && ev.data.kind) || 'out';
|
|
48
|
+
}
|
|
49
|
+
// tool_call *end* events are outcomes, not markers — keep the tape calm
|
|
50
|
+
if (ev.type === 'tool_call' && ev.data && ev.data.phase === 'end') return null;
|
|
51
|
+
return ev.type;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function evText(ev) {
|
|
55
|
+
const d = ev.data || {};
|
|
56
|
+
const strip = (s) => String(s == null ? '' : s)
|
|
57
|
+
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
|
|
58
|
+
.replace(/\x1b\[[?0-9;:><]*[ -/]*[@-~]/g, '')
|
|
59
|
+
.replace(/\x1b[P^_].*?\x1b\\/g, '')
|
|
60
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
|
61
|
+
.replace(/\r/g, '')
|
|
62
|
+
.replace(/^\[TOOL\]\s*/i, '');
|
|
63
|
+
if (typeof d.text === 'string' && d.text.length) return strip(d.text);
|
|
64
|
+
if (typeof d.detail === 'string') return strip(d.detail);
|
|
65
|
+
if (d.detail && typeof d.detail === 'object') {
|
|
66
|
+
return `${d.detail.op || ''} ${d.detail.path || ''}`.trim() || JSON.stringify(d.detail);
|
|
67
|
+
}
|
|
68
|
+
if (ev.type === 'exit') return `exit code ${d.code} after ${fmtDuration(d.durationMs)}`;
|
|
69
|
+
if (ev.type === 'signal') return `${d.signal} received`;
|
|
70
|
+
if (ev.type === 'meta') return d.cmd || 'session start';
|
|
71
|
+
if (ev.type === 'tool_call') {
|
|
72
|
+
if (d.phase === 'end') {
|
|
73
|
+
const ms = d.durationMs != null ? ` in ${fmtDuration(d.durationMs)}` : '';
|
|
74
|
+
return `${d.name || 'tool'} → ${d.status || 'ok'}${ms}`;
|
|
75
|
+
}
|
|
76
|
+
return `${d.name || 'tool'}(${shorten(inputPreview(d.input, 160), 110)})`;
|
|
77
|
+
}
|
|
78
|
+
if (ev.type === 'prompt') return shorten(d.text, 200);
|
|
79
|
+
if (ev.type === 'notification') return shorten(d.message, 200);
|
|
80
|
+
if (ev.type === 'turn_end') return 'turn complete';
|
|
81
|
+
if (ev.type === 'note') return d.message || '';
|
|
82
|
+
if (ev.type === 'mcp_msg') {
|
|
83
|
+
const arrow = d.dir === 'C2S' ? '→ client→server' : '← server→client';
|
|
84
|
+
const id = d.id != null ? ` #${d.id}` : '';
|
|
85
|
+
return `${arrow} ${d.method || 'message'}${id}`;
|
|
86
|
+
}
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function visibleEventRows(events, cur, limit) {
|
|
91
|
+
const rows = [];
|
|
92
|
+
const from = Math.max(0, cur - limit * 12);
|
|
93
|
+
for (let i = from; i <= cur; i++) {
|
|
94
|
+
const ev = events[i];
|
|
95
|
+
const k = evKind(ev);
|
|
96
|
+
if (k === null) continue;
|
|
97
|
+
const rawText = evText(ev) || '';
|
|
98
|
+
const human = k === 'in' || k === 'prompt';
|
|
99
|
+
const text = human ? rawText.replace(/[\r\n]+$/, '') : rawText.trim();
|
|
100
|
+
if (!text && !(human && /\s/.test(rawText))) continue;
|
|
101
|
+
const previous = rows[rows.length - 1];
|
|
102
|
+
if (human && previous && previous.k === k && ev.t - previous.ev.t <= 2500) {
|
|
103
|
+
previous.text += text;
|
|
104
|
+
previous.ev = ev;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (previous && text === previous.text) continue;
|
|
108
|
+
rows.push({ ev, k, text });
|
|
109
|
+
}
|
|
110
|
+
return rows.slice(-limit);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function writeFrame(stdout, lines, previous) {
|
|
114
|
+
if (!previous.length) stdout.write('\x1b[2J');
|
|
115
|
+
const writes = [];
|
|
116
|
+
const count = Math.max(lines.length, previous.length);
|
|
117
|
+
for (let i = 0; i < count; i++) {
|
|
118
|
+
const line = lines[i] || '';
|
|
119
|
+
if (line === previous[i]) continue;
|
|
120
|
+
writes.push(`\x1b[${i + 1};1H\x1b[2K${line}`);
|
|
121
|
+
}
|
|
122
|
+
if (writes.length) stdout.write(writes.join(''));
|
|
123
|
+
previous.splice(0, previous.length, ...lines);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function replayExitCode(input) {
|
|
127
|
+
const s = String(input);
|
|
128
|
+
if (s.includes('\x03')) return 130;
|
|
129
|
+
if (s.includes('q') || s.includes('Q')) return 0;
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function mmss(ms) {
|
|
134
|
+
const s = Math.max(0, ms) / 1000;
|
|
135
|
+
const m = Math.floor(s / 60);
|
|
136
|
+
const rem = s - m * 60;
|
|
137
|
+
return `${String(m).padStart(2, '0')}:${rem.toFixed(1).padStart(4, '0')}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function shorten(s, n) {
|
|
141
|
+
s = String(s).replace(/\t/g, ' ');
|
|
142
|
+
n = Math.max(1, Number(n) || 1);
|
|
143
|
+
if (s.length <= n) return s;
|
|
144
|
+
return s.slice(0, n - 1) + '…';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Static, non-interactive render (used headless + in tests). */
|
|
148
|
+
function renderStatic(events, opts = {}) {
|
|
149
|
+
const width = Math.min(opts.width || 100, 110);
|
|
150
|
+
const t0 = events[0].t;
|
|
151
|
+
const tN = events[events.length - 1].t;
|
|
152
|
+
const dur = Math.max(1, tN - t0);
|
|
153
|
+
const lines = [];
|
|
154
|
+
const meta = events.find((e) => e.type === 'meta');
|
|
155
|
+
const exit = [...events].reverse().find((e) => e.type === 'exit');
|
|
156
|
+
|
|
157
|
+
lines.push(`${CYAN}${BOLD}⬢ AGENTBOX FLIGHT RECORD${RESET} ${DIM}${shorten(meta ? evText(meta) : '?', width - 30)}${RESET}`);
|
|
158
|
+
lines.push(`${DIM}${'─'.repeat(width)}${RESET}`);
|
|
159
|
+
|
|
160
|
+
// timeline with markers
|
|
161
|
+
const barW = width - 24;
|
|
162
|
+
const bar = new Array(barW).fill('─');
|
|
163
|
+
for (const ev of events) {
|
|
164
|
+
let mark = null;
|
|
165
|
+
let color = null;
|
|
166
|
+
const k = evKind(ev);
|
|
167
|
+
if (k === 'tool' || k === 'tool_call') { mark = '▲'; color = ORANGE; }
|
|
168
|
+
else if (k === 'cmd') { mark = '$'; color = GREEN; }
|
|
169
|
+
else if (k === 'file') { mark = '✎'; color = MAGENTA; }
|
|
170
|
+
else if (k === 'in' || k === 'prompt') { mark = 'i'; color = CYAN; }
|
|
171
|
+
else if (k === 'stderr') { mark = '·'; color = RED; }
|
|
172
|
+
else if (k === 'mcp_msg') { mark = '⇄'; color = CYAN; }
|
|
173
|
+
if (mark) {
|
|
174
|
+
const p = Math.min(barW - 1, Math.floor(((ev.t - t0) / dur) * barW));
|
|
175
|
+
bar[p] = color ? `${color}${mark}${DIM}` : mark;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
lines.push(` ${DIM}[${bar.join('')}${DIM}]${RESET} ${BOLD}${mmss(dur)}${RESET}`);
|
|
179
|
+
lines.push(` ${DIM}▲ tool $ shell ✎ file i human ⇄ mcp · stderr${RESET}`);
|
|
180
|
+
lines.push('');
|
|
181
|
+
|
|
182
|
+
const shown = events.slice(-(opts.tail || 12));
|
|
183
|
+
for (const ev of shown) {
|
|
184
|
+
const k = evKind(ev);
|
|
185
|
+
if (k === null) continue; // end-phase outcomes are folded into starts
|
|
186
|
+
const st = KIND_STYLE[k] || KIND_STYLE.out;
|
|
187
|
+
const at = mmss(ev.t - t0);
|
|
188
|
+
lines.push(` ${DIM}${at}${RESET} ${st.c}${BOLD}${st.tag.padEnd(5)}${RESET} ${st.c}${shorten(evText(ev) || '·', width - 20)}${RESET}`);
|
|
189
|
+
}
|
|
190
|
+
lines.push('');
|
|
191
|
+
if (exit) {
|
|
192
|
+
lines.push(` ${exit.data && exit.data.code === 0 ? GREEN : RED}${BOLD}landing: exit ${exit.data.code}${RESET} ${DIM}· ${events.length} events · ${fmtDuration(exit.data.durationMs)}${RESET}`);
|
|
193
|
+
}
|
|
194
|
+
lines.push(`${DIM} (interactive mode: run in a TTY → agentbox replay <file>)${RESET}`);
|
|
195
|
+
return lines.join('\n');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Interactive TUI. */
|
|
199
|
+
function replayTui(file, events, opts = {}) {
|
|
200
|
+
const stdout = process.stdout;
|
|
201
|
+
const t0 = events[0].t;
|
|
202
|
+
const tN = events[events.length - 1].t;
|
|
203
|
+
const dur = Math.max(1000, tN - t0);
|
|
204
|
+
const meta = events.find((e) => e.type === 'meta');
|
|
205
|
+
const SPEEDS = [1, 2, 4, 8, 16, 64];
|
|
206
|
+
|
|
207
|
+
let vTime = 0; // virtual clock (ms into flight)
|
|
208
|
+
let playing = true;
|
|
209
|
+
let speedIdx = 1; // 2x default keeps busy terminal sessions readable
|
|
210
|
+
let lastFrame = Date.now();
|
|
211
|
+
let markerWidth = -1;
|
|
212
|
+
let markers = [];
|
|
213
|
+
const previousFrame = [];
|
|
214
|
+
let stopped = false;
|
|
215
|
+
|
|
216
|
+
const name = meta && meta.data ? evText({ type: 'out', data: { text: meta.data.name || 'session' } }) : 'session';
|
|
217
|
+
const cols = () => (stdout.columns || 100);
|
|
218
|
+
const rows = () => (stdout.rows || 30);
|
|
219
|
+
|
|
220
|
+
stdout.write('\x1b[?1049h\x1b[?25l'); // alt screen + hide cursor
|
|
221
|
+
|
|
222
|
+
function shutdown(code) {
|
|
223
|
+
if (stopped) return;
|
|
224
|
+
stopped = true;
|
|
225
|
+
clearInterval(timer);
|
|
226
|
+
process.stdin.setRawMode(false);
|
|
227
|
+
process.stdin.removeListener('data', onKey);
|
|
228
|
+
process.stdin.pause();
|
|
229
|
+
process.removeListener('SIGINT', onSig);
|
|
230
|
+
process.removeListener('SIGTERM', onTerm);
|
|
231
|
+
process.removeListener('SIGHUP', onHup);
|
|
232
|
+
stdout.removeListener('resize', render);
|
|
233
|
+
stdout.write('\x1b[?25h\x1b[?1049l\x1b[0m');
|
|
234
|
+
stdout.write(`${DIM}⬢ agentbox: replay ended — ${name}${RESET}\n`);
|
|
235
|
+
process.exit(code);
|
|
236
|
+
}
|
|
237
|
+
const onSig = () => shutdown(130);
|
|
238
|
+
const onTerm = () => shutdown(143);
|
|
239
|
+
const onHup = () => shutdown(129);
|
|
240
|
+
|
|
241
|
+
function visibleIdx() {
|
|
242
|
+
// index of last event with t - t0 <= vTime
|
|
243
|
+
let lo = 0, hi = events.length - 1, ans = -1;
|
|
244
|
+
while (lo <= hi) {
|
|
245
|
+
const mid = (lo + hi) >> 1;
|
|
246
|
+
if (events[mid].t - t0 <= vTime) { ans = mid; lo = mid + 1; } else { hi = mid - 1; }
|
|
247
|
+
}
|
|
248
|
+
return ans;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function step(delta) {
|
|
252
|
+
const cur = visibleIdx();
|
|
253
|
+
const next = Math.max(0, Math.min(events.length - 1, cur + delta));
|
|
254
|
+
vTime = events[next].t - t0;
|
|
255
|
+
render();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function render() {
|
|
259
|
+
const W = Math.max(1, cols());
|
|
260
|
+
const H = Math.max(1, rows());
|
|
261
|
+
const paneH = Math.max(0, H - 8);
|
|
262
|
+
const cur = visibleIdx();
|
|
263
|
+
const pct = Math.min(100, (vTime / dur) * 100);
|
|
264
|
+
|
|
265
|
+
const buf = [];
|
|
266
|
+
if (H < 8) {
|
|
267
|
+
buf.push(`${CYAN}${BOLD}⬢ REPLAY${RESET} ${shorten(name, W - 10)}`);
|
|
268
|
+
buf.push(`${BOLD}${mmss(vTime)}${RESET}/${mmss(dur)} ${SPEEDS[speedIdx]}x`);
|
|
269
|
+
for (const { text } of visibleEventRows(events, cur, Math.max(0, H - 3))) buf.push(shorten(text, W));
|
|
270
|
+
while (buf.length < H - 1) buf.push('');
|
|
271
|
+
buf.push(`${DIM}q quit · space ${playing ? 'pause' : 'play'}${RESET}`);
|
|
272
|
+
writeFrame(stdout, buf.slice(0, H), previousFrame);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (W < 50) buf.push(`${CYAN}${BOLD}⬢ REPLAY${RESET} ${shorten(name, W - 10)}`);
|
|
276
|
+
else buf.push(`${CYAN}${BOLD}⬢ AGENTBOX REPLAY${RESET} ${BOLD}${shorten(name, 24)}${RESET} ${DIM}│${RESET} ${DIM}${shorten(meta ? evText(meta) : '?', W - 46)}${RESET}`);
|
|
277
|
+
buf.push(`${DIM}${'─'.repeat(W)}${RESET}`);
|
|
278
|
+
|
|
279
|
+
// timeline bar
|
|
280
|
+
const barW = Math.max(1, W - 6);
|
|
281
|
+
const filled = Math.floor((pct / 100) * barW);
|
|
282
|
+
const bar = [];
|
|
283
|
+
for (let x = 0; x < barW; x++) {
|
|
284
|
+
if (x < filled) bar.push(`${CYAN}█${RESET}`);
|
|
285
|
+
else bar.push(`${DIM}░${RESET}`);
|
|
286
|
+
}
|
|
287
|
+
// Marker positions only depend on terminal width, not playback time.
|
|
288
|
+
if (markerWidth !== barW) {
|
|
289
|
+
markerWidth = barW;
|
|
290
|
+
markers = new Array(barW);
|
|
291
|
+
for (const ev of events) {
|
|
292
|
+
const k = evKind(ev);
|
|
293
|
+
let mark = null;
|
|
294
|
+
let color = null;
|
|
295
|
+
if (k === 'tool' || k === 'tool_call') { mark = '▲'; color = ORANGE; }
|
|
296
|
+
else if (k === 'cmd') { mark = '$'; color = GREEN; }
|
|
297
|
+
else if (k === 'file') { mark = '✎'; color = MAGENTA; }
|
|
298
|
+
else if (k === 'in' || k === 'prompt') { mark = 'i'; color = CYAN; }
|
|
299
|
+
else if (k === 'mcp_msg') { mark = '⇄'; color = CYAN; }
|
|
300
|
+
if (mark) {
|
|
301
|
+
const p = Math.min(barW - 1, Math.floor(((ev.t - t0) / dur) * barW));
|
|
302
|
+
markers[p] = { mark, color };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
for (let p = 0; p < markers.length; p++) {
|
|
307
|
+
const marker = markers[p];
|
|
308
|
+
if (marker) bar[p] = `${marker.color}${marker.mark}${p > filled ? DIM : CYAN}`;
|
|
309
|
+
}
|
|
310
|
+
const speed = SPEEDS[speedIdx];
|
|
311
|
+
buf.push(` [${bar.join('')}]`);
|
|
312
|
+
buf.push(` ${BOLD}${mmss(vTime)}${RESET} ${DIM}/ ${mmss(dur)} · ${speed}x · ${cur + 1}/${events.length} events${RESET}`);
|
|
313
|
+
buf.push(` ${DIM}▲ tool $ shell ✎ file i human${RESET}`);
|
|
314
|
+
buf.push('');
|
|
315
|
+
|
|
316
|
+
// event pane (last paneH visible events)
|
|
317
|
+
for (const { ev, k, text } of visibleEventRows(events, cur, paneH)) {
|
|
318
|
+
const st = KIND_STYLE[k] || KIND_STYLE.out;
|
|
319
|
+
const at = mmss(ev.t - t0);
|
|
320
|
+
const tag = st.tag.padEnd(5);
|
|
321
|
+
if (W < 20) buf.push(`${st.c}${shorten(text, W)}${RESET}`);
|
|
322
|
+
else buf.push(`${DIM}${at}${RESET} ${st.c}${BOLD}${tag}${RESET} ${st.c}${shorten(text, W - 16)}${RESET}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// footer
|
|
326
|
+
while (buf.length < H - 2) buf.push('');
|
|
327
|
+
buf.push(`${DIM}${'─'.repeat(W)}${RESET}`);
|
|
328
|
+
const controls = W < 60
|
|
329
|
+
? `[space] ${playing ? 'pause' : 'play'} ←/→ event q quit`
|
|
330
|
+
: `[space] ${playing ? 'pause' : 'play '} │ [←/→] event │ [j/k] 10s │ [[/]] ${speed}x │ [g/G] start/end │ [q] quit`;
|
|
331
|
+
buf.push(`${DIM} ${shorten(controls, W - 1)}${RESET}`);
|
|
332
|
+
writeFrame(stdout, buf.slice(0, H), previousFrame);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let lastTick = -1;
|
|
336
|
+
const timer = setInterval(() => {
|
|
337
|
+
const now = Date.now();
|
|
338
|
+
const dt = now - lastFrame;
|
|
339
|
+
lastFrame = now;
|
|
340
|
+
if (playing) {
|
|
341
|
+
vTime += dt * SPEEDS[speedIdx];
|
|
342
|
+
const cur = visibleIdx();
|
|
343
|
+
if (cur >= events.length - 1) {
|
|
344
|
+
playing = false;
|
|
345
|
+
vTime = dur;
|
|
346
|
+
render();
|
|
347
|
+
setTimeout(() => shutdown(0), 300).unref();
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const tick = Math.floor(vTime / 100);
|
|
351
|
+
if (tick !== lastTick) {
|
|
352
|
+
lastTick = tick;
|
|
353
|
+
render();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}, 50);
|
|
357
|
+
|
|
358
|
+
const onKey = (d) => {
|
|
359
|
+
const s = d.toString('utf8');
|
|
360
|
+
lastFrame = Date.now();
|
|
361
|
+
const exitCode = replayExitCode(s);
|
|
362
|
+
if (exitCode !== null) { shutdown(exitCode); return; }
|
|
363
|
+
if (s === ' ') { playing = !playing; render(); return; }
|
|
364
|
+
if (s === '\x1b[C' || s === 'l') { step(1); return; }
|
|
365
|
+
if (s === '\x1b[D' || s === 'h') { step(-1); return; }
|
|
366
|
+
if (s === 'j') { vTime = Math.max(0, vTime - 10000); render(); return; }
|
|
367
|
+
if (s === 'k') { vTime = Math.min(dur, vTime + 10000); render(); return; }
|
|
368
|
+
if (s === '[') { speedIdx = Math.max(0, speedIdx - 1); render(); return; }
|
|
369
|
+
if (s === ']') { speedIdx = Math.min(SPEEDS.length - 1, speedIdx + 1); render(); return; }
|
|
370
|
+
if (s === 'g') { vTime = 0; render(); return; }
|
|
371
|
+
if (s === 'G') { vTime = dur; render(); return; }
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
process.on('SIGINT', onSig);
|
|
375
|
+
process.on('SIGTERM', onTerm);
|
|
376
|
+
process.on('SIGHUP', onHup);
|
|
377
|
+
process.stdin.setRawMode(true);
|
|
378
|
+
process.stdin.resume();
|
|
379
|
+
process.stdin.on('data', onKey);
|
|
380
|
+
stdout.on('resize', render);
|
|
381
|
+
render();
|
|
382
|
+
|
|
383
|
+
if (process.env.AGENTBOX_SMOKE) {
|
|
384
|
+
// CI smoke mode: render a few frames, then leave.
|
|
385
|
+
setTimeout(() => { vTime = dur * 0.5; render(); }, 120);
|
|
386
|
+
setTimeout(() => shutdown(0), 260);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function replay(file, opts = {}) {
|
|
391
|
+
const res = verifyChain(file);
|
|
392
|
+
if (!res.ok && !opts.force) {
|
|
393
|
+
process.stderr.write(`\x1b[31m⬢ agentbox: chain verification FAILED — ${res.reason}\n`);
|
|
394
|
+
process.stderr.write(' use --force to replay anyway (for forensics)\x1b[0m\n');
|
|
395
|
+
process.exitCode = 1;
|
|
396
|
+
return res.ok;
|
|
397
|
+
}
|
|
398
|
+
const events = res.events;
|
|
399
|
+
if (events.length < 2) {
|
|
400
|
+
process.stderr.write('⬢ agentbox: nothing to replay — session has too few events\n');
|
|
401
|
+
process.exitCode = 1;
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
if (!process.stdout.isTTY || opts.headless || process.env.AGENTBOX_SMOKE) {
|
|
405
|
+
process.stdout.write(renderStatic(events, { width: opts.width, tail: opts.tail }) + '\n');
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
replayTui(file, events, opts);
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
module.exports = { replay, renderStatic, visibleEventRows, writeFrame, replayExitCode };
|