@zanii/blackbox 0.0.0-stage → 0.2.0

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.
Files changed (135) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +109 -2
  3. package/dist/agents/index.d.ts +34 -0
  4. package/dist/agents/index.js +73 -0
  5. package/dist/analysis/detectors.d.ts +36 -0
  6. package/dist/analysis/detectors.js +339 -0
  7. package/dist/analysis/faults.d.ts +9 -0
  8. package/dist/analysis/faults.js +250 -0
  9. package/dist/analysis/index.d.ts +68 -0
  10. package/dist/analysis/index.js +388 -0
  11. package/dist/analysis/landing.d.ts +25 -0
  12. package/dist/analysis/landing.js +225 -0
  13. package/dist/analysis/memory.d.ts +13 -0
  14. package/dist/analysis/memory.js +33 -0
  15. package/dist/analysis/waste.d.ts +29 -0
  16. package/dist/analysis/waste.js +79 -0
  17. package/dist/approvals/index.d.ts +11 -0
  18. package/dist/approvals/index.js +27 -0
  19. package/dist/approvals/warnings.d.ts +2 -0
  20. package/dist/approvals/warnings.js +28 -0
  21. package/dist/attest/index.d.ts +17 -0
  22. package/dist/attest/index.js +106 -0
  23. package/dist/authority/index.d.ts +24 -0
  24. package/dist/authority/index.js +77 -0
  25. package/dist/billing/index.d.ts +99 -0
  26. package/dist/billing/index.js +174 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +1057 -0
  29. package/dist/client/index.d.ts +146 -0
  30. package/dist/client/index.js +210 -0
  31. package/dist/compliance/index.d.ts +41 -0
  32. package/dist/compliance/index.js +96 -0
  33. package/dist/cost/index.d.ts +133 -0
  34. package/dist/cost/index.js +293 -0
  35. package/dist/data/index.d.ts +191 -0
  36. package/dist/data/index.js +762 -0
  37. package/dist/directives/index.d.ts +35 -0
  38. package/dist/directives/index.js +80 -0
  39. package/dist/drills/index.d.ts +43 -0
  40. package/dist/drills/index.js +101 -0
  41. package/dist/duty/index.d.ts +21 -0
  42. package/dist/duty/index.js +68 -0
  43. package/dist/fleet/index.d.ts +141 -0
  44. package/dist/fleet/index.js +454 -0
  45. package/dist/hooks/ai-sdk.d.ts +42 -0
  46. package/dist/hooks/ai-sdk.js +62 -0
  47. package/dist/hooks/claude-agent-sdk.d.ts +14 -0
  48. package/dist/hooks/claude-agent-sdk.js +70 -0
  49. package/dist/hooks/index.d.ts +7 -0
  50. package/dist/hooks/index.js +10 -0
  51. package/dist/hooks/langchain-agent.d.ts +69 -0
  52. package/dist/hooks/langchain-agent.js +163 -0
  53. package/dist/hooks/langchain.d.ts +41 -0
  54. package/dist/hooks/langchain.js +216 -0
  55. package/dist/hooks/langgraph-checkpoint.d.ts +12 -0
  56. package/dist/hooks/langgraph-checkpoint.js +73 -0
  57. package/dist/hooks/memory.d.ts +17 -0
  58. package/dist/hooks/memory.js +64 -0
  59. package/dist/hooks/openai-agents.d.ts +6 -0
  60. package/dist/hooks/openai-agents.js +40 -0
  61. package/dist/hooks/protect.d.ts +7 -0
  62. package/dist/hooks/protect.js +39 -0
  63. package/dist/hooks/providers.d.ts +16 -0
  64. package/dist/hooks/providers.js +149 -0
  65. package/dist/hooks/shared.d.ts +11 -0
  66. package/dist/hooks/shared.js +39 -0
  67. package/dist/index.d.ts +46 -0
  68. package/dist/index.js +48 -0
  69. package/dist/investigate/index.d.ts +66 -0
  70. package/dist/investigate/index.js +119 -0
  71. package/dist/mcp-server/index.d.ts +85 -0
  72. package/dist/mcp-server/index.js +216 -0
  73. package/dist/mcp-wrap/index.d.ts +17 -0
  74. package/dist/mcp-wrap/index.js +170 -0
  75. package/dist/money/index.d.ts +114 -0
  76. package/dist/money/index.js +622 -0
  77. package/dist/occurrence/index.d.ts +108 -0
  78. package/dist/occurrence/index.js +168 -0
  79. package/dist/ocsf/index.d.ts +22 -0
  80. package/dist/ocsf/index.js +168 -0
  81. package/dist/otlp/index.d.ts +24 -0
  82. package/dist/otlp/index.js +143 -0
  83. package/dist/packs/index.d.ts +48 -0
  84. package/dist/packs/index.js +343 -0
  85. package/dist/policy/delta.d.ts +11 -0
  86. package/dist/policy/delta.js +39 -0
  87. package/dist/policy/drafts.d.ts +34 -0
  88. package/dist/policy/drafts.js +129 -0
  89. package/dist/policy/index.d.ts +47 -0
  90. package/dist/policy/index.js +154 -0
  91. package/dist/precog/index.d.ts +96 -0
  92. package/dist/precog/index.js +167 -0
  93. package/dist/precog/intervention.d.ts +22 -0
  94. package/dist/precog/intervention.js +44 -0
  95. package/dist/precog/normal.d.ts +31 -0
  96. package/dist/precog/normal.js +89 -0
  97. package/dist/preflight/index.d.ts +11 -0
  98. package/dist/preflight/index.js +19 -0
  99. package/dist/ratings/index.d.ts +21 -0
  100. package/dist/ratings/index.js +48 -0
  101. package/dist/reconcile/claude-code.d.ts +19 -0
  102. package/dist/reconcile/claude-code.js +220 -0
  103. package/dist/reconcile/codex.d.ts +5 -0
  104. package/dist/reconcile/codex.js +191 -0
  105. package/dist/reconcile/index.d.ts +19 -0
  106. package/dist/reconcile/index.js +50 -0
  107. package/dist/reconcile/record.d.ts +49 -0
  108. package/dist/reconcile/record.js +225 -0
  109. package/dist/reconcile/shared.d.ts +65 -0
  110. package/dist/reconcile/shared.js +113 -0
  111. package/dist/replay/index.d.ts +11 -0
  112. package/dist/replay/index.js +64 -0
  113. package/dist/replay/repair.d.ts +10 -0
  114. package/dist/replay/repair.js +62 -0
  115. package/dist/session/drain.d.ts +13 -0
  116. package/dist/session/drain.js +35 -0
  117. package/dist/session/index.d.ts +275 -0
  118. package/dist/session/index.js +681 -0
  119. package/dist/undo/index.d.ts +45 -0
  120. package/dist/undo/index.js +212 -0
  121. package/dist/verify/anchor.d.ts +54 -0
  122. package/dist/verify/anchor.js +77 -0
  123. package/dist/verify/chain.d.ts +27 -0
  124. package/dist/verify/chain.js +105 -0
  125. package/dist/verify/envelope.d.ts +28 -0
  126. package/dist/verify/envelope.js +55 -0
  127. package/dist/verify/index.d.ts +3 -0
  128. package/dist/verify/index.js +3 -0
  129. package/dist/version.d.ts +1 -0
  130. package/dist/version.js +2 -0
  131. package/dist/weather/index.d.ts +24 -0
  132. package/dist/weather/index.js +45 -0
  133. package/dist/workspace-receipt/index.d.ts +15 -0
  134. package/dist/workspace-receipt/index.js +121 -0
  135. package/package.json +56 -3
@@ -0,0 +1,681 @@
1
+ // SDK sessions (spec/sdk.md): a local spool with sdk_seq (T1.1), shipped in the background, and the
2
+ // never-crash contract (T1.7). Mirrors sdks/python/src/zanii_blackbox/session.py.
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { appendFileSync, closeSync, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, rmSync, statSync, truncateSync, writeFileSync, } from "node:fs";
5
+ import { request as httpRequest } from "node:http";
6
+ import { request as httpsRequest } from "node:https";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { attest, isReadOnly } from "../attest/index.js";
10
+ /** spec/data.md §6: a direct HTTPS request to `url` (default https://example.com). Never throws. */
11
+ export async function checkEgress(options = {}) {
12
+ const url = options.url ?? "https://example.com";
13
+ try {
14
+ await fetch(url, { method: "HEAD", signal: AbortSignal.timeout(options.timeoutMs ?? 5000) });
15
+ return { url, open: true };
16
+ }
17
+ catch {
18
+ return { url, open: false };
19
+ }
20
+ }
21
+ const TYPE = /^[a-z][a-z0-9_.]{0,63}$/;
22
+ /** N4 (idea R6): a caller's logical id for an event, so a re-sent one is recorded as a duplicate. */
23
+ const EVENT_ID = /^[A-Za-z0-9._:-]{1,128}$/;
24
+ /** N4 (idea R6): the same parts always give the same event id (sha256, 128 bits). */
25
+ export function stableEventId(...parts) {
26
+ return `evt_${createHash("sha256").update(parts.join("\u001f")).digest("hex").slice(0, 32)}`;
27
+ }
28
+ const MAX_DATA = 64 * 1024;
29
+ /** Audit S20: how much of an over-long event's data is kept, in code points of its JSON. */
30
+ const PREVIEW = 4096;
31
+ const BATCH = 500;
32
+ const READ_CHUNK = 4 * 1024 * 1024;
33
+ const LOCK_WAIT_MS = 2_000;
34
+ const LOCK_STALE_MS = 10_000;
35
+ /** Opens (or joins) a session. Never throws: on failure it returns a disabled session and logs why. */
36
+ export async function session(options) {
37
+ const log = options.logger ?? (() => { });
38
+ let id = options.sessionId;
39
+ let token = options.token;
40
+ try {
41
+ if (!id || !token) {
42
+ const key = options.adminKey ?? options.apiKey;
43
+ if (!key)
44
+ throw new Error("pass sessionId + token, or adminKey to open a session");
45
+ const o = options;
46
+ // Audit K10: one key for every attempt, so a retry after a lost answer can't open a second.
47
+ const idempotencyKey = randomUUID();
48
+ const r = await openWithRetry(options.url, key, idempotencyKey, {
49
+ ...(o.label ? { label: o.label } : {}),
50
+ ...(o.mode ? { mode: o.mode } : {}),
51
+ ...(o.flightPlan ? { flight_plan: o.flightPlan } : {}),
52
+ ...(o.policy ? { policy: o.policy } : {}),
53
+ ...(o.budgetUsd !== undefined ? { budget_usd: o.budgetUsd } : {}),
54
+ ...(o.maxLlmCalls !== undefined ? { max_llm_calls: o.maxLlmCalls } : {}),
55
+ ...(o.parent ? { parent: o.parent } : {}),
56
+ ...(o.handoff ? { handoff: o.handoff } : {}),
57
+ ...(o.replay ? { replay: o.replay } : {}),
58
+ ...(o.tenant ? { tenant: o.tenant } : {}),
59
+ ...(o.authority ? { authority: o.authority } : {}),
60
+ ...(o.preflight ? { preflight: o.preflight } : {}),
61
+ ...(o.drill ? { drill: o.drill } : {}),
62
+ sdk: true, // this SDK will report the session's model calls (L2.3.2)
63
+ });
64
+ if (r.status !== 201)
65
+ throw new Error(`opening a session: the gateway answered ${r.status}${typeof r.json.error === "string" ? ` (${r.json.error})` : ""}`);
66
+ id = String(r.json.session_id);
67
+ token = String(r.json.token);
68
+ return new BlackboxSession(options, id, token, true, true);
69
+ }
70
+ return new BlackboxSession(options, id, token);
71
+ }
72
+ catch (error) {
73
+ log(`blackbox: session disabled: ${message(error)}`);
74
+ return BlackboxSession.disabled(options, message(error));
75
+ }
76
+ }
77
+ export class BlackboxSession {
78
+ stats = { recorded: 0, acked: 0, errors: 0, dropped: 0 };
79
+ enabled;
80
+ spool;
81
+ ackFile;
82
+ nextSeq = 0;
83
+ ack = { next: 0, offset: 0, gen: 0 };
84
+ /** The spool's size after our own last change; anything else means another process wrote. */
85
+ knownSize = -1;
86
+ shipping = null;
87
+ retryAt = 0;
88
+ backoff = 500;
89
+ timers = [];
90
+ closed = false;
91
+ landings = 0;
92
+ /** A session that records nothing. `reason`, when opening failed, goes to `stats.lastError` (audit K1). */
93
+ static disabled(options, reason) {
94
+ const s = new BlackboxSession(options, "", "", false);
95
+ if (reason !== undefined) {
96
+ s.stats.errors++;
97
+ const kind = reason.startsWith("opening a session: the gateway answered")
98
+ ? "rejected"
99
+ : "network";
100
+ s.stats.lastError = { kind, message: reason, at: new Date().toISOString() };
101
+ }
102
+ return s;
103
+ }
104
+ options;
105
+ id;
106
+ token;
107
+ constructor(options, id, token, enabled = true,
108
+ /** Audit K5: this SDK opened the session, so only it knows the token: keep it by the spool. */
109
+ keepToken = false) {
110
+ this.options = options;
111
+ this.id = id;
112
+ this.token = token;
113
+ this.enabled = enabled;
114
+ const dir = options.spoolDir ?? process.env.BLACKBOX_SPOOL_DIR ?? join(tmpdir(), "zanii-blackbox-spool");
115
+ this.spool = join(dir, `${id}.jsonl`);
116
+ this.ackFile = join(dir, `${id}.ack`);
117
+ if (!enabled)
118
+ return;
119
+ this.guard(() => {
120
+ mkdirSync(dir, { recursive: true });
121
+ if (keepToken)
122
+ writeFileSync(join(dir, `${id}.token`), token, { mode: 0o600 });
123
+ // Resume: number after the last line, ship from the last acknowledged offset.
124
+ this.withLock(() => {
125
+ if (existsSync(this.spool))
126
+ repairTornLine(this.spool);
127
+ this.knownSize = -1; // force a refresh from the files
128
+ });
129
+ this.stats.recorded = this.nextSeq;
130
+ this.stats.acked = this.ack.next;
131
+ });
132
+ const flush = setInterval(() => void this.tick(), options.flushMs ?? 500);
133
+ flush.unref();
134
+ this.timers.push(flush);
135
+ const beat = options.heartbeatMs ?? 10_000;
136
+ if (beat > 0) {
137
+ const t = setInterval(() => void this.heartbeat(), beat);
138
+ t.unref();
139
+ this.timers.push(t);
140
+ }
141
+ }
142
+ // ------------------------------------------------------------ recording (never throws, never blocks on the network)
143
+ event(type, name, data, options = {}) {
144
+ if (!this.enabled || this.closed)
145
+ return;
146
+ this.guard(() => {
147
+ const eventId = options.eventId;
148
+ if (!TYPE.test(type) ||
149
+ (name !== undefined && name.length > 256) ||
150
+ (eventId !== undefined && !EVENT_ID.test(eventId))) {
151
+ this.stats.dropped++;
152
+ this.fail("invalid", `event dropped (invalid type or name): ${type}`);
153
+ return;
154
+ }
155
+ const json = data === undefined ? "" : JSON.stringify(data);
156
+ const bytes = Buffer.byteLength(json);
157
+ // Audit S20: over 64 KB, kept cut short rather than lost. The gateway counts UTF-8 bytes too.
158
+ const kept = bytes > MAX_DATA
159
+ ? { truncated: bytes, preview: Array.from(json).slice(0, PREVIEW).join("") }
160
+ : data;
161
+ if (kept !== data)
162
+ this.log(`blackbox: event data over 64 KB (${bytes} bytes) kept truncated`);
163
+ try {
164
+ // Under the spool lock: another process of the same session may have written (L2.2.2).
165
+ this.withLock(() => {
166
+ const line = JSON.stringify({
167
+ sdk_seq: this.nextSeq,
168
+ type,
169
+ ...(name === undefined ? {} : { name }),
170
+ ...(eventId === undefined ? {} : { event_id: eventId }),
171
+ ts: new Date().toISOString(),
172
+ ...(kept === undefined ? {} : { data: kept }),
173
+ });
174
+ appendFileSync(this.spool, `${line}\n`);
175
+ this.nextSeq++;
176
+ this.knownSize = statSync(this.spool).size;
177
+ });
178
+ }
179
+ catch (error) {
180
+ this.stats.dropped++;
181
+ throw error;
182
+ }
183
+ this.stats.recorded++;
184
+ });
185
+ }
186
+ step(name, data) {
187
+ this.event("step", name, data);
188
+ }
189
+ toolCall(name, args) {
190
+ this.event("tool.call", name, args === undefined ? undefined : { args });
191
+ }
192
+ toolResult(name, result) {
193
+ this.event("tool.result", name, result);
194
+ }
195
+ /** spec/data.md §7: a subject's consent for a purpose. The subject is fingerprinted at capture,
196
+ * so it must be an identifier the deployment knows (an e-mail, an Emirates ID, a pack's own). */
197
+ consent(subject, purpose, granted) {
198
+ this.event("consent", granted ? "granted" : "withdrawn", { subject, purpose });
199
+ }
200
+ /** spec/data.md §6: tries the internet directly, outside the gateway, and records the answer.
201
+ * `open: true` means the gateway isn't the only way out (EGRESS_OPEN). */
202
+ async egressCheck(options = {}) {
203
+ const result = await checkEgress(options);
204
+ this.event("egress_check", result.open ? "open" : "closed", { url: result.url });
205
+ return result;
206
+ }
207
+ llmCall(ids) {
208
+ this.event("llm.call", ids.provider, { ...ids });
209
+ }
210
+ note(text) {
211
+ this.event("note", undefined, { text });
212
+ }
213
+ /** Files the flight plan (spec/findings.md §5), unless one was filed when the session opened. */
214
+ flightPlan(plan) {
215
+ this.event("flight_plan", undefined, plan);
216
+ }
217
+ /** A completion claim; the server judges it against the recorded tool calls. */
218
+ claim(summary, asserts) {
219
+ this.event("claim", undefined, { summary, ...(asserts ? { asserts } : {}) });
220
+ }
221
+ /** N2 (idea C10): claims it's done and reads the gateway's verdict, so a failed check goes back
222
+ * to the model as feedback, a bounded number of times. Never throws. */
223
+ async land(summary, asserts, options = {}) {
224
+ this.landings++;
225
+ const attemptsLeft = Math.max(0, (options.maxAttempts ?? 3) - this.landings);
226
+ const error = (why) => {
227
+ this.fail("network", `landing: ${why}`);
228
+ return { landed: false, verdict: "error", feedback: [why], attemptsLeft };
229
+ };
230
+ if (!this.enabled)
231
+ return { landed: false, verdict: "error", feedback: [], attemptsLeft };
232
+ try {
233
+ this.claim(summary, asserts);
234
+ if (!(await this.flush({ timeoutMs: 10_000 })))
235
+ return error("the claim wasn't shipped");
236
+ const r = await send("GET", this.options.url, `/v1/sessions/${this.id}/landing`, this.token);
237
+ const claims = (r.json.claims ?? []);
238
+ const last = claims.at(-1);
239
+ if (r.status !== 200 || !last)
240
+ return error(`the gateway answered ${r.status}`);
241
+ const verdict = last.verdict;
242
+ const feedback = last.criteria
243
+ .filter((c) => c.result !== "pass")
244
+ .map((c) => `${c.criterion} ${JSON.stringify(c.predicate)}: ${c.result}${c.gap ? ` (${c.gap})` : ""}`);
245
+ if (verdict === "unverified" && feedback.length === 0)
246
+ feedback.push("nothing to check");
247
+ return { landed: verdict === "satisfied", verdict, feedback, attemptsLeft };
248
+ }
249
+ catch (e) {
250
+ return error(message(e));
251
+ }
252
+ }
253
+ /** N4 (idea R8): the context the model sees changed (summarised, trimmed, a tool result moved
254
+ * aside), so a replay knows what the model saw. */
255
+ contextChange(change) {
256
+ this.event("context.change", change.kind, { ...change });
257
+ }
258
+ /** Stage 2 R3: the result of one of the customer's own checks (tests, a schema, a policy, a
259
+ * partial goal), mid-run or at the end. A pass then a fail is VERIFY_REGRESSION; a success whose last
260
+ * check failed is FALSE_SUCCESS (spec/findings.md §6). */
261
+ verify(check, ok, detail) {
262
+ this.event("verify", check, { ok, ...(detail ? { detail: detail.slice(0, 500) } : {}) });
263
+ }
264
+ /** N4 (idea R10): one more attempt at a model or tool call. */
265
+ retry(kind, target, info) {
266
+ this.event("retry", target, {
267
+ kind,
268
+ attempt: info.attempt,
269
+ ...(info.error === undefined ? {} : { error: info.error.slice(0, 500) }),
270
+ ...(info.delayMs === undefined ? {} : { delay_ms: info.delayMs }),
271
+ });
272
+ }
273
+ /** N4 (idea R10): a call moved to another model or provider. */
274
+ fallback(from, to, reason) {
275
+ this.event("fallback", to, { from, to, ...(reason ? { reason: reason.slice(0, 500) } : {}) });
276
+ }
277
+ /** N4 (idea R5): how the run is configured: LangGraph's `durability` (sync, async, exit), retry,
278
+ * timeout and cache policies. */
279
+ runConfig(config) {
280
+ this.event("run.config", undefined, { ...config });
281
+ }
282
+ /** The agent reports something it almost got wrong (spec/fleet.md §3): a NEAR_MISS finding. */
283
+ nearMiss(description) {
284
+ this.event("near_miss", undefined, { description });
285
+ }
286
+ /** spec/attestation.md: re-runs a read-only command and records whether the output matches. */
287
+ attest(argv, claimed, cwd, claimedExit) {
288
+ if (!isReadOnly(argv))
289
+ return undefined;
290
+ const result = attest(argv, claimed, cwd, claimedExit);
291
+ this.event("attestation", argv[0], { ...result });
292
+ return result;
293
+ }
294
+ /** spec/agents.md §1: the agent's memory store wrote, revoked or returned memories. */
295
+ memoryWrite(memoryId, summary) {
296
+ this.event("memory.write", undefined, { memory_id: memoryId, ...(summary ? { summary } : {}) });
297
+ }
298
+ memoryRevoke(memoryId, reason) {
299
+ this.event("memory.revoke", undefined, { memory_id: memoryId, ...(reason ? { reason } : {}) });
300
+ }
301
+ memoryRead(memoryIds, query) {
302
+ this.event("memory.read", undefined, { memory_ids: memoryIds, ...(query ? { query } : {}) });
303
+ }
304
+ // ------------------------------------------------------------ shipping
305
+ /** Ships what's pending now. Resolves true when everything recorded is acknowledged. With
306
+ * `timeoutMs` (audit S24) it keeps trying until then, and never waits longer. */
307
+ async flush(options = {}) {
308
+ if (!this.enabled)
309
+ return false;
310
+ if (options.timeoutMs === undefined) {
311
+ this.retryAt = 0;
312
+ await this.tick();
313
+ return this.ack.next >= this.nextSeq;
314
+ }
315
+ const deadline = Date.now() + options.timeoutMs;
316
+ for (;;) {
317
+ this.retryAt = 0;
318
+ let timer;
319
+ const late = new Promise((r) => {
320
+ timer = setTimeout(r, Math.max(0, deadline - Date.now()));
321
+ });
322
+ await Promise.race([this.tick(), late]);
323
+ clearTimeout(timer);
324
+ if (this.ack.next >= this.nextSeq || Date.now() >= deadline)
325
+ break;
326
+ await sleep(Math.min(250, Math.max(0, deadline - Date.now())));
327
+ }
328
+ const done = this.ack.next >= this.nextSeq;
329
+ if (!done)
330
+ this.fail("network", `flush timed out after ${options.timeoutMs} ms`);
331
+ return done;
332
+ }
333
+ /** Audit S17: the gateway's view of this session (blocked? findings?), or null if it can't be
334
+ * read. Lets an agent react to its own block before its next model call. Never throws. */
335
+ async state() {
336
+ if (!this.enabled)
337
+ return null;
338
+ try {
339
+ const r = await send("GET", this.options.url, `/v1/sessions/${this.id}/state`, this.token);
340
+ if (r.status === 200)
341
+ return r.json;
342
+ this.fail("rejected", `reading the session state: the gateway answered ${r.status}`);
343
+ }
344
+ catch (error) {
345
+ this.fail("network", `reading the session state: ${message(error)}`);
346
+ }
347
+ return null;
348
+ }
349
+ /** spec/approvals.md §2: asks a second person before running one of the agent's own risky tools,
350
+ * and waits (polling) for the answer. Never throws. Only `"approved"` means go ahead; anything
351
+ * else (`rejected`, `timeout`, or `error` when the gateway can't be asked) means don't. */
352
+ async requestApproval(tool, options = {}) {
353
+ return this.ask("approvals", { tool, ...(options.args ? { args: options.args } : {}) }, options);
354
+ }
355
+ /** N2 (idea C2): asks a person to let this session use a gateway tool its policy denies (the
356
+ * denial's `requires.tool`, e.g. `mcp__files__delete`). A yes is a standing grant for the
357
+ * session: retry the call. Answers like `requestApproval`. Never throws. */
358
+ async requestPermission(tool, options = {}) {
359
+ return this.ask("permissions", { tool }, options);
360
+ }
361
+ async ask(route, body, options) {
362
+ if (!this.enabled)
363
+ return "error";
364
+ try {
365
+ const r = await post(this.options.url, `/v1/sessions/${this.id}/${route}`, this.token, {
366
+ ...body,
367
+ ...(options.reason ? { reason: options.reason } : {}),
368
+ });
369
+ const id = r.json.approval_id;
370
+ if (r.status !== 201 || typeof id !== "string") {
371
+ this.fail("rejected", `asking for an approval: the gateway answered ${r.status}`);
372
+ return "error";
373
+ }
374
+ const deadline = Date.now() + (options.timeoutMs ?? 310_000);
375
+ while (Date.now() < deadline) {
376
+ const s = await send("GET", this.options.url, `/v1/sessions/${this.id}/approvals/${id}`, this.token);
377
+ const status = s.json.status;
378
+ if (status === "approved" || status === "rejected" || status === "timeout")
379
+ return status;
380
+ await sleep(options.pollMs ?? 1_000);
381
+ }
382
+ return "timeout";
383
+ }
384
+ catch (error) {
385
+ this.fail("network", `asking for an approval: ${message(error)}`);
386
+ return "error";
387
+ }
388
+ }
389
+ /** Audit S8: `await using s = await session(…)` closes the session when the scope ends. */
390
+ async [Symbol.asyncDispose]() {
391
+ await this.close();
392
+ }
393
+ /** Drains the spool (up to closeTimeoutMs), then closes the gateway session. Never throws.
394
+ * `outcome` labels the run for the waste ledger (spec/cost.md §5); `note` says why (audit S7).
395
+ * Audit K9: true only when the gateway closed it (or it was closed already); otherwise
396
+ * `stats.lastError` says why. */
397
+ async close(outcome, note) {
398
+ if (!this.enabled || this.closed)
399
+ return false;
400
+ this.closed = true;
401
+ for (const t of this.timers)
402
+ clearInterval(t);
403
+ if (!(await this.flush({ timeoutMs: this.options.closeTimeoutMs ?? 5_000 })))
404
+ this.log(`blackbox: ${this.nextSeq - this.ack.next} event(s) left in the spool: ship them with \`blackbox drain\``);
405
+ try {
406
+ const r = await post(this.options.url, `/v1/sessions/${this.id}/close`, this.token, {
407
+ ...(outcome ? { outcome } : {}),
408
+ ...(note ? { note } : {}),
409
+ });
410
+ if (r.status === 200 || r.status === 409) {
411
+ if (this.ack.next >= this.nextSeq)
412
+ this.forgetToken();
413
+ return true;
414
+ }
415
+ this.stats.errors++;
416
+ this.fail("rejected", `closing the session: the gateway answered ${r.status}`);
417
+ }
418
+ catch (error) {
419
+ this.stats.errors++;
420
+ this.fail("network", `closing the session failed: ${message(error)}`);
421
+ }
422
+ return false;
423
+ }
424
+ /** Stops shipping and heartbeats without closing the gateway session (for `drain`). */
425
+ stop() {
426
+ this.closed = true;
427
+ for (const t of this.timers)
428
+ clearInterval(t);
429
+ }
430
+ /** The kept token (K5) is no longer needed once everything is shipped and the session closed. */
431
+ forgetToken() {
432
+ try {
433
+ rmSync(this.spool.replace(/\.jsonl$/, ".token"), { force: true });
434
+ }
435
+ catch { }
436
+ }
437
+ tick() {
438
+ if (this.shipping)
439
+ return this.shipping;
440
+ if (Date.now() < this.retryAt || this.ack.next >= this.nextSeq)
441
+ return Promise.resolve();
442
+ this.shipping = this.ship()
443
+ .catch((error) => {
444
+ this.stats.errors++;
445
+ this.backoff = Math.min(this.backoff * 2, 30_000);
446
+ this.retryAt = Date.now() + this.backoff * (0.5 + Math.random() / 2);
447
+ this.fail(error instanceof Rejected ? "rejected" : "network", `shipping failed, retrying: ${message(error)}`);
448
+ })
449
+ .finally(() => {
450
+ this.shipping = null;
451
+ });
452
+ return this.shipping;
453
+ }
454
+ async ship() {
455
+ const lines = [];
456
+ let from = { offset: 0, gen: 0 };
457
+ this.withLock(() => {
458
+ from = { offset: this.ack.offset, gen: this.ack.gen ?? 0 };
459
+ const fd = openSync(this.spool, "r+");
460
+ try {
461
+ fsyncSync(fd); // everything recorded so far reaches the disk before it's sent
462
+ const size = fstatSync(fd).size;
463
+ const buffer = Buffer.alloc(Math.max(0, Math.min(READ_CHUNK, size - from.offset)));
464
+ readSync(fd, buffer, 0, buffer.length, from.offset);
465
+ let start = 0;
466
+ for (let i = buffer.indexOf(0x0a); i >= 0 && lines.length < BATCH; i = buffer.indexOf(0x0a, start)) {
467
+ lines.push({ text: buffer.subarray(start, i).toString("utf8"), bytes: i + 1 - start });
468
+ start = i + 1;
469
+ }
470
+ }
471
+ finally {
472
+ closeSync(fd);
473
+ }
474
+ });
475
+ if (lines.length === 0)
476
+ return;
477
+ const events = lines.map((l) => JSON.parse(l.text));
478
+ const r = await post(this.options.url, `/v1/sessions/${this.id}/sdk-events`, this.token, {
479
+ events,
480
+ });
481
+ const next = typeof r.json.next === "number" ? r.json.next : null;
482
+ if (next === null)
483
+ throw new Rejected(r.status);
484
+ this.withLock(() => {
485
+ // Another process may have acknowledged or compacted meanwhile: then our offsets are stale.
486
+ if ((this.ack.gen ?? 0) === from.gen &&
487
+ this.ack.offset === from.offset &&
488
+ next > this.ack.next) {
489
+ // Advance past every sent line the server now has (sdk_seq < next).
490
+ let offset = from.offset;
491
+ for (const [i, e] of events.entries())
492
+ if (e.sdk_seq < next)
493
+ offset += lines[i]?.bytes ?? 0;
494
+ this.ack = { next, offset, gen: from.gen };
495
+ writeFileSync(this.ackFile, JSON.stringify(this.ack));
496
+ }
497
+ this.compact();
498
+ });
499
+ this.stats.acked = this.ack.next;
500
+ if (r.status !== 200)
501
+ throw new Rejected(r.status);
502
+ this.backoff = 500;
503
+ }
504
+ /** L2.2.3: once everything is acknowledged and enough is behind, empty the spool. The ack is
505
+ * written first: a crash in between only resends acknowledged lines, which the server ignores. */
506
+ compact() {
507
+ if (this.ack.next < this.nextSeq ||
508
+ this.ack.offset < (this.options.compactBytes ?? 1024 * 1024))
509
+ return;
510
+ this.ack = { next: this.ack.next, offset: 0, gen: (this.ack.gen ?? 0) + 1 };
511
+ writeFileSync(this.ackFile, JSON.stringify(this.ack));
512
+ truncateSync(this.spool, 0);
513
+ this.knownSize = 0;
514
+ }
515
+ /** Runs `fn` holding the session's spool lock (a lock file), after catching up with any other
516
+ * process's writes. A stale lock (a crashed holder) is broken after LOCK_STALE_MS. */
517
+ withLock(fn) {
518
+ const lock = `${this.spool}.lock`;
519
+ const deadline = Date.now() + LOCK_WAIT_MS;
520
+ for (;;) {
521
+ try {
522
+ closeSync(openSync(lock, "wx"));
523
+ break;
524
+ }
525
+ catch (error) {
526
+ // Windows reports a lock file that's being deleted as EPERM / EACCES / EBUSY: also "held".
527
+ const code = error.code ?? "";
528
+ if (!["EEXIST", "EPERM", "EACCES", "EBUSY"].includes(code))
529
+ throw error;
530
+ try {
531
+ if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS)
532
+ rmSync(lock, { force: true });
533
+ }
534
+ catch { }
535
+ if (Date.now() > deadline)
536
+ throw new Error("the spool is locked by another process");
537
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
538
+ }
539
+ }
540
+ try {
541
+ this.refresh();
542
+ fn();
543
+ }
544
+ finally {
545
+ rmSync(lock, { force: true });
546
+ }
547
+ }
548
+ /** Catches up with the files if another process changed them since our own last change. */
549
+ refresh() {
550
+ const size = existsSync(this.spool) ? statSync(this.spool).size : 0;
551
+ if (size === this.knownSize)
552
+ return;
553
+ if (existsSync(this.ackFile))
554
+ this.ack = JSON.parse(readFileSync(this.ackFile, "utf8"));
555
+ this.nextSeq = existsSync(this.spool) ? nextSeqOf(this.spool, this.ack.next) : this.ack.next;
556
+ this.knownSize = size;
557
+ }
558
+ async heartbeat() {
559
+ try {
560
+ const r = await post(this.options.url, `/v1/sessions/${this.id}/heartbeat`, this.token, {});
561
+ if (r.status >= 300) {
562
+ this.stats.errors++;
563
+ this.fail("rejected", `the heartbeat: the gateway answered ${r.status}`);
564
+ }
565
+ }
566
+ catch {
567
+ this.stats.errors++;
568
+ }
569
+ }
570
+ guard(fn) {
571
+ try {
572
+ fn();
573
+ }
574
+ catch (error) {
575
+ this.stats.errors++;
576
+ this.fail("disk", message(error));
577
+ }
578
+ }
579
+ /** Records the latest failure (audit S23) and logs it. */
580
+ fail(kind, text) {
581
+ this.stats.lastError = { kind, message: text, at: new Date().toISOString() };
582
+ this.log(`blackbox: ${text}`);
583
+ }
584
+ log(text) {
585
+ try {
586
+ this.options.logger?.(text);
587
+ }
588
+ catch { }
589
+ }
590
+ }
591
+ // ---------------------------------------------------------------- helpers
592
+ /** Cuts off a torn last line (a crash mid-write; never shipped). */
593
+ function repairTornLine(file) {
594
+ const bytes = readFileSync(file);
595
+ const end = bytes.lastIndexOf(0x0a) + 1;
596
+ if (end < bytes.length)
597
+ truncateSync(file, end);
598
+ }
599
+ /** The next sdk_seq: after the spool's last complete line, or `fallback` (the acknowledged next)
600
+ * when the spool is empty, e.g. just compacted. Reads only the tail. */
601
+ function nextSeqOf(file, fallback) {
602
+ const fd = openSync(file, "r");
603
+ try {
604
+ const size = fstatSync(fd).size;
605
+ const n = Math.min(size, 128 * 1024);
606
+ const tail = Buffer.alloc(n);
607
+ readSync(fd, tail, 0, n, size - n);
608
+ const end = tail.lastIndexOf(0x0a);
609
+ if (end < 0)
610
+ return fallback;
611
+ const start = tail.lastIndexOf(0x0a, end - 1) + 1;
612
+ const seq = JSON.parse(tail.subarray(start, end).toString("utf8"))
613
+ .sdk_seq;
614
+ return Math.max(seq + 1, fallback);
615
+ }
616
+ finally {
617
+ closeSync(fd);
618
+ }
619
+ }
620
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
621
+ const message = (e) => (e instanceof Error ? e.message : String(e));
622
+ /** The gateway answered, but with an error (audit S23: `rejected`, not `network`). */
623
+ class Rejected extends Error {
624
+ constructor(status) {
625
+ super(`the gateway answered ${status}`);
626
+ }
627
+ }
628
+ function post(base, path, bearer, body, headers = {}) {
629
+ return send("POST", base, path, bearer, body, headers);
630
+ }
631
+ /** Audit K10: opening is retried on a network error, a 429 or a 503 (up to 3 tries, backing off,
632
+ * honouring Retry-After), with the same Idempotency-Key, so the gateway opens it at most once. */
633
+ async function openWithRetry(base, key, idempotencyKey, body) {
634
+ for (let attempt = 1;; attempt++) {
635
+ try {
636
+ const r = await post(base, "/v1/sessions", key, body, { "idempotency-key": idempotencyKey });
637
+ if ((r.status !== 429 && r.status !== 503) || attempt >= 3)
638
+ return r;
639
+ const after = Number(r.headers["retry-after"]);
640
+ await sleep(Math.min(5_000, after > 0 ? after * 1000 : 250 * 2 ** attempt));
641
+ }
642
+ catch (error) {
643
+ if (attempt >= 3)
644
+ throw error;
645
+ await sleep(250 * 2 ** attempt);
646
+ }
647
+ }
648
+ }
649
+ function send(method, base, path, bearer, body, headers = {}) {
650
+ return new Promise((resolve, reject) => {
651
+ const url = new URL(`${base.replace(/\/$/, "")}${path}`);
652
+ const data = body === undefined ? "" : JSON.stringify(body);
653
+ const req = (url.protocol === "https:" ? httpsRequest : httpRequest)(url, {
654
+ method,
655
+ timeout: 10_000,
656
+ headers: {
657
+ authorization: `Bearer ${bearer}`,
658
+ ...headers,
659
+ ...(body === undefined
660
+ ? {}
661
+ : { "content-type": "application/json", "content-length": Buffer.byteLength(data) }),
662
+ },
663
+ });
664
+ req.on("response", (res) => {
665
+ const parts = [];
666
+ res.on("data", (d) => parts.push(d));
667
+ res.on("end", () => {
668
+ let json = {};
669
+ try {
670
+ json = JSON.parse(Buffer.concat(parts).toString() || "{}");
671
+ }
672
+ catch { }
673
+ resolve({ status: res.statusCode ?? 0, json, headers: res.headers });
674
+ });
675
+ res.on("error", reject);
676
+ });
677
+ req.on("timeout", () => req.destroy(new Error("timed out")));
678
+ req.on("error", reject);
679
+ req.end(data);
680
+ });
681
+ }