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/wrap.js ADDED
@@ -0,0 +1,310 @@
1
+ 'use strict';
2
+ /**
3
+ * agentbox — wrap.js
4
+ * `agentbox wrap -- <command>` : spawn the command with the black box on.
5
+ * Streams stdout/stderr through live (so the human still sees everything)
6
+ * while recording line-by-line, classified events into the hash chain.
7
+ * 100% local. No SDK changes needed in the wrapped program.
8
+ */
9
+ const fs = require('fs');
10
+ const os = require('os');
11
+ const path = require('path');
12
+ const { spawn } = require('child_process');
13
+ const { Recorder, newSessionFile, VERSION } = require('./chain');
14
+ const { classifyLine } = require('./parse');
15
+
16
+ const DIM = '\x1b[2m';
17
+ const CYAN = '\x1b[36m';
18
+ const BOLD = '\x1b[1m';
19
+ const RESET = '\x1b[0m';
20
+ const MAX_PARTIAL_LINE = 1024 * 1024;
21
+
22
+ function shellQuote(arg) {
23
+ return `'${String(arg).replace(/'/g, `'"'"'`)}'`;
24
+ }
25
+
26
+ /**
27
+ * Interactive terminal programs (Codex, vim, etc.) must see a real TTY.
28
+ * util-linux `script` supplies a PTY while still letting us capture its output.
29
+ */
30
+ function spawnSpec(commandArgs, usePty, terminalSize = {}) {
31
+ if (!usePty) return { command: commandArgs[0], args: commandArgs.slice(1) };
32
+
33
+ const rows = Number.isInteger(terminalSize.rows) && terminalSize.rows > 0 ? terminalSize.rows : 24;
34
+ const columns = Number.isInteger(terminalSize.columns) && terminalSize.columns > 0 ? terminalSize.columns : 80;
35
+ if (process.platform === 'darwin' || process.platform.endsWith('bsd')) {
36
+ const command = `stty rows ${rows} cols ${columns} 2>/dev/null; exec ${commandArgs.map(shellQuote).join(' ')}`;
37
+ return { command: 'script', args: ['-q', '/dev/null', 'sh', '-c', command] };
38
+ }
39
+
40
+ // `script` writes to our capture pipe, so it cannot infer geometry from its
41
+ // own stdout. Set the newly allocated slave PTY before starting the command.
42
+ const command = `stty rows ${rows} cols ${columns} 2>/dev/null; exec ${commandArgs.map(shellQuote).join(' ')}`;
43
+ return { command: 'script', args: ['-qefc', command, '/dev/null'] };
44
+ }
45
+
46
+ function resizeChildPty(pid, terminalSize) {
47
+ if (process.platform !== 'linux' || !pid) return false;
48
+ const rows = Number(terminalSize.rows);
49
+ const columns = Number(terminalSize.columns);
50
+ if (!(rows > 0 && columns > 0)) return false;
51
+ try {
52
+ let current = pid;
53
+ // `script` starts a shell which execs the requested command.
54
+ for (let depth = 0; depth < 3; depth++) {
55
+ const children = fs.readFileSync(`/proc/${current}/task/${current}/children`, 'utf8').trim().split(/\s+/).filter(Boolean);
56
+ if (!children.length) break;
57
+ current = Number(children[0]);
58
+ }
59
+ const ttyFd = `/proc/${current}/fd/0`;
60
+ const resize = spawn('stty', ['-F', ttyFd, 'rows', String(rows), 'cols', String(columns)], {
61
+ stdio: 'ignore',
62
+ });
63
+ resize.on('error', () => {});
64
+ try { process.kill(current, 'SIGWINCH'); } catch { /* child may have exited */ }
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Line-buffered recorder around a raw stream.
73
+ * Emits one event per complete line (kind classified), flushes the
74
+ * trailing partial line on close.
75
+ */
76
+ class LineRecorder {
77
+ constructor(recorder, stream) {
78
+ this.rec = recorder;
79
+ this.stream = stream;
80
+ this.parts = [];
81
+ this.length = 0;
82
+ }
83
+
84
+ push(chunk) {
85
+ const data = String(chunk);
86
+ let start = 0;
87
+ let idx;
88
+ while ((idx = data.indexOf('\n', start)) !== -1) {
89
+ const part = data.slice(start, idx);
90
+ const line = this.parts.length ? this.parts.join('') + part : part;
91
+ this.parts = [];
92
+ this.length = 0;
93
+ this.emit(line);
94
+ start = idx + 1;
95
+ }
96
+ if (start < data.length) {
97
+ const rest = data.slice(start);
98
+ this.parts.push(rest);
99
+ this.length += rest.length;
100
+ if (this.length >= MAX_PARTIAL_LINE) {
101
+ this.emit(this.parts.join(''));
102
+ this.parts = [];
103
+ this.length = 0;
104
+ }
105
+ }
106
+ }
107
+
108
+ emit(line) {
109
+ // strip trailing \r (windows / progress bars)
110
+ const text = line.replace(/\r$/, '');
111
+ const { kind, detail } = classifyLine(text);
112
+ const data = { stream: this.stream, kind, text };
113
+ if (detail !== undefined) data.detail = detail;
114
+ this.rec.append('out', data);
115
+ }
116
+
117
+ flush() {
118
+ if (this.length) {
119
+ this.emit(this.parts.join(''));
120
+ this.parts = [];
121
+ this.length = 0;
122
+ }
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Record `commandArgs` (array) into a new session file.
128
+ * opts: { name, cwd, quiet, dir }
129
+ * Returns { file, exitCode, events } via callback or promise.
130
+ */
131
+ function wrap(commandArgs, opts = {}) {
132
+ return new Promise((resolve) => {
133
+ if (!commandArgs || commandArgs.length === 0) {
134
+ throw new Error('wrap: nothing to record — pass a command after `--`');
135
+ }
136
+ const name = opts.name || path.basename(commandArgs[0]);
137
+ const file = opts.file || newSessionFile(opts.cwd || process.cwd(), name);
138
+ const rec = new Recorder(file, {
139
+ name,
140
+ cmd: commandArgs.join(' '),
141
+ argv: commandArgs,
142
+ cwd: process.cwd(),
143
+ user: os.userInfo().username,
144
+ host: os.hostname(),
145
+ platform: `${os.platform()} ${os.arch()}`,
146
+ node: process.version,
147
+ agentbox: VERSION,
148
+ pid: process.pid,
149
+ });
150
+
151
+ if (!opts.quiet) {
152
+ process.stderr.write(`${DIM}${CYAN}⬢ agentbox${RESET}${DIM}: black box on → recording to ${file}${RESET}\n`);
153
+ }
154
+
155
+ const usePty = opts.pty === true || (opts.pty !== false && process.stdin.isTTY && process.platform !== 'win32');
156
+ const terminalSize = opts.terminalSize || {
157
+ columns: process.stdout.columns || process.stderr.columns,
158
+ rows: process.stdout.rows || process.stderr.rows,
159
+ };
160
+ const spec = spawnSpec(commandArgs, usePty, terminalSize);
161
+ const child = spawn(spec.command, spec.args, {
162
+ stdio: ['pipe', 'pipe', 'pipe'],
163
+ env: {
164
+ ...process.env,
165
+ AGENTBOX: '1',
166
+ AGENTBOX_SESSION: file,
167
+ ...(usePty ? { COLUMNS: String(terminalSize.columns || 80), LINES: String(terminalSize.rows || 24) } : {}),
168
+ },
169
+ cwd: opts.cwd || process.cwd(),
170
+ detached: process.platform !== 'win32',
171
+ });
172
+
173
+ const outR = new LineRecorder(rec, 'stdout');
174
+ const errR = new LineRecorder(rec, 'stderr');
175
+ const t0 = Date.now();
176
+ let done = false;
177
+ let requestedSignal = null;
178
+ let killTimer = null;
179
+ const killChild = (signal) => {
180
+ if (!child.pid) return;
181
+ try {
182
+ if (process.platform !== 'win32') process.kill(-child.pid, signal);
183
+ else child.kill(signal);
184
+ } catch { /* gone */ }
185
+ };
186
+ const forwardSignal = (signal) => {
187
+ if (done) return;
188
+ requestedSignal = signal;
189
+ rec.append('signal', { signal, source: 'parent' });
190
+ killChild(signal);
191
+ clearTimeout(killTimer);
192
+ killTimer = setTimeout(() => killChild('SIGKILL'), 2000);
193
+ killTimer.unref();
194
+ };
195
+ const onSigint = () => forwardSignal('SIGINT');
196
+ const onSigterm = () => forwardSignal('SIGTERM');
197
+ const onSighup = () => forwardSignal('SIGHUP');
198
+ process.on('SIGINT', onSigint);
199
+ process.on('SIGTERM', onSigterm);
200
+ process.on('SIGHUP', onSighup);
201
+ const onResize = () => resizeChildPty(child.pid, {
202
+ columns: process.stdout.columns || process.stderr.columns,
203
+ rows: process.stdout.rows || process.stderr.rows,
204
+ });
205
+ if (usePty) process.stdout.on('resize', onResize);
206
+
207
+ child.stdout.on('data', (c) => {
208
+ const s = c.toString('utf8');
209
+ outR.push(s);
210
+ if (!opts.quiet && !process.stdout.write(c)) {
211
+ child.stdout.pause();
212
+ process.stdout.once('drain', () => child.stdout.resume());
213
+ }
214
+ });
215
+ child.stderr.on('data', (c) => {
216
+ const s = c.toString('utf8');
217
+ errR.push(s);
218
+ if (!opts.quiet && !process.stderr.write(c)) {
219
+ child.stderr.pause();
220
+ process.stderr.once('drain', () => child.stderr.resume());
221
+ }
222
+ });
223
+
224
+ // stdin: forward + record (raw mode when TTY so we see every keystroke).
225
+ // Non-TTY stdin is opt-in via AGENTBOX_PIPE_STDIN=1 — an open, silent pipe
226
+ // would otherwise keep this process alive forever.
227
+ //
228
+ // Critical: always detach stdin on child close/error. Leaving a resumed
229
+ // stdin listener is what made `agentbox demo` / `wrap` hang the parent
230
+ // process after the agent exited (preflight, CI, scripts).
231
+ let stdinAttached = false;
232
+ const onStdinData = (d) => {
233
+ const s = d.toString('utf8');
234
+ const control = Math.min(...['\x03', '\x04'].map((c) => { const i = s.indexOf(c); return i < 0 ? Infinity : i; }));
235
+ if (Number.isFinite(control) && control > 0 && child.stdin && child.stdin.writable) child.stdin.write(d.subarray(0, control));
236
+ if (s.includes('\x03')) {
237
+ rec.append('signal', { signal: 'SIGINT', source: 'keyboard' });
238
+ requestedSignal = 'SIGINT';
239
+ killChild('SIGINT');
240
+ return;
241
+ }
242
+ if (s.includes('\x04')) {
243
+ if (child.stdin) child.stdin.end();
244
+ return;
245
+ }
246
+ if (child.stdin && child.stdin.writable) child.stdin.write(d);
247
+ if (s.length <= 512) rec.append('in', { text: s.replace(/\n$/, '') });
248
+ };
249
+
250
+ function attachStdin() {
251
+ if (process.stdin.isTTY) {
252
+ try { process.stdin.setRawMode(true); } catch { /* non-interactive */ }
253
+ process.stdin.on('data', onStdinData);
254
+ process.stdin.resume();
255
+ stdinAttached = true;
256
+ } else if (process.env.AGENTBOX_PIPE_STDIN === '1' && !process.stdin.readableEnded) {
257
+ process.stdin.on('data', onStdinData);
258
+ stdinAttached = true;
259
+ }
260
+ }
261
+
262
+ function detachStdin() {
263
+ if (!stdinAttached) return;
264
+ try { process.stdin.removeListener('data', onStdinData); } catch { /* fine */ }
265
+ if (process.stdin.isTTY) {
266
+ try { process.stdin.setRawMode(false); } catch { /* fine */ }
267
+ }
268
+ try { process.stdin.pause(); } catch { /* fine */ }
269
+ stdinAttached = false;
270
+ }
271
+
272
+ attachStdin();
273
+
274
+ function finalize(code, signal, spawnError) {
275
+ if (done) return;
276
+ done = true;
277
+ clearTimeout(killTimer);
278
+ process.removeListener('SIGINT', onSigint);
279
+ process.removeListener('SIGTERM', onSigterm);
280
+ process.removeListener('SIGHUP', onSighup);
281
+ process.stdout.removeListener('resize', onResize);
282
+ detachStdin();
283
+ if (spawnError) {
284
+ rec.append('out', { stream: 'stderr', kind: 'plain', text: `agentbox: failed to spawn: ${spawnError.message}` });
285
+ }
286
+ outR.flush(); errR.flush();
287
+ const durationMs = Date.now() - t0;
288
+ const finalCode = requestedSignal && code === 0 ? (requestedSignal === 'SIGINT' ? 130 : 143) : code;
289
+ rec.append('exit', {
290
+ code: finalCode,
291
+ durationMs,
292
+ signal: signal || undefined,
293
+ spawnError: spawnError ? spawnError.message : undefined,
294
+ });
295
+ rec.close();
296
+ if (!opts.quiet) {
297
+ process.stderr.write(`${DIM}${CYAN}⬢ agentbox${RESET}${DIM}: ${rec.i} events recorded · ${durationMs} ms · try: agentbox receipt${RESET}\n`);
298
+ }
299
+ resolve({ file, exitCode: finalCode, events: rec.i });
300
+ }
301
+
302
+ child.on('error', (e) => finalize(127, null, e));
303
+
304
+ child.on('close', (code, signal) => {
305
+ finalize(code == null ? (signal ? -1 : 1) : code, signal);
306
+ });
307
+ });
308
+ }
309
+
310
+ module.exports = { wrap, LineRecorder, spawnSpec, resizeChildPty };