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.
@@ -0,0 +1,346 @@
1
+ 'use strict';
2
+ /**
3
+ * agentbox — adapters/mcp.js
4
+ * The MCP wire tap: run any MCP server behind a recording proxy.
5
+ *
6
+ * agentbox mcp -- npx -y @modelcontextprotocol/server-everything
7
+ *
8
+ * Point your MCP client (Claude Desktop, Cursor, Claude Code, any harness)
9
+ * at agentbox instead of the server. Agentbox spawns the real server, forwards
10
+ * every JSON-RPC message verbatim, and writes a hash-chained tape of:
11
+ *
12
+ * mcp_msg every message, both directions (method + id + preview)
13
+ * tool_call every tools/call — start (name + arguments) and end
14
+ * (ok/error + duration + result preview), paired by request id
15
+ * note initialize handshake (server name/version/protocol)
16
+ *
17
+ * MCP stdio transport is newline-delimited JSON — the proxy is a line pump.
18
+ * Unknown bytes still get forwarded untouched; the tape never blocks a flight.
19
+ */
20
+ const fs = require('fs');
21
+ const os = require('os');
22
+ const path = require('path');
23
+ const { spawn } = require('child_process');
24
+ const { Recorder, newSessionFile, VERSION } = require('../chain');
25
+ const MAX_PARTIAL_LINE = 1024 * 1024;
26
+
27
+ function trunc(s, n) {
28
+ const str = s == null ? '' : String(s);
29
+ return str.length <= n ? str : `${str.slice(0, n)}…`;
30
+ }
31
+
32
+ /** Keep small JSON values intact; stringify+truncate anything chunky. */
33
+ function slim(v, cap) {
34
+ if (v == null) return null;
35
+ if (typeof v === 'string') return trunc(v, cap);
36
+ try {
37
+ const j = JSON.stringify(v);
38
+ if (j.length <= cap) return v;
39
+ const keys = ['command', 'file_path', 'notebook_path', 'path', 'url', 'query', 'pattern'];
40
+ const summary = { _truncated: true };
41
+ for (const key of keys) if (v[key] != null) summary[key] = trunc(v[key], cap / 2);
42
+ return Object.keys(summary).length > 1 ? summary : trunc(j, cap);
43
+ } catch { return trunc(String(v), cap); }
44
+ }
45
+
46
+ function requestKey(id) {
47
+ if (id === undefined) return null;
48
+ return `${typeof id}:${String(id)}`;
49
+ }
50
+
51
+ function responseStatus(msg) {
52
+ return msg && (msg.error != null || (msg.result && msg.result.isError)) ? 'error' : 'ok';
53
+ }
54
+
55
+ /** Split a byte stream into complete lines (MCP stdio = 1 JSON-RPC msg/line). */
56
+ class LineSplitter {
57
+ constructor(onLine, onFlush) {
58
+ this.parts = [];
59
+ this.length = 0;
60
+ this.onLine = onLine;
61
+ this.onFlush = onFlush;
62
+ }
63
+
64
+ push(chunk) {
65
+ const data = chunk.toString('utf8');
66
+ let start = 0;
67
+ let idx;
68
+ while ((idx = data.indexOf('\n', start)) !== -1) {
69
+ const part = data.slice(start, idx);
70
+ const line = (this.parts.length ? this.parts.join('') + part : part).replace(/\r$/, '');
71
+ this.parts = [];
72
+ this.length = 0;
73
+ if (line) this.onLine(line);
74
+ start = idx + 1;
75
+ }
76
+ if (start < data.length) {
77
+ const rest = data.slice(start);
78
+ this.parts.push(rest);
79
+ this.length += rest.length;
80
+ if (this.length >= MAX_PARTIAL_LINE) {
81
+ const bounded = this.parts.join('');
82
+ this.parts = [];
83
+ this.length = 0;
84
+ if (this.onFlush) this.onFlush(bounded);
85
+ }
86
+ }
87
+ }
88
+
89
+ flush() {
90
+ if (this.length) {
91
+ const rest = this.parts.join('');
92
+ if (rest.trim() && this.onFlush) this.onFlush(rest.replace(/\r$/, ''));
93
+ this.parts = [];
94
+ this.length = 0;
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Run the proxy. `serverArgs` is the real MCP server command line.
101
+ * opts: { name, cwd, quiet, file }
102
+ * Returns a promise: { file, exitCode }.
103
+ */
104
+ function runMcpProxy(serverArgs, opts = {}) {
105
+ return new Promise((resolve, reject) => {
106
+ if (!Array.isArray(serverArgs) || serverArgs.length === 0) {
107
+ reject(new Error('mcp: no server command — usage: agentbox mcp -- <server command> [args…]'));
108
+ return;
109
+ }
110
+ const name = opts.name || `${path.basename(serverArgs[0])}-mcp`;
111
+ const file = opts.file || newSessionFile(opts.cwd || process.cwd(), name);
112
+ const rec = new Recorder(file, {
113
+ name,
114
+ cmd: `mcp ⇄ ${serverArgs.join(' ')}`,
115
+ argv: serverArgs,
116
+ adapter: 'mcp',
117
+ cwd: process.cwd(),
118
+ user: os.userInfo().username,
119
+ host: os.hostname(),
120
+ platform: `${process.platform} ${process.arch}`,
121
+ agentbox: VERSION,
122
+ pid: process.pid,
123
+ });
124
+
125
+ if (!opts.quiet) {
126
+ const DIM = '\x1b[2m'; const CYAN = '\x1b[36m'; const BOLD = '\x1b[1m'; const RESET = '\x1b[0m';
127
+ process.stderr.write(`${DIM}${CYAN}⬢ agentbox${RESET}${DIM}: MCP wire tap on → ${file}${RESET}\n`);
128
+ process.stderr.write(`${DIM} point your MCP client at agentbox; server: ${serverArgs.join(' ')}${RESET}\n`);
129
+ }
130
+
131
+ const child = spawn(serverArgs[0], serverArgs.slice(1), {
132
+ stdio: ['pipe', 'pipe', 'pipe'],
133
+ env: { ...process.env, AGENTBOX: '1', AGENTBOX_SESSION: file },
134
+ cwd: opts.cwd || process.cwd(),
135
+ detached: process.platform !== 'win32',
136
+ });
137
+
138
+ const pending = new Map(); // request id (string) → { name, t0 }
139
+ const t0 = Date.now();
140
+ let serverName = null;
141
+ let serverVersion = null;
142
+ let calls = 0;
143
+ let done = false;
144
+ let anonSeq = 0;
145
+ let shutdownTimer = null;
146
+ let requestedSignal = null;
147
+
148
+ const clientSplit = new LineSplitter(onClientLine, (raw) => forwardToServer(raw));
149
+ const serverSplit = new LineSplitter(onServerLine, (raw) => forwardToClient(raw));
150
+
151
+ function finalize(code, signal) {
152
+ if (done) return;
153
+ done = true;
154
+ clearTimeout(shutdownTimer);
155
+ process.removeListener('SIGINT', onSigint);
156
+ process.removeListener('SIGTERM', onSigterm);
157
+ clientSplit.flush();
158
+ serverSplit.flush();
159
+ rec.append('exit', {
160
+ code: code == null ? (signal ? -1 : 0) : code,
161
+ durationMs: Date.now() - t0,
162
+ signal: signal || undefined,
163
+ server: serverName,
164
+ unansweredCalls: pending.size,
165
+ toolCalls: calls,
166
+ });
167
+ rec.close();
168
+ if (!opts.quiet) {
169
+ const DIM = '\x1b[2m'; const CYAN = '\x1b[36m'; const RESET = '\x1b[0m';
170
+ process.stderr.write(`${DIM}${CYAN}⬢ agentbox${RESET}${DIM}: ${rec.i} events recorded · ${calls} tool calls · try: agentbox receipt${RESET}\n`);
171
+ }
172
+ const exitCode = requestedSignal === 'SIGINT' ? 130 : requestedSignal === 'SIGTERM' ? 143 : (code == null ? (signal ? 1 : 0) : code);
173
+ resolve({ file, exitCode });
174
+ }
175
+
176
+ function forwardToServer(line) {
177
+ if (child.stdin && child.stdin.writable && !child.stdin.write(`${line}\n`)) {
178
+ process.stdin.pause();
179
+ child.stdin.once('drain', () => process.stdin.resume());
180
+ }
181
+ }
182
+ function forwardToClient(line) {
183
+ if (!process.stdout.write(`${line}\n`)) {
184
+ child.stdout.pause();
185
+ process.stdout.once('drain', () => child.stdout.resume());
186
+ }
187
+ }
188
+
189
+ /** client (agent) → agentbox → real server */
190
+ function onClientLine(line) {
191
+ let msg = null;
192
+ try { msg = JSON.parse(line); } catch { /* not JSON — still forwarded verbatim */ }
193
+ if (msg && typeof msg === 'object') {
194
+ rec.append('mcp_msg', {
195
+ dir: 'C2S',
196
+ method: msg.method || null,
197
+ id: msg.id === undefined ? null : msg.id,
198
+ preview: trunc(line, 400),
199
+ });
200
+ const key = msg.id === undefined ? `anon:${++anonSeq}` : requestKey(msg.id);
201
+ if (msg.method === 'tools/call' && msg.params) {
202
+ calls += 1;
203
+ rec.append('tool_call', {
204
+ phase: 'start',
205
+ name: String(msg.params.name || 'unknown'),
206
+ input: slim(msg.params.arguments, 2000),
207
+ source: 'mcp',
208
+ id: msg.id === undefined ? null : msg.id,
209
+ });
210
+ pending.set(key, { name: String(msg.params.name || 'unknown'), t0: Date.now() });
211
+ } else if (msg.method === 'initialize') {
212
+ rec.append('note', { message: `client initialize · protocol ${msg.params && msg.params.protocolVersion ? msg.params.protocolVersion : '?'}` });
213
+ }
214
+ }
215
+ forwardToServer(line);
216
+ }
217
+
218
+ /** real server → agentbox → client */
219
+ function onServerLine(line) {
220
+ let msg = null;
221
+ try { msg = JSON.parse(line); } catch { /* pass through untouched */ }
222
+ if (msg && typeof msg === 'object') {
223
+ rec.append('mcp_msg', {
224
+ dir: 'S2C',
225
+ method: msg.method || null,
226
+ id: msg.id === undefined ? null : msg.id,
227
+ preview: trunc(line, 400),
228
+ });
229
+ const key = requestKey(msg.id);
230
+ if (pending.has(key)) {
231
+ const p = pending.get(key);
232
+ pending.delete(key);
233
+ const result = msg.result || {};
234
+ const rpcError = msg.error != null;
235
+ rec.append('tool_call', {
236
+ phase: 'end',
237
+ name: p.name,
238
+ source: 'mcp',
239
+ status: responseStatus(msg),
240
+ durationMs: Date.now() - p.t0,
241
+ preview: slim(rpcError ? msg.error : (result.content !== undefined ? result.content : result), 500),
242
+ id: msg.id === undefined ? null : msg.id,
243
+ });
244
+ }
245
+ if (msg.result && msg.result.serverInfo) {
246
+ serverName = (msg.result.serverInfo && msg.result.serverInfo.name) || null;
247
+ serverVersion = (msg.result.serverInfo && msg.result.serverInfo.version) || null;
248
+ rec.append('note', { message: `server: ${serverName || '?'}${serverVersion ? ` v${serverVersion}` : ''} · protocol ${msg.result.protocolVersion || '?'}` });
249
+ }
250
+ }
251
+ forwardToClient(line);
252
+ }
253
+
254
+ process.stdin.setEncoding('utf8');
255
+ process.stdin.on('data', (c) => clientSplit.push(c));
256
+ process.stdin.on('end', () => {
257
+ clientSplit.flush();
258
+ try { child.stdin.end(); } catch { /* gone */ }
259
+ shutdownTimer = setTimeout(() => terminate('SIGTERM'), 1000);
260
+ shutdownTimer.unref();
261
+ });
262
+ process.stdin.on('error', () => { /* client vanished; server close will finalize */ });
263
+
264
+ child.stdout.setEncoding('utf8');
265
+ child.stdout.on('data', (c) => serverSplit.push(c));
266
+
267
+ child.stderr.setEncoding('utf8');
268
+ child.stderr.on('data', (c) => {
269
+ process.stderr.write(c); // server logs stay visible — transparency first
270
+ for (const line of String(c).split('\n')) {
271
+ if (line.trim()) rec.append('out', { stream: 'stderr', kind: 'plain', text: trunc(line, 500) });
272
+ }
273
+ });
274
+
275
+ child.on('error', (e) => {
276
+ rec.append('out', { stream: 'stderr', kind: 'plain', text: `agentbox: failed to spawn MCP server: ${e.message}` });
277
+ finalize(127);
278
+ });
279
+ child.on('close', (code, signal) => finalize(code, signal));
280
+
281
+ const killChild = (signal) => {
282
+ if (!child.pid) return;
283
+ try { if (process.platform !== 'win32') process.kill(-child.pid, signal); else child.kill(signal); } catch { /* gone */ }
284
+ };
285
+ const terminate = (signal) => {
286
+ requestedSignal = requestedSignal || signal;
287
+ rec.append('signal', { signal, source: 'keyboard' });
288
+ killChild(signal);
289
+ clearTimeout(shutdownTimer);
290
+ shutdownTimer = setTimeout(() => killChild('SIGKILL'), 2000);
291
+ shutdownTimer.unref();
292
+ };
293
+ const onSigint = () => terminate('SIGINT');
294
+ const onSigterm = () => terminate('SIGTERM');
295
+ process.on('SIGINT', onSigint);
296
+ process.on('SIGTERM', onSigterm);
297
+ });
298
+ }
299
+
300
+ /**
301
+ * `agentbox init mcp -- <server command>`
302
+ * Print ready-to-paste MCP config blocks that route the server through agentbox.
303
+ */
304
+ function initMcp(serverArgs, opts = {}) {
305
+ if (!Array.isArray(serverArgs) || serverArgs.length === 0) {
306
+ process.stderr.write('usage: agentbox init mcp -- <server command> [args…]\nexample: agentbox init mcp -- npx -y @modelcontextprotocol/server-everything\n');
307
+ process.exitCode = 1;
308
+ return;
309
+ }
310
+ const CYAN = '\x1b[36m'; const BOLD = '\x1b[2m'; const DIM = '\x1b[2m'; const RESET = '\x1b[0m';
311
+ // identity: prefer the package/module arg (npx -y @scope/pkg, node path/server.js)
312
+ // over the runner binary (npx/node) so config keys are meaningful
313
+ let serverName = path.basename(serverArgs[0]).replace(/[^\w.-]+/g, '-');
314
+ for (let i = 1; i < serverArgs.length; i++) {
315
+ const a = String(serverArgs[i]);
316
+ if (a.startsWith('-')) continue; // flags (and values we can skip cheaply)
317
+ if (a.startsWith('@') || a.includes('/') || a.endsWith('.js')) {
318
+ serverName = a.replace(/^@/, '').replace(/[^\w.-]+/g, '-');
319
+ break;
320
+ }
321
+ }
322
+ const displayCmd = `agentbox mcp -- ${serverArgs.join(' ')}`;
323
+
324
+ process.stdout.write(`${CYAN}${BOLD}⬢ agentbox${RESET}: MCP wire tap — route ${DIM}${serverArgs.join(' ')}${RESET} through agentbox\n\n`);
325
+ process.stdout.write(` every ${BOLD}tools/call${RESET} (arguments, results, duration) lands on a tamper-evident tape\n\n`);
326
+
327
+ const block = (title, file, json) => {
328
+ process.stdout.write(` ${BOLD}${title}${RESET} ${DIM}${file}${RESET}\n ${json.replace(/\n/g, '\n ')}\n\n`);
329
+ };
330
+
331
+ block('Claude Code (project)', '.mcp.json', JSON.stringify({
332
+ mcpServers: { [serverName]: { command: 'agentbox', args: ['mcp', '--', ...serverArgs] } },
333
+ }, null, 2));
334
+
335
+ block('Claude Desktop', 'claude_desktop_config.json', JSON.stringify({
336
+ mcpServers: { [serverName]: { command: 'agentbox', args: ['mcp', '--', ...serverArgs] } },
337
+ }, null, 2));
338
+
339
+ block('Cursor', '~/.cursor/mcp.json', JSON.stringify({
340
+ mcpServers: { [serverName]: { command: 'agentbox', args: ['mcp', '--', ...serverArgs] } },
341
+ }, null, 2));
342
+
343
+ process.stdout.write(` ${DIM}alias: ${displayCmd}\n agentbox must be on PATH (npm i -g agentbox-cli) or replace "agentbox"\n with \`node <path>/bin/agentbox.js\`. restart the client to pick it up.${RESET}\n`);
344
+ }
345
+
346
+ module.exports = { runMcpProxy, initMcp, LineSplitter, requestKey, responseStatus };
package/src/chain.js ADDED
@@ -0,0 +1,286 @@
1
+ 'use strict';
2
+ /**
3
+ * agentbox — chain.js
4
+ * Tamper-evident, hash-chained event log (the "black box tape").
5
+ *
6
+ * Every event is a JSONL line:
7
+ * { i, t, type, data, prev, hash }
8
+ * where hash = sha256(prev || i || t || type || JSON(data)).
9
+ * Genesis prev is 64 zeros. Break or edit ANY line and verify() fails.
10
+ */
11
+ const crypto = require('crypto');
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { redactEventData } = require('./redact');
15
+
16
+ const GENESIS = '0'.repeat(64);
17
+ const VERSION = '0.2.1';
18
+ const LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(4));
19
+
20
+ function sha256(s) {
21
+ return crypto.createHash('sha256').update(s).digest('hex');
22
+ }
23
+
24
+ /** Deterministic event hash. data MUST be a JSON-stable object. */
25
+ function eventHash(prev, i, t, type, data) {
26
+ return sha256(JSON.stringify([prev, i, t, type, data]));
27
+ }
28
+
29
+ function assertNotSymlink(file) {
30
+ const absolute = path.resolve(file);
31
+ const parsed = path.parse(absolute);
32
+ let current = parsed.root;
33
+ for (const part of absolute.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
34
+ current = path.join(current, part);
35
+ try {
36
+ if (fs.lstatSync(current).isSymbolicLink()) throw new Error(`refusing symlink path: ${current}`);
37
+ } catch (e) {
38
+ if (e.code === 'ENOENT') return;
39
+ throw e;
40
+ }
41
+ }
42
+ }
43
+
44
+ /** Append-only recorder. Writes JSONL, one event per line. */
45
+ class Recorder {
46
+ constructor(file, meta) {
47
+ this.file = file;
48
+ this.i = 0;
49
+ this.prev = GENESIS;
50
+ this.redactions = 0;
51
+ this.pending = [];
52
+ this.pendingBytes = 0;
53
+ this.flushTimer = null;
54
+ assertNotSymlink(file);
55
+ fs.mkdirSync(path.dirname(file), { recursive: true });
56
+ this.fd = fs.openSync(file, 'w');
57
+ // stamp redaction policy into meta so the tape is self-describing
58
+ const redactOff = process.env.AGENTBOX_REDACT != null
59
+ && /^(0|false|off|no)$/i.test(String(process.env.AGENTBOX_REDACT).trim());
60
+ const metaWithPolicy = { ...meta, redact: !redactOff };
61
+ this.append('meta', metaWithPolicy); // event 0
62
+ this.flush(); // make the session discoverable immediately
63
+ }
64
+
65
+ flush() {
66
+ if (this.flushTimer) {
67
+ clearTimeout(this.flushTimer);
68
+ this.flushTimer = null;
69
+ }
70
+ if (!this.pendingBytes) return;
71
+ fs.writeSync(this.fd, this.pending.join(''));
72
+ this.pending = [];
73
+ this.pendingBytes = 0;
74
+ }
75
+
76
+ append(type, data) {
77
+ const { data: scrubbed, count } = redactEventData(data);
78
+ this.redactions += count;
79
+ const t = Date.now();
80
+ const i = this.i++;
81
+ const hash = eventHash(this.prev, i, t, type, scrubbed);
82
+ const line = JSON.stringify({ i, t, type, data: scrubbed, prev: this.prev, hash });
83
+ const record = line + '\n';
84
+ this.pending.push(record);
85
+ this.pendingBytes += Buffer.byteLength(record);
86
+ if (this.pendingBytes >= 64 * 1024) this.flush();
87
+ else if (!this.flushTimer) {
88
+ this.flushTimer = setTimeout(() => this.flush(), 25);
89
+ this.flushTimer.unref();
90
+ }
91
+ this.prev = hash;
92
+ return { i, t, type, data: scrubbed, hash, redactions: count };
93
+ }
94
+
95
+ close() {
96
+ this.flush();
97
+ try { fs.closeSync(this.fd); } catch { /* already closed */ }
98
+ }
99
+ }
100
+
101
+ /** Parse a session file into events (tolerates corrupt lines). */
102
+ function loadEvents(file) {
103
+ let fd;
104
+ try {
105
+ fd = fs.openSync(file, 'r');
106
+ } catch (e) {
107
+ return { events: [], corrupt: { line: 0, error: e.message } };
108
+ }
109
+ const events = [];
110
+ const chunk = Buffer.allocUnsafe(64 * 1024);
111
+ let carry = Buffer.alloc(0);
112
+ let lineNo = 0;
113
+ try {
114
+ for (;;) {
115
+ const bytes = fs.readSync(fd, chunk, 0, chunk.length, null);
116
+ if (!bytes) break;
117
+ const data = carry.length ? Buffer.concat([carry, chunk.subarray(0, bytes)]) : chunk.subarray(0, bytes);
118
+ let start = 0;
119
+ for (let i = 0; i < data.length; i++) {
120
+ if (data[i] !== 10) continue;
121
+ const line = data.subarray(start, i).toString('utf8').trim();
122
+ if (line) events.push(JSON.parse(line));
123
+ lineNo += 1;
124
+ start = i + 1;
125
+ }
126
+ carry = start < data.length ? Buffer.from(data.subarray(start)) : Buffer.alloc(0);
127
+ }
128
+ const tail = carry.toString('utf8').trim();
129
+ if (tail) events.push(JSON.parse(tail));
130
+ } catch (e) {
131
+ return { events, corrupt: { line: lineNo, error: e.message } };
132
+ } finally {
133
+ fs.closeSync(fd);
134
+ }
135
+ return { events, corrupt: null };
136
+ }
137
+
138
+ /** Recompute the full hash chain. Returns { ok, reason?, events }. */
139
+ function verifyChain(file) {
140
+ const { events, corrupt } = loadEvents(file);
141
+ if (corrupt) {
142
+ return { ok: false, reason: `unreadable/corrupt JSON at line ${corrupt.line + 1}: ${corrupt.error}`, events };
143
+ }
144
+ if (events.length === 0) {
145
+ return { ok: false, reason: 'empty session', events };
146
+ }
147
+ let prev = GENESIS;
148
+ for (let index = 0; index < events.length; index++) {
149
+ const ev = events[index];
150
+ if (!ev || typeof ev !== 'object' || ev.i !== index || !Number.isFinite(ev.t) || typeof ev.type !== 'string' || !ev.data || typeof ev.data !== 'object' || Array.isArray(ev.data) || typeof ev.hash !== 'string') {
151
+ return { ok: false, reason: `invalid event structure at event ${index}`, events };
152
+ }
153
+ if (index === 0 && ev.type !== 'meta') return { ok: false, reason: 'event 0 must be session metadata', events };
154
+ if (index > 0 && ev.t < events[index - 1].t) return { ok: false, reason: `timestamp moved backwards at event ${index}`, events };
155
+ if (ev.prev !== prev) {
156
+ return { ok: false, reason: `chain break at event ${ev.i}: prev-pointer mismatch`, events };
157
+ }
158
+ const expect = eventHash(prev, ev.i, ev.t, ev.type, ev.data);
159
+ if (ev.hash !== expect) {
160
+ return { ok: false, reason: `hash mismatch at event ${ev.i} — content changed without rebuilding the chain`, events };
161
+ }
162
+ prev = ev.hash;
163
+ }
164
+ const complete = events[events.length - 1].type === 'exit';
165
+ return { ok: true, complete, events, count: events.length };
166
+ }
167
+
168
+ /**
169
+ * Last event in a session file — the cheap tail-read used to extend a chain
170
+ * from a different process (hook adapters are short-lived CLI invocations).
171
+ * Throws on a corrupt tail (never silently fork a broken chain); returns
172
+ * null only when the file is missing or empty.
173
+ */
174
+ function lastEvent(file) {
175
+ let fd;
176
+ try {
177
+ fd = fs.openSync(file, 'r');
178
+ } catch (e) {
179
+ if (e.code === 'ENOENT') return null;
180
+ throw e;
181
+ }
182
+ try {
183
+ const size = fs.fstatSync(fd).size;
184
+ const chunks = [];
185
+ let end = size;
186
+ let foundNewline = false;
187
+ while (end > 0 && !foundNewline) {
188
+ const start = Math.max(0, end - 4096);
189
+ const buf = Buffer.allocUnsafe(end - start);
190
+ fs.readSync(fd, buf, 0, buf.length, start);
191
+ chunks.unshift(buf);
192
+ const combined = Buffer.concat(chunks);
193
+ let last = combined.length - 1;
194
+ while (last >= 0 && (combined[last] === 10 || combined[last] === 13 || combined[last] === 32 || combined[last] === 9)) last--;
195
+ const nl = combined.lastIndexOf(10, last);
196
+ if (nl >= 0 || start === 0) {
197
+ foundNewline = true;
198
+ const line = combined.subarray(nl + 1, last + 1).toString('utf8');
199
+ return line ? JSON.parse(line) : null;
200
+ }
201
+ end = start;
202
+ }
203
+ return null;
204
+ } finally {
205
+ fs.closeSync(fd);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Append one event to an existing chain file (or start one from genesis).
211
+ * Single appendFileSync write — atomic for lines < 4 KB on POSIX.
212
+ * Wrap in withFileLock() when several processes may race.
213
+ */
214
+ function appendToChain(file, type, data) {
215
+ assertNotSymlink(file);
216
+ const { data: scrubbed, count } = redactEventData(data);
217
+ const last = lastEvent(file);
218
+ const i = last ? last.i + 1 : 0;
219
+ const prev = last ? last.hash : GENESIS;
220
+ const t = Date.now();
221
+ const hash = eventHash(prev, i, t, type, scrubbed);
222
+ const line = JSON.stringify({ i, t, type, data: scrubbed, prev, hash });
223
+ fs.mkdirSync(path.dirname(file), { recursive: true });
224
+ fs.appendFileSync(file, line + '\n');
225
+ return { i, t, type, data: scrubbed, hash, redactions: count };
226
+ }
227
+
228
+ /**
229
+ * Exclusive cross-process lock around chain appends (hook adapter).
230
+ * fn(locked) — locked=false means we gave up after ~5 s; callers should
231
+ * DROP the event then (a dropped event beats a forked hash chain).
232
+ */
233
+ function withFileLock(file, fn) {
234
+ const lock = `${file}.lock`;
235
+ assertNotSymlink(lock);
236
+ fs.mkdirSync(path.dirname(file), { recursive: true }); // lock file lives next to the session
237
+ const deadline = Date.now() + 5000;
238
+ let acquired = false;
239
+ const token = `${process.pid}:${crypto.randomBytes(16).toString('hex')}`;
240
+ for (;;) {
241
+ let fd;
242
+ try {
243
+ fd = fs.openSync(lock, 'wx');
244
+ fs.writeSync(fd, token);
245
+ fs.closeSync(fd);
246
+ acquired = true;
247
+ break;
248
+ } catch (e) {
249
+ if (e.code !== 'EEXIST') throw e;
250
+ // stale lock from a crashed process? reclaim it
251
+ try {
252
+ const owner = fs.readFileSync(lock, 'utf8').split(':')[0];
253
+ let alive = false;
254
+ try { process.kill(Number(owner), 0); alive = true; } catch { /* dead */ }
255
+ if (!alive && Date.now() - fs.statSync(lock).mtimeMs > 5000) { fs.unlinkSync(lock); continue; }
256
+ } catch { /* vanished — retry */ }
257
+ if (Date.now() > deadline) break;
258
+ // Synchronous hooks still need to wait, but sleeping avoids burning a core.
259
+ Atomics.wait(LOCK_SLEEP, 0, 0, 25);
260
+ }
261
+ }
262
+ try {
263
+ return fn(acquired);
264
+ } finally {
265
+ if (acquired) {
266
+ try { if (fs.readFileSync(lock, 'utf8') === token) fs.unlinkSync(lock); } catch { /* already gone */ }
267
+ }
268
+ }
269
+ }
270
+
271
+ /** Default sessions dir for a project. */
272
+ function sessionsDir(cwd) {
273
+ return path.join(cwd || process.cwd(), '.agentbox', 'sessions');
274
+ }
275
+
276
+ let sessionSequence = 0;
277
+
278
+ function newSessionFile(cwd, name) {
279
+ const dir = sessionsDir(cwd);
280
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 23);
281
+ const safe = (name || 'session').replace(/[^\w.-]+/g, '-').slice(0, 48);
282
+ const sequence = sessionSequence++;
283
+ return path.join(dir, `${stamp}-${process.pid}-${sequence}-${safe}.jsonl`);
284
+ }
285
+
286
+ module.exports = { GENESIS, VERSION, sha256, eventHash, Recorder, loadEvents, verifyChain, lastEvent, appendToChain, withFileLock, sessionsDir, newSessionFile, assertNotSymlink };