@agentguard-run/burn 0.1.0 → 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 (41) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +110 -1
  3. package/dist/src/adapters/codex.d.ts +48 -0
  4. package/dist/src/adapters/codex.js +194 -0
  5. package/dist/src/adapters/cursor.d.ts +35 -0
  6. package/dist/src/adapters/cursor.js +132 -0
  7. package/dist/src/adapters/raw-api.d.ts +76 -0
  8. package/dist/src/adapters/raw-api.js +130 -0
  9. package/dist/src/cli.d.ts +7 -3
  10. package/dist/src/cli.js +99 -10
  11. package/dist/src/conformance.d.ts +26 -0
  12. package/dist/src/conformance.js +261 -0
  13. package/dist/src/defaults.d.ts +11 -0
  14. package/dist/src/defaults.js +16 -1
  15. package/dist/src/detectors/local-compute.d.ts +19 -0
  16. package/dist/src/detectors/local-compute.js +66 -0
  17. package/dist/src/events.d.ts +94 -0
  18. package/dist/src/events.js +47 -0
  19. package/dist/src/gateway.d.ts +134 -0
  20. package/dist/src/gateway.js +522 -0
  21. package/dist/src/hook/pre-tool-use.js +5 -4
  22. package/dist/src/index.d.ts +15 -4
  23. package/dist/src/index.js +41 -1
  24. package/dist/src/proxy/server.d.ts +45 -0
  25. package/dist/src/proxy/server.js +169 -0
  26. package/dist/src/proxy/usage-observer.d.ts +40 -0
  27. package/dist/src/proxy/usage-observer.js +128 -0
  28. package/dist/src/receipt.d.ts +61 -0
  29. package/dist/src/receipt.js +98 -0
  30. package/dist/src/replay/render.d.ts +10 -3
  31. package/dist/src/replay/render.js +175 -44
  32. package/dist/src/replay/simulate.d.ts +4 -0
  33. package/dist/src/replay/simulate.js +24 -1
  34. package/dist/src/state/reservations.d.ts +115 -11
  35. package/dist/src/state/reservations.js +293 -59
  36. package/dist/src/state/session.d.ts +6 -0
  37. package/dist/src/state/session.js +17 -0
  38. package/dist/src/status.d.ts +11 -0
  39. package/dist/src/status.js +48 -0
  40. package/dist/src/types.d.ts +14 -1
  41. package/package.json +34 -7
@@ -1,11 +1,14 @@
1
1
  "use strict";
2
2
  /**
3
- * Terminal rendering for replay. This output is the marketing artifact, so it
4
- * has to survive a screenshot: pure ASCII box drawing, ANSI colour that
5
- * degrades cleanly, nothing that depends on a font.
3
+ * Terminal rendering. This output is the distribution artifact, so it is
4
+ * designed to be screenshotted: one giant number, a curve you can see the
5
+ * runaway in, one line worth quoting. Pure ASCII/ANSI, degrades cleanly.
6
6
  */
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.sparkline = sparkline;
9
+ exports.comparison = comparison;
8
10
  exports.renderReplay = renderReplay;
11
+ exports.renderStop = renderStop;
9
12
  exports.renderSessionRow = renderSessionRow;
10
13
  const evaluate_1 = require("../detectors/evaluate");
11
14
  const C = {
@@ -16,74 +19,202 @@ const C = {
16
19
  yellow: '\x1b[33m',
17
20
  green: '\x1b[32m',
18
21
  cyan: '\x1b[36m',
22
+ magenta: '\x1b[35m',
23
+ bgRed: '\x1b[41m',
24
+ white: '\x1b[97m',
19
25
  };
20
- function colour(enabled, code, text) {
21
- return enabled ? `${code}${text}${C.reset}` : text;
26
+ const W = 72;
27
+ function paint(on, code, text) {
28
+ return on ? `${code}${text}${C.reset}` : text;
22
29
  }
23
- const W = 70;
24
- function line(text = '') {
25
- const clean = text.replace(/\x1b\[[0-9;]*m/g, '');
26
- const pad = Math.max(0, W - 2 - clean.length);
30
+ function strip(text) {
31
+ return text.replace(/\x1b\[[0-9;]*m/g, '');
32
+ }
33
+ function row(text = '') {
34
+ const pad = Math.max(0, W - 2 - strip(text).length);
27
35
  return `│ ${text}${' '.repeat(pad)} │`;
28
36
  }
29
37
  const TOP = `╭${'─'.repeat(W)}╮`;
30
38
  const MID = `├${'─'.repeat(W)}┤`;
31
39
  const BOT = `╰${'─'.repeat(W)}╯`;
32
- /** API-list equivalent. Labelled as a scenario, never as the user's bill. */
40
+ // ---------------------------------------------------------------------------
41
+ // Block digits. Five rows, three columns each, for 0-9 . % B K M and space.
42
+ // ---------------------------------------------------------------------------
43
+ const GLYPHS = {
44
+ '0': ['█▀▀█', '█ █', '█ █', '█ █', '▀▀▀▀'],
45
+ '1': [' ▀█', ' █', ' █', ' █', ' ▀'],
46
+ '2': ['▀▀▀█', ' █', '█▀▀▀', '█ ', '▀▀▀▀'],
47
+ '3': ['▀▀▀█', ' █', ' ▀▀█', ' █', '▀▀▀▀'],
48
+ '4': ['█ █', '█ █', '▀▀▀█', ' █', ' ▀'],
49
+ '5': ['█▀▀▀', '█ ', '▀▀▀█', ' █', '▀▀▀▀'],
50
+ '6': ['█▀▀▀', '█ ', '█▀▀█', '█ █', '▀▀▀▀'],
51
+ '7': ['▀▀▀█', ' █', ' █', ' █', ' ▀'],
52
+ '8': ['█▀▀█', '█ █', '█▀▀█', '█ █', '▀▀▀▀'],
53
+ '9': ['█▀▀█', '█ █', '▀▀▀█', ' █', '▀▀▀▀'],
54
+ '.': [' ', ' ', ' ', ' ', ' ▀ '],
55
+ '%': ['▀▀ █', ' █ ', ' █ ', ' █ ', '█ ▀▀'],
56
+ B: ['█▀▀▄', '█ █', '█▀▀▄', '█ █', '▀▀▀ '],
57
+ M: ['█▄ ▄█', '█ ▀ █', '█ █', '█ █', '▀ ▀'],
58
+ K: ['█ █', '█ █ ', '██ ', '█ █ ', '▀ ▀'],
59
+ ' ': [' ', ' ', ' ', ' ', ' '],
60
+ };
61
+ function blockDigits(text) {
62
+ const rows = ['', '', '', '', ''];
63
+ for (const ch of text) {
64
+ const g = GLYPHS[ch] ?? GLYPHS[' '];
65
+ for (let r = 0; r < 5; r++)
66
+ rows[r] += g[r] + ' ';
67
+ }
68
+ return rows.map((r) => r.trimEnd());
69
+ }
70
+ // ---------------------------------------------------------------------------
71
+ // Sparkline with the STOP point marked.
72
+ // ---------------------------------------------------------------------------
73
+ const BARS = '▁▂▃▄▅▆▇█';
74
+ function sparkline(curve, stopAt, on) {
75
+ if (curve.length === 0)
76
+ return '';
77
+ let out = '';
78
+ for (let i = 0; i < curve.length; i++) {
79
+ const level = Math.min(BARS.length - 1, Math.max(0, Math.round(curve[i] * (BARS.length - 1))));
80
+ const bar = BARS[level];
81
+ if (stopAt !== null && i >= stopAt) {
82
+ // Everything after the stop is the tail enforcement would have cut.
83
+ out += paint(on, C.red, i === stopAt ? '┃' : bar);
84
+ }
85
+ else {
86
+ out += paint(on, C.green, bar);
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+ // ---------------------------------------------------------------------------
92
+ // The quotable comparison. Tokens to something a human can picture.
93
+ // ---------------------------------------------------------------------------
94
+ const COMPARISONS = [
95
+ { tokens: 7_000_000_000, label: 'all of English Wikipedia' },
96
+ { tokens: 1_200_000_000, label: 'the entire Harry Potter series, 1,000 times' },
97
+ { tokens: 200_000_000, label: 'the complete works of Shakespeare, 200 times' },
98
+ { tokens: 40_000_000, label: 'the King James Bible, 50 times' },
99
+ { tokens: 130_000, label: 'Slaughterhouse-Five' },
100
+ ];
101
+ function comparison(tokens) {
102
+ for (const c of COMPARISONS) {
103
+ const times = tokens / c.tokens;
104
+ if (times >= 1) {
105
+ const n = times >= 10 ? Math.round(times) : Math.round(times * 10) / 10;
106
+ return `≈ ${c.label}${n > 1 ? `, ${n}×` : ''}`;
107
+ }
108
+ }
109
+ return '';
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // Replay
113
+ // ---------------------------------------------------------------------------
33
114
  function scenarioUsd(tokens, cacheShare) {
34
115
  const cached = tokens * cacheShare;
35
116
  return (cached * 0.3 + (tokens - cached) * 5) / 1e6;
36
117
  }
37
118
  function renderReplay(summary, opts = {}) {
38
119
  const on = opts.colour ?? Boolean(process.stdout.isTTY);
39
- const top = opts.top ?? 8;
120
+ const top = opts.top ?? 6;
40
121
  const out = [];
41
- const cacheShare = summary.sessions.length
42
- ? summary.sessions.reduce((s, r) => s + r.cacheReadRatio * r.totalTokens, 0) / Math.max(summary.totalTokens, 1)
122
+ const cacheShare = summary.totalTokens
123
+ ? summary.sessions.reduce((s, r) => s + r.cacheReadRatio * r.totalTokens, 0) / summary.totalTokens
43
124
  : 0;
125
+ const share = Math.round(summary.catchableShare * 100);
44
126
  out.push(TOP);
45
- out.push(line(colour(on, C.bold, 'AGENTGUARD REPLAY')));
46
- out.push(line(colour(on, C.dim, 'Your agent history, before enforcement was installed')));
127
+ out.push(row(paint(on, C.bold + C.magenta, 'AGENTGUARD') + ' ' + paint(on, C.dim, 'replay · your agent history, before enforcement')));
47
128
  out.push(MID);
48
- out.push(line(`${summary.sessions.length} sessions ${summary.totalSpawns} spawns ${(0, evaluate_1.fmt)(summary.totalTokens)} tokens observed`));
49
- out.push(line(colour(on, C.dim, `${Math.round(cacheShare * 100)}% cache-read. Shown as explanation; it decided nothing.`)));
129
+ // Hero: the one number. Rendered huge.
130
+ out.push(row(''));
131
+ for (const line of blockDigits(`${share}%`))
132
+ out.push(row(' ' + paint(on, C.bold + C.green, line)));
133
+ out.push(row(''));
134
+ out.push(row(' ' + paint(on, C.bold, 'of everything you burned came after a point it would have blocked')));
135
+ out.push(row(''));
136
+ // Three supporting numbers, card-style.
137
+ const cards = [
138
+ { n: (0, evaluate_1.fmt)(summary.totalTokens), l: 'observed', c: C.white },
139
+ { n: (0, evaluate_1.fmt)(summary.catchableTail), l: 'after STOP', c: C.red },
140
+ { n: `$${Math.round(scenarioUsd(summary.catchableTail, cacheShare)).toLocaleString()}`, l: 'API-list, scenario', c: C.yellow },
141
+ ];
142
+ const COL = 20;
143
+ out.push(row(' ' + cards.map((k) => paint(on, C.bold + k.c, k.n.padEnd(COL))).join('')));
144
+ out.push(row(' ' + cards.map((k) => paint(on, C.dim, k.l.padEnd(COL))).join('')));
145
+ const comp = comparison(summary.catchableTail);
146
+ if (comp)
147
+ out.push(row(' ' + paint(on, C.dim, comp)));
50
148
  out.push(MID);
51
- out.push(line(colour(on, C.bold, 'WHAT ENFORCEMENT WOULD HAVE INTERCEPTED')));
52
- out.push(line(''));
53
- const share = Math.round(summary.catchableShare * 100);
54
- out.push(line(`${colour(on, C.bold, (0, evaluate_1.fmt)(summary.catchableTail))} tokens observed after the first STOP boundary`));
55
- out.push(line(`${share}% of everything you burned ≈ $${Math.round(scenarioUsd(summary.catchableTail, cacheShare)).toLocaleString()} at API list, as a scenario`));
56
- out.push(line(''));
57
- out.push(line(colour(on, C.dim, 'Upper bound. It assumes you would not have overridden or restarted.')));
58
- out.push(MID);
59
- out.push(line(`${colour(on, C.red, `${summary.stops} STOP`)} ${colour(on, C.yellow, `${summary.warns} WARN`)} ${colour(on, C.green, `${summary.clean} clean`)}`));
149
+ out.push(row(`${paint(on, C.bold + C.red, `${summary.stops} STOP`)} ${paint(on, C.bold + C.yellow, `${summary.warns} WARN`)} ${paint(on, C.bold + C.green, `${summary.clean} clean`)}` +
150
+ paint(on, C.dim, ` across ${summary.sessions.length} sessions, ${summary.totalSpawns} spawns`)));
60
151
  out.push(MID);
61
152
  for (const s of summary.sessions.slice(0, top)) {
62
- out.push(line(colour(on, C.bold, `session ${s.sessionId.slice(0, 8)}`) + ` ${(0, evaluate_1.fmt)(s.totalTokens)} tokens · ${s.spawns} spawns · depth ${s.maxDepth}`));
63
- if (s.fanoutStop) {
64
- out.push(line(` ${colour(on, C.red, 'FAN-OUT STOP')} before spawn ${s.fanoutStop.atSpawn} at ${(0, evaluate_1.fmt)(s.fanoutStop.tokensAtStop)}`));
65
- }
66
- if (s.sustainedStop) {
67
- out.push(line(` ${colour(on, C.red, 'SUSTAINED STOP')} near ${(0, evaluate_1.fmt)(s.sustainedStop.tokensAtStop)}`));
68
- }
69
- if (!s.fanoutStop && !s.sustainedStop) {
70
- if (s.firstWarn)
71
- out.push(line(` ${colour(on, C.yellow, 'WARN')} (${s.firstWarn.detector}) at ${(0, evaluate_1.fmt)(s.firstWarn.tokensAt)} · no stop`));
72
- else
73
- out.push(line(` ${colour(on, C.green, 'clean')}`));
74
- }
75
- if (s.catchableTail > 0) {
76
- out.push(line(` observed tail after stop: ${colour(on, C.bold, (0, evaluate_1.fmt)(s.catchableTail))}`));
77
- }
153
+ const tag = s.fanoutStop ? paint(on, C.red, 'FAN-OUT STOP') : s.sustainedStop ? paint(on, C.red, 'SUSTAINED STOP') : s.firstWarn ? paint(on, C.yellow, 'WARN') : paint(on, C.green, 'clean');
154
+ out.push(row(`${sparkline(s.curve, s.stopAtIndex, on)} ${tag}`));
155
+ const detail = s.fanoutStop
156
+ ? `before spawn ${s.fanoutStop.atSpawn}, tail ${(0, evaluate_1.fmt)(s.catchableTail)}`
157
+ : s.sustainedStop
158
+ ? `near ${(0, evaluate_1.fmt)(s.sustainedStop.tokensAtStop)}, tail ${(0, evaluate_1.fmt)(s.catchableTail)}`
159
+ : `${(0, evaluate_1.fmt)(s.totalTokens)} · ${s.spawns} spawns`;
160
+ out.push(row(paint(on, C.dim, `${s.sessionId.slice(0, 8)} ${(0, evaluate_1.fmt)(s.totalTokens).padStart(6)} · ${String(s.spawns).padStart(3)} spawns ${detail}`)));
78
161
  }
79
162
  if (summary.sessions.length > top) {
80
- out.push(line(colour(on, C.dim, `… ${summary.sessions.length - top} more sessions below the warning line`)));
163
+ out.push(row(paint(on, C.dim, `… ${summary.sessions.length - top} more, all clean`)));
81
164
  }
82
165
  out.push(MID);
83
- out.push(line(colour(on, C.cyan, 'Next: agentguard-burn init (shadow mode; blocks nothing yet)')));
166
+ out.push(row(paint(on, C.dim, 'Upper bound; assumes no override or restart. Nothing left this machine.')));
167
+ out.push(row(paint(on, C.cyan, 'next agentguard-burn init') + paint(on, C.dim, ' shadow mode; blocks nothing until you say so')));
84
168
  out.push(BOT);
85
169
  return out.join('\n');
86
170
  }
171
+ // ---------------------------------------------------------------------------
172
+ // The mid-session STOP box. What Claude shows the user when a spawn is denied.
173
+ // ---------------------------------------------------------------------------
174
+ function renderStop(report, opts = {}) {
175
+ const on = opts.colour ?? false;
176
+ const lead = report.findings.find((f) => f.verdict === 'STOP')?.summary ?? 'Fan-out ceiling reached.';
177
+ const w = 64;
178
+ const bar = '━'.repeat(w);
179
+ const title = opts.subject === 'call' ? ' model call blocked' : ' agent spawn blocked';
180
+ const lines = [];
181
+ lines.push(paint(on, C.red, `┏${bar}┓`));
182
+ lines.push(paint(on, C.red, '┃ ') + paint(on, C.bold + C.bgRed + C.white, ' AGENTGUARD STOP ') + paint(on, C.bold, title) + paint(on, C.red, ' '.repeat(w - 19 - title.length) + '┃'));
183
+ lines.push(paint(on, C.red, `┣${bar}┫`));
184
+ lines.push(paint(on, C.red, '┃ ') + lead.padEnd(w - 1).slice(0, w - 1) + paint(on, C.red, '┃'));
185
+ lines.push(paint(on, C.red, '┃ ') + paint(on, C.dim, `${(0, evaluate_1.fmt)(report.totals.tokens)} tokens · ${report.totals.spawns} spawns · depth ${report.totals.maxDepth}`.padEnd(w - 1)) + paint(on, C.red, '┃'));
186
+ lines.push(paint(on, C.red, `┣${bar}┫`));
187
+ lines.push(paint(on, C.red, '┃ ') + paint(on, C.bold, 'DO NOW'.padEnd(w - 1)) + paint(on, C.red, '┃'));
188
+ report.prescriptions.slice(0, 3).forEach((p, i) => {
189
+ const wrapped = wrap(`${i + 1}. ${p}`, w - 2, ' ');
190
+ for (const line of wrapped) {
191
+ lines.push(paint(on, C.red, '┃ ') + line.padEnd(w - 1) + paint(on, C.red, '┃'));
192
+ }
193
+ });
194
+ lines.push(paint(on, C.red, `┣${bar}┫`));
195
+ lines.push(paint(on, C.red, '┃ ') + paint(on, C.dim, 'override once: agentguard-burn resume --once --reason "..."'.padEnd(w - 1)) + paint(on, C.red, '┃'));
196
+ lines.push(paint(on, C.red, `┗${bar}┛`));
197
+ return lines.join('\n');
198
+ }
199
+ /** Word-aware wrap. Continuation lines are indented. */
200
+ function wrap(text, width, indent) {
201
+ const words = text.split(' ');
202
+ const lines = [];
203
+ let current = '';
204
+ for (const word of words) {
205
+ const prefix = lines.length ? indent : '';
206
+ if ((current + ' ' + word).trim().length + prefix.length > width && current) {
207
+ lines.push((lines.length ? indent : '') + current);
208
+ current = word;
209
+ }
210
+ else {
211
+ current = current ? `${current} ${word}` : word;
212
+ }
213
+ }
214
+ if (current)
215
+ lines.push((lines.length ? indent : '') + current);
216
+ return lines;
217
+ }
87
218
  function renderSessionRow(s) {
88
219
  return `${s.sessionId.slice(0, 8)} ${(0, evaluate_1.fmt)(s.totalTokens).padStart(8)} ${String(s.spawns).padStart(4)} spawns ${s.finalVerdict}`;
89
220
  }
@@ -37,6 +37,10 @@ export interface SessionReplay {
37
37
  tokensAt: number;
38
38
  spawnsAt: number;
39
39
  } | null;
40
+ /** Cumulative tokens sampled over active time, for a sparkline. 0..1 normalised. */
41
+ curve: number[];
42
+ /** Index into curve where the earliest STOP fired, or null. */
43
+ stopAtIndex: number | null;
40
44
  }
41
45
  export declare function discoverTranscripts(root?: string): string[];
42
46
  export declare function replaySession(path: string, thresholds: Thresholds): SessionReplay | null;
@@ -60,6 +60,9 @@ function replayEvents(sessionId, path, events, thresholds) {
60
60
  let sustainedStop = null;
61
61
  let firstWarn = null;
62
62
  let tokensAtEarliestStop = null;
63
+ // Cumulative tokens after every event, for the sparkline.
64
+ const timeline = [];
65
+ let stopEventIndex = null;
63
66
  for (const event of events) {
64
67
  // A hook boundary exists only where a spawn was attempted. Evaluate the
65
68
  // *proposal* before applying the event, then apply it.
@@ -80,6 +83,7 @@ function replayEvents(sessionId, path, events, thresholds) {
80
83
  }
81
84
  if ((fanoutStop || sustainedStop) && tokensAtEarliestStop === null) {
82
85
  tokensAtEarliestStop = state.totalTokens;
86
+ stopEventIndex = timeline.length;
83
87
  }
84
88
  }
85
89
  // The sustained plane also has a boundary at every tool call, not just
@@ -92,12 +96,16 @@ function replayEvents(sessionId, path, events, thresholds) {
92
96
  const hit = report.findings.find((f) => f.verdict === 'STOP' && (f.detector === 'sustained_burn' || f.detector === 'burn_debt'));
93
97
  if (hit) {
94
98
  sustainedStop = { atSpawn: state.spawnCount, tokensAtStop: state.totalTokens };
95
- if (tokensAtEarliestStop === null)
99
+ if (tokensAtEarliestStop === null) {
96
100
  tokensAtEarliestStop = state.totalTokens;
101
+ stopEventIndex = timeline.length;
102
+ }
97
103
  }
98
104
  }
99
105
  (0, session_1.applyEvent)(state, event);
106
+ timeline.push(state.totalTokens);
100
107
  }
108
+ const { curve, stopAtIndex } = downsample(timeline, stopEventIndex, 32);
101
109
  const final = (0, evaluate_1.evaluate)(state, thresholds, null);
102
110
  return {
103
111
  sessionId,
@@ -112,8 +120,23 @@ function replayEvents(sessionId, path, events, thresholds) {
112
120
  sustainedStop,
113
121
  catchableTail: tokensAtEarliestStop === null ? 0 : Math.max(0, state.totalTokens - tokensAtEarliestStop),
114
122
  firstWarn,
123
+ curve,
124
+ stopAtIndex,
115
125
  };
116
126
  }
127
+ /** Compress a cumulative timeline into N normalised buckets, carrying the stop index across. */
128
+ function downsample(timeline, stopIndex, buckets) {
129
+ if (timeline.length === 0)
130
+ return { curve: [], stopAtIndex: null };
131
+ const max = timeline[timeline.length - 1] || 1;
132
+ const curve = [];
133
+ for (let b = 0; b < buckets; b++) {
134
+ const i = Math.min(timeline.length - 1, Math.floor(((b + 1) / buckets) * timeline.length) - 1);
135
+ curve.push(Math.max(0, timeline[Math.max(0, i)]) / max);
136
+ }
137
+ const stopAtIndex = stopIndex === null ? null : Math.min(buckets - 1, Math.floor((stopIndex / timeline.length) * buckets));
138
+ return { curve, stopAtIndex };
139
+ }
117
140
  function replayAll(paths, thresholds, minTokens = 0) {
118
141
  const sessions = [];
119
142
  for (const path of paths) {
@@ -27,18 +27,88 @@ export interface Reservation {
27
27
  at: number;
28
28
  expiresAt: number;
29
29
  }
30
+ /**
31
+ * A model call in flight or recently finished. Lives in the same file, under
32
+ * the same lock, as spawn reservations: the cross-tool claim collapses the
33
+ * moment there are two lock domains. Finished calls are kept only as long as
34
+ * the local-compute window needs them for occupied-time accounting.
35
+ */
36
+ export interface CallReservation {
37
+ sessionId: string;
38
+ callId: string;
39
+ host: string;
40
+ estimatedTokens: number;
41
+ startedAt: number;
42
+ finishedAt: number | null;
43
+ expiresAt: number;
44
+ }
45
+ interface ReservationFile {
46
+ version: 1 | 2;
47
+ reservations: Reservation[];
48
+ calls?: CallReservation[];
49
+ }
50
+ export interface ComputeSnapshot {
51
+ /** Calls started and not yet finished, across every host on this machine. */
52
+ inFlight: number;
53
+ inFlightForSession: number;
54
+ /** Sum of request durations overlapping the window. Not GPU utilisation. */
55
+ occupiedMs: number;
56
+ /** Estimates reserved by calls still in flight. */
57
+ pendingEstimatedTokens: number;
58
+ windowMs: number;
59
+ }
30
60
  export declare const RESERVATION_TTL_MS = 90000;
61
+ /**
62
+ * Lock instances are identified by nonce, not by path. This is what makes
63
+ * the lock survive contention from hundreds of processes:
64
+ *
65
+ * A waiter that reads the owner record, then gets descheduled, then judges
66
+ * "owner is dead" is telling the truth about an instance that has since
67
+ * been released and replaced. Under 240 concurrent hook processes that
68
+ * exact stall happened, the waiter tore down a live sibling's lock, two
69
+ * processes ran the transaction at once, and 43 spawns were admitted
70
+ * against a cap of 40. Every teardown below is therefore checked against
71
+ * the nonce it was judged on, and every write is fenced on the holder's own
72
+ * nonce still being on the path.
73
+ */
31
74
  export declare class ReservationStore {
32
75
  private readonly home;
33
76
  private readonly lockDir;
34
77
  private readonly file;
78
+ private held;
35
79
  constructor(home: string);
36
80
  /** Acquire the lock or throw. Callers must fail closed on throw. */
37
81
  private acquire;
82
+ private readOwnerAt;
83
+ /** The holder's own instance is still the one on the path. */
84
+ private fence;
85
+ /**
86
+ * Take the lock directory off its path atomically, then verify it is the
87
+ * instance we meant. rmSync on the live path is readdir + unlink + rmdir,
88
+ * and a sibling can mkdir the same path between those steps, so removal
89
+ * is always rename-then-delete. If the instance we grabbed is not the one
90
+ * we judged (`expect`), it is a live sibling's: put it back.
91
+ */
92
+ private discard;
93
+ private trace;
38
94
  private recoverIfStale;
39
95
  private release;
40
96
  private load;
41
97
  private save;
98
+ /**
99
+ * Run `fn` with the machine-wide lock held. Everything inside sees one
100
+ * consistent reservation file and writes it back once. The gateway uses
101
+ * this to make "fold state, evaluate, reserve, sign" a single transaction,
102
+ * so two hosts racing the same session cannot interleave halfway.
103
+ *
104
+ * Throws if the lock cannot be taken. Callers must fail closed on throw.
105
+ */
106
+ withLock<T>(fn: (tx: Transaction) => T): T;
107
+ /**
108
+ * Callers that write their own files inside a transaction (the gateway's
109
+ * session file) call this right before writing, for the same reason.
110
+ */
111
+ assertHeld(): void;
42
112
  /**
43
113
  * Try to reserve one spawn slot. `observedSpawns` is what the transcript
44
114
  * shows; the decision is made against observed + pending, under the lock.
@@ -46,19 +116,53 @@ export declare class ReservationStore {
46
116
  * Returns the effective count that was evaluated, so the caller can report
47
117
  * exactly why a spawn was denied.
48
118
  */
49
- reserve(args: {
50
- sessionId: string;
51
- toolUseId: string;
52
- observedSpawns: number;
53
- ceiling: number;
54
- now?: number;
55
- }): {
56
- allowed: boolean;
57
- effectiveSpawns: number;
58
- pending: number;
59
- };
119
+ reserve(args: ReserveArgs): ReserveResult;
60
120
  /** Drop reservations the transcript has now accounted for. */
61
121
  reconcile(sessionId: string, observedSpawns: number, previouslyObserved: number): void;
62
122
  pendingFor(sessionId: string, now?: number): number;
123
+ reserveCall(args: ReserveCallArgs): ComputeSnapshot;
124
+ finishCall(callId: string, windowMs: number, now?: number): number | null;
125
+ /** Lock-free read for status. May be a few milliseconds stale; never used to decide. */
126
+ computeSnapshot(sessionId: string, windowMs: number, now?: number): ComputeSnapshot;
63
127
  clear(): void;
64
128
  }
129
+ export interface ReserveArgs {
130
+ sessionId: string;
131
+ toolUseId: string;
132
+ observedSpawns: number;
133
+ ceiling: number;
134
+ now?: number;
135
+ }
136
+ export interface ReserveResult {
137
+ allowed: boolean;
138
+ effectiveSpawns: number;
139
+ pending: number;
140
+ }
141
+ export interface ReserveCallArgs {
142
+ sessionId: string;
143
+ callId: string;
144
+ host: string;
145
+ estimatedTokens: number;
146
+ ttlMs: number;
147
+ windowMs: number;
148
+ now?: number;
149
+ }
150
+ /** Operations on the loaded reservation file while the lock is held. */
151
+ export declare class Transaction {
152
+ private readonly data;
153
+ dirty: boolean;
154
+ constructor(data: ReservationFile);
155
+ reserve(args: ReserveArgs): ReserveResult;
156
+ reconcile(sessionId: string, observedSpawns: number, previouslyObserved: number): void;
157
+ /**
158
+ * Open a model-call reservation and return the compute snapshot it was
159
+ * admitted against. Idempotent on callId: middleware and a proxy that both
160
+ * see the same call converge on one record. The caller decides the verdict;
161
+ * the store only guarantees the count is atomic across processes.
162
+ */
163
+ reserveCall(args: ReserveCallArgs): ComputeSnapshot;
164
+ /** Mark a call finished. Returns its duration, or null if unknown to us. */
165
+ finishCall(callId: string, windowMs: number, now?: number): number | null;
166
+ computeSnapshot(sessionId: string, windowMs: number, now?: number): ComputeSnapshot;
167
+ }
168
+ export {};