@agentguard-run/burn 0.1.1 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +145 -6
  3. package/dist/src/adapters/codex.d.ts +48 -0
  4. package/dist/src/adapters/codex.js +197 -0
  5. package/dist/src/adapters/cursor.d.ts +35 -0
  6. package/dist/src/adapters/cursor.js +135 -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 +141 -17
  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 +141 -0
  20. package/dist/src/gateway.js +536 -0
  21. package/dist/src/hook/pre-tool-use.d.ts +25 -1
  22. package/dist/src/hook/pre-tool-use.js +64 -16
  23. package/dist/src/index.d.ts +19 -4
  24. package/dist/src/index.js +57 -1
  25. package/dist/src/install.d.ts +29 -0
  26. package/dist/src/install.js +145 -0
  27. package/dist/src/override.d.ts +32 -0
  28. package/dist/src/override.js +72 -0
  29. package/dist/src/proxy/server.d.ts +45 -0
  30. package/dist/src/proxy/server.js +169 -0
  31. package/dist/src/proxy/usage-observer.d.ts +40 -0
  32. package/dist/src/proxy/usage-observer.js +128 -0
  33. package/dist/src/receipt.d.ts +61 -0
  34. package/dist/src/receipt.js +98 -0
  35. package/dist/src/replay/render.d.ts +1 -0
  36. package/dist/src/replay/render.js +2 -1
  37. package/dist/src/state/reservations.d.ts +115 -11
  38. package/dist/src/state/reservations.js +293 -59
  39. package/dist/src/state/session.d.ts +6 -0
  40. package/dist/src/state/session.js +17 -0
  41. package/dist/src/status.d.ts +19 -0
  42. package/dist/src/status.js +112 -0
  43. package/dist/src/types.d.ts +14 -1
  44. package/fixtures/codex-0.151.0-pretooluse.json +49 -0
  45. package/package.json +34 -6
@@ -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 {};
@@ -23,7 +23,7 @@
23
23
  * account do not share it, and that is stated rather than papered over.
24
24
  */
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.ReservationStore = exports.RESERVATION_TTL_MS = void 0;
26
+ exports.Transaction = exports.ReservationStore = exports.RESERVATION_TTL_MS = void 0;
27
27
  const node_fs_1 = require("node:fs");
28
28
  const node_path_1 = require("node:path");
29
29
  const LOCK_STALE_MS = 10_000;
@@ -42,10 +42,24 @@ function pidAlive(pid) {
42
42
  function sleepSync(ms) {
43
43
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
44
44
  }
45
+ /**
46
+ * Lock instances are identified by nonce, not by path. This is what makes
47
+ * the lock survive contention from hundreds of processes:
48
+ *
49
+ * A waiter that reads the owner record, then gets descheduled, then judges
50
+ * "owner is dead" is telling the truth about an instance that has since
51
+ * been released and replaced. Under 240 concurrent hook processes that
52
+ * exact stall happened, the waiter tore down a live sibling's lock, two
53
+ * processes ran the transaction at once, and 43 spawns were admitted
54
+ * against a cap of 40. Every teardown below is therefore checked against
55
+ * the nonce it was judged on, and every write is fenced on the holder's own
56
+ * nonce still being on the path.
57
+ */
45
58
  class ReservationStore {
46
59
  home;
47
60
  lockDir;
48
61
  file;
62
+ held = null;
49
63
  constructor(home) {
50
64
  this.home = home;
51
65
  (0, node_fs_1.mkdirSync)(home, { recursive: true, mode: 0o700 });
@@ -58,7 +72,15 @@ class ReservationStore {
58
72
  for (;;) {
59
73
  try {
60
74
  (0, node_fs_1.mkdirSync)(this.lockDir, { mode: 0o700 });
61
- (0, node_fs_1.writeFileSync)((0, node_path_1.join)(this.lockDir, 'owner'), `${process.pid}\n${Date.now()}\n`, { mode: 0o600 });
75
+ // The owner record must appear whole or not at all. A sibling that
76
+ // read a half-written timestamp once parsed it as "held since 1970",
77
+ // reclaimed a live lock, and admitted a 41st spawn.
78
+ const owner = { pid: process.pid, since: Date.now(), nonce: `${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` };
79
+ const tmp = (0, node_path_1.join)(this.lockDir, `owner.${process.pid}`);
80
+ (0, node_fs_1.writeFileSync)(tmp, JSON.stringify(owner), { mode: 0o600 });
81
+ (0, node_fs_1.renameSync)(tmp, (0, node_path_1.join)(this.lockDir, 'owner'));
82
+ this.held = owner;
83
+ this.trace('acquired');
62
84
  return;
63
85
  }
64
86
  catch (error) {
@@ -72,34 +94,124 @@ class ReservationStore {
72
94
  }
73
95
  }
74
96
  }
97
+ readOwnerAt(dir) {
98
+ try {
99
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(dir, 'owner'), 'utf8'));
100
+ if (Number.isInteger(parsed.pid) && typeof parsed.since === 'number' && parsed.since > 1_000_000_000_000 && typeof parsed.nonce === 'string') {
101
+ return { pid: parsed.pid, since: parsed.since, nonce: parsed.nonce };
102
+ }
103
+ }
104
+ catch {
105
+ /* missing or unreadable */
106
+ }
107
+ return null;
108
+ }
109
+ /** The holder's own instance is still the one on the path. */
110
+ fence() {
111
+ const current = this.readOwnerAt(this.lockDir);
112
+ if (!this.held || !current || current.nonce !== this.held.nonce) {
113
+ throw new Error('AgentGuard lost the reservation lock mid-transaction; failing closed.');
114
+ }
115
+ }
116
+ /**
117
+ * Take the lock directory off its path atomically, then verify it is the
118
+ * instance we meant. rmSync on the live path is readdir + unlink + rmdir,
119
+ * and a sibling can mkdir the same path between those steps, so removal
120
+ * is always rename-then-delete. If the instance we grabbed is not the one
121
+ * we judged (`expect`), it is a live sibling's: put it back.
122
+ */
123
+ discard(reason, expect) {
124
+ const quarantine = `${this.lockDir}.${reason}.${process.pid}.${Date.now().toString(36)}`;
125
+ try {
126
+ (0, node_fs_1.renameSync)(this.lockDir, quarantine);
127
+ }
128
+ catch {
129
+ return false; // somebody else already took it off the path
130
+ }
131
+ const got = this.readOwnerAt(quarantine);
132
+ if ((got?.nonce ?? null) !== expect) {
133
+ // Not the instance we judged. Give it back; if a waiter slipped into
134
+ // the freed path in between, the displaced holder's fence throws and
135
+ // its transaction is discarded, so nothing double-commits.
136
+ try {
137
+ (0, node_fs_1.renameSync)(quarantine, this.lockDir);
138
+ this.trace(`discard ${reason}: wrong instance, returned`);
139
+ return false;
140
+ }
141
+ catch {
142
+ this.trace(`discard ${reason}: wrong instance, could not return`);
143
+ }
144
+ }
145
+ try {
146
+ (0, node_fs_1.rmSync)(quarantine, { recursive: true, force: true });
147
+ }
148
+ catch {
149
+ /* late write by the old holder; unique name, harmless */
150
+ }
151
+ this.trace(`discard ${reason} ok`);
152
+ return true;
153
+ }
154
+ trace(line) {
155
+ if (!process.env.AGENTGUARD_DEBUG_LOCK)
156
+ return;
157
+ try {
158
+ (0, node_fs_1.appendFileSync)((0, node_path_1.join)(this.home, 'lock.log'), `${Date.now()} ${process.pid} ${line}\n`);
159
+ }
160
+ catch {
161
+ /* debug only */
162
+ }
163
+ }
75
164
  recoverIfStale() {
165
+ const owner = this.readOwnerAt(this.lockDir);
166
+ if (owner) {
167
+ const stale = !pidAlive(owner.pid) || Date.now() - owner.since > LOCK_STALE_MS;
168
+ if (stale) {
169
+ this.trace(`reclaim: owner ${owner.pid} alive=${pidAlive(owner.pid)} age=${Date.now() - owner.since}ms`);
170
+ this.discard('stale', owner.nonce);
171
+ }
172
+ return;
173
+ }
174
+ // No trustworthy owner record: the holder is between mkdir and rename, or
175
+ // it died in that gap. Only the directory's age can tell those apart.
176
+ // Reclaiming immediately here was the race that let a 41st spawn through
177
+ // under 60 concurrent processes.
76
178
  try {
77
- const raw = (0, node_fs_1.readFileSync)((0, node_path_1.join)(this.lockDir, 'owner'), 'utf8').split('\n');
78
- const pid = Number(raw[0]);
79
- const since = Number(raw[1]);
80
- const stale = !Number.isFinite(pid) || !pidAlive(pid) || Date.now() - since > LOCK_STALE_MS;
81
- if (stale)
82
- (0, node_fs_1.rmSync)(this.lockDir, { recursive: true, force: true });
179
+ const age = Date.now() - (0, node_fs_1.statSync)(this.lockDir).mtimeMs;
180
+ if (age > LOCK_STALE_MS) {
181
+ this.trace(`reclaim ownerless dir age=${age}ms`);
182
+ this.discard('orphan', null);
183
+ }
83
184
  }
84
185
  catch {
85
- // Owner file missing: the lock holder is mid-write or gone. If the
86
- // directory is older than the stale window, reclaim it.
87
- (0, node_fs_1.rmSync)(this.lockDir, { recursive: true, force: true });
186
+ /* directory vanished between checks: the next mkdir attempt decides */
88
187
  }
89
188
  }
90
189
  release() {
91
- (0, node_fs_1.rmSync)(this.lockDir, { recursive: true, force: true });
190
+ // Only our own instance is released. If it was reclaimed and a sibling
191
+ // now holds the path, discard() sees the nonce mismatch and returns it.
192
+ // Release never throws: a crash here is how a lock goes ownerless.
193
+ const mine = this.held;
194
+ this.held = null;
195
+ if (!mine)
196
+ return;
197
+ try {
198
+ this.discard('released', mine.nonce);
199
+ }
200
+ catch (error) {
201
+ this.trace(`release threw ${error.message}`);
202
+ }
92
203
  }
93
204
  load() {
94
205
  try {
95
206
  const parsed = JSON.parse((0, node_fs_1.readFileSync)(this.file, 'utf8'));
96
- if (parsed && parsed.version === 1 && Array.isArray(parsed.reservations))
97
- return parsed;
207
+ if (parsed && (parsed.version === 1 || parsed.version === 2) && Array.isArray(parsed.reservations)) {
208
+ return { version: 2, reservations: parsed.reservations, calls: Array.isArray(parsed.calls) ? parsed.calls : [] };
209
+ }
98
210
  }
99
211
  catch {
100
212
  /* corrupt or missing: start clean, never trust partial state */
101
213
  }
102
- return { version: 1, reservations: [] };
214
+ return { version: 2, reservations: [], calls: [] };
103
215
  }
104
216
  save(data) {
105
217
  const tmp = `${this.file}.${process.pid}.tmp`;
@@ -107,70 +219,192 @@ class ReservationStore {
107
219
  (0, node_fs_1.renameSync)(tmp, this.file);
108
220
  }
109
221
  /**
110
- * Try to reserve one spawn slot. `observedSpawns` is what the transcript
111
- * shows; the decision is made against observed + pending, under the lock.
222
+ * Run `fn` with the machine-wide lock held. Everything inside sees one
223
+ * consistent reservation file and writes it back once. The gateway uses
224
+ * this to make "fold state, evaluate, reserve, sign" a single transaction,
225
+ * so two hosts racing the same session cannot interleave halfway.
112
226
  *
113
- * Returns the effective count that was evaluated, so the caller can report
114
- * exactly why a spawn was denied.
227
+ * Throws if the lock cannot be taken. Callers must fail closed on throw.
115
228
  */
116
- reserve(args) {
117
- const now = args.now ?? Date.now();
229
+ withLock(fn) {
118
230
  this.acquire();
119
231
  try {
120
232
  const data = this.load();
121
- data.reservations = data.reservations.filter((r) => r.expiresAt > now);
122
- // Idempotent: the same tool_use_id evaluated twice must not double-count.
123
- const existing = data.reservations.find((r) => r.toolUseId === args.toolUseId);
124
- const pendingForSession = data.reservations.filter((r) => r.sessionId === args.sessionId && r.toolUseId !== args.toolUseId).length;
125
- const effective = args.observedSpawns + pendingForSession + 1;
126
- if (existing) {
127
- return { allowed: true, effectiveSpawns: effective, pending: pendingForSession };
128
- }
129
- if (effective > args.ceiling) {
130
- return { allowed: false, effectiveSpawns: effective, pending: pendingForSession };
131
- }
132
- data.reservations.push({
133
- sessionId: args.sessionId,
134
- toolUseId: args.toolUseId,
135
- at: now,
136
- expiresAt: now + exports.RESERVATION_TTL_MS,
137
- });
138
- this.save(data);
139
- return { allowed: true, effectiveSpawns: effective, pending: pendingForSession };
233
+ const tx = new Transaction(data);
234
+ const result = fn(tx);
235
+ // Fence before anything is written: if our instance was reclaimed
236
+ // while fn ran, the whole transaction is discarded, not half-applied.
237
+ this.fence();
238
+ if (tx.dirty)
239
+ this.save(data);
240
+ return result;
140
241
  }
141
242
  finally {
142
243
  this.release();
143
244
  }
144
245
  }
246
+ /**
247
+ * Callers that write their own files inside a transaction (the gateway's
248
+ * session file) call this right before writing, for the same reason.
249
+ */
250
+ assertHeld() {
251
+ this.fence();
252
+ }
253
+ /**
254
+ * Try to reserve one spawn slot. `observedSpawns` is what the transcript
255
+ * shows; the decision is made against observed + pending, under the lock.
256
+ *
257
+ * Returns the effective count that was evaluated, so the caller can report
258
+ * exactly why a spawn was denied.
259
+ */
260
+ reserve(args) {
261
+ return this.withLock((tx) => tx.reserve(args));
262
+ }
145
263
  /** Drop reservations the transcript has now accounted for. */
146
264
  reconcile(sessionId, observedSpawns, previouslyObserved) {
147
- const settled = Math.max(0, observedSpawns - previouslyObserved);
148
- if (settled === 0)
265
+ if (Math.max(0, observedSpawns - previouslyObserved) === 0)
149
266
  return;
150
- this.acquire();
151
- try {
152
- const data = this.load();
153
- let remaining = settled;
154
- data.reservations = data.reservations.filter((r) => {
155
- if (r.sessionId === sessionId && remaining > 0) {
156
- remaining -= 1;
157
- return false;
158
- }
159
- return true;
160
- });
161
- this.save(data);
162
- }
163
- finally {
164
- this.release();
165
- }
267
+ this.withLock((tx) => tx.reconcile(sessionId, observedSpawns, previouslyObserved));
166
268
  }
167
269
  pendingFor(sessionId, now = Date.now()) {
168
270
  return this.load().reservations.filter((r) => r.sessionId === sessionId && r.expiresAt > now).length;
169
271
  }
272
+ reserveCall(args) {
273
+ return this.withLock((tx) => tx.reserveCall(args));
274
+ }
275
+ finishCall(callId, windowMs, now = Date.now()) {
276
+ return this.withLock((tx) => tx.finishCall(callId, windowMs, now));
277
+ }
278
+ /** Lock-free read for status. May be a few milliseconds stale; never used to decide. */
279
+ computeSnapshot(sessionId, windowMs, now = Date.now()) {
280
+ return computeSnapshot(pruneCalls(this.load().calls ?? [], now, windowMs), sessionId, now, windowMs);
281
+ }
170
282
  clear() {
171
283
  if ((0, node_fs_1.existsSync)(this.file))
172
284
  (0, node_fs_1.rmSync)(this.file, { force: true });
173
- (0, node_fs_1.rmSync)(this.lockDir, { recursive: true, force: true });
285
+ this.discard('cleared', this.readOwnerAt(this.lockDir)?.nonce ?? null);
174
286
  }
175
287
  }
176
288
  exports.ReservationStore = ReservationStore;
289
+ /** Operations on the loaded reservation file while the lock is held. */
290
+ class Transaction {
291
+ data;
292
+ dirty = false;
293
+ constructor(data) {
294
+ this.data = data;
295
+ if (!this.data.calls)
296
+ this.data.calls = [];
297
+ }
298
+ reserve(args) {
299
+ const now = args.now ?? Date.now();
300
+ const data = this.data;
301
+ const before = data.reservations.length;
302
+ data.reservations = data.reservations.filter((r) => r.expiresAt > now);
303
+ if (data.reservations.length !== before)
304
+ this.dirty = true;
305
+ // Idempotent: the same tool_use_id evaluated twice must not double-count.
306
+ const existing = data.reservations.find((r) => r.toolUseId === args.toolUseId);
307
+ const pendingForSession = data.reservations.filter((r) => r.sessionId === args.sessionId && r.toolUseId !== args.toolUseId).length;
308
+ const effective = args.observedSpawns + pendingForSession + 1;
309
+ if (existing) {
310
+ return { allowed: true, effectiveSpawns: effective, pending: pendingForSession };
311
+ }
312
+ if (effective > args.ceiling) {
313
+ return { allowed: false, effectiveSpawns: effective, pending: pendingForSession };
314
+ }
315
+ data.reservations.push({
316
+ sessionId: args.sessionId,
317
+ toolUseId: args.toolUseId,
318
+ at: now,
319
+ expiresAt: now + exports.RESERVATION_TTL_MS,
320
+ });
321
+ this.dirty = true;
322
+ return { allowed: true, effectiveSpawns: effective, pending: pendingForSession };
323
+ }
324
+ reconcile(sessionId, observedSpawns, previouslyObserved) {
325
+ let remaining = Math.max(0, observedSpawns - previouslyObserved);
326
+ if (remaining === 0)
327
+ return;
328
+ const before = this.data.reservations.length;
329
+ this.data.reservations = this.data.reservations.filter((r) => {
330
+ if (r.sessionId === sessionId && remaining > 0) {
331
+ remaining -= 1;
332
+ return false;
333
+ }
334
+ return true;
335
+ });
336
+ if (this.data.reservations.length !== before)
337
+ this.dirty = true;
338
+ }
339
+ /**
340
+ * Open a model-call reservation and return the compute snapshot it was
341
+ * admitted against. Idempotent on callId: middleware and a proxy that both
342
+ * see the same call converge on one record. The caller decides the verdict;
343
+ * the store only guarantees the count is atomic across processes.
344
+ */
345
+ reserveCall(args) {
346
+ const now = args.now ?? Date.now();
347
+ const calls = pruneCalls(this.data.calls, now, args.windowMs);
348
+ if (calls.length !== this.data.calls.length)
349
+ this.dirty = true;
350
+ this.data.calls = calls;
351
+ if (!calls.some((c) => c.callId === args.callId)) {
352
+ calls.push({
353
+ sessionId: args.sessionId,
354
+ callId: args.callId,
355
+ host: args.host,
356
+ estimatedTokens: Math.max(0, args.estimatedTokens),
357
+ startedAt: now,
358
+ finishedAt: null,
359
+ expiresAt: now + args.ttlMs,
360
+ });
361
+ this.dirty = true;
362
+ }
363
+ return computeSnapshot(calls, args.sessionId, now, args.windowMs);
364
+ }
365
+ /** Mark a call finished. Returns its duration, or null if unknown to us. */
366
+ finishCall(callId, windowMs, now = Date.now()) {
367
+ const call = this.data.calls.find((c) => c.callId === callId);
368
+ if (!call)
369
+ return null;
370
+ if (call.finishedAt === null) {
371
+ call.finishedAt = now;
372
+ call.estimatedTokens = 0;
373
+ // Keep it exactly as long as the occupied-time window can see it.
374
+ call.expiresAt = now + windowMs;
375
+ this.dirty = true;
376
+ }
377
+ return call.finishedAt - call.startedAt;
378
+ }
379
+ computeSnapshot(sessionId, windowMs, now = Date.now()) {
380
+ return computeSnapshot(pruneCalls(this.data.calls, now, windowMs), sessionId, now, windowMs);
381
+ }
382
+ }
383
+ exports.Transaction = Transaction;
384
+ function pruneCalls(calls, now, windowMs) {
385
+ const horizon = now - windowMs;
386
+ return calls.filter((c) => {
387
+ if (c.finishedAt === null)
388
+ return c.expiresAt > now;
389
+ return c.finishedAt > horizon;
390
+ });
391
+ }
392
+ function computeSnapshot(calls, sessionId, now, windowMs) {
393
+ const horizon = now - windowMs;
394
+ let inFlight = 0;
395
+ let inFlightForSession = 0;
396
+ let occupiedMs = 0;
397
+ let pendingEstimatedTokens = 0;
398
+ for (const c of calls) {
399
+ const end = c.finishedAt ?? now;
400
+ if (c.finishedAt === null) {
401
+ inFlight += 1;
402
+ if (c.sessionId === sessionId)
403
+ inFlightForSession += 1;
404
+ pendingEstimatedTokens += c.estimatedTokens;
405
+ }
406
+ // Only the part of the request inside the window counts.
407
+ occupiedMs += Math.max(0, end - Math.max(c.startedAt, horizon));
408
+ }
409
+ return { inFlight, inFlightForSession, occupiedMs, pendingEstimatedTokens, windowMs };
410
+ }
@@ -12,6 +12,12 @@ import type { BurnEvent, SessionState } from '../types';
12
12
  export declare function newSessionState(sessionId: string, firstEventAt: number): SessionState;
13
13
  /** Apply one event. Returns the active minutes that elapsed for debt accounting. */
14
14
  export declare function applyEvent(state: SessionState, event: BurnEvent): number;
15
+ /**
16
+ * Correct a count already applied, in either direction, without touching
17
+ * active time. Used when a real usage figure replaces an estimate that was
18
+ * reserved under the same call ID. Buckets never go below zero.
19
+ */
20
+ export declare function applyCorrection(state: SessionState, deltaTokens: number, deltaCacheRead: number): void;
15
21
  /** Sum of a rolling window over the last N active minutes. */
16
22
  export declare function windowSum(byMinute: Map<number, number>, activeMinutes: number, windowMinutes: number): number;
17
23
  /** Median of completed, nonzero active minutes. The current partial minute is excluded. */
@@ -12,6 +12,7 @@
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
13
  exports.newSessionState = newSessionState;
14
14
  exports.applyEvent = applyEvent;
15
+ exports.applyCorrection = applyCorrection;
15
16
  exports.windowSum = windowSum;
16
17
  exports.medianCompletedMinute = medianCompletedMinute;
17
18
  exports.cacheReadRatio = cacheReadRatio;
@@ -62,6 +63,22 @@ function applyEvent(state, event) {
62
63
  }
63
64
  return activeGapMinutes;
64
65
  }
66
+ /**
67
+ * Correct a count already applied, in either direction, without touching
68
+ * active time. Used when a real usage figure replaces an estimate that was
69
+ * reserved under the same call ID. Buckets never go below zero.
70
+ */
71
+ function applyCorrection(state, deltaTokens, deltaCacheRead) {
72
+ state.totalTokens = Math.max(0, state.totalTokens + deltaTokens);
73
+ state.totalCacheRead = Math.max(0, state.totalCacheRead + deltaCacheRead);
74
+ const bucket = Math.floor(state.activeMinutes);
75
+ const current = state.tokensByActiveMinute.get(bucket) ?? 0;
76
+ const next = Math.max(0, current + deltaTokens);
77
+ if (next > 0)
78
+ state.tokensByActiveMinute.set(bucket, next);
79
+ else
80
+ state.tokensByActiveMinute.delete(bucket);
81
+ }
65
82
  /** Sum of a rolling window over the last N active minutes. */
66
83
  function windowSum(byMinute, activeMinutes, windowMinutes) {
67
84
  const from = Math.max(0, Math.floor(activeMinutes) - windowMinutes);
@@ -0,0 +1,19 @@
1
+ /**
2
+ * One status for the whole machine.
3
+ *
4
+ * Every host's sessions in one table, with coverage stated per plane, so an
5
+ * OK never masquerades as full visibility. A Cursor session shows
6
+ * `usage:n/a`; a proxy session shows `spawns:n/a`; a session fed by both
7
+ * middleware and the proxy shows full coverage, because it has it.
8
+ */
9
+ import type { GatewaySessionView } from './gateway';
10
+ import type { HostHealth } from './install';
11
+ import type { ComputeSnapshot } from './state/reservations';
12
+ /**
13
+ * Claude Code sessions live in the hook's own files (they carry a transcript
14
+ * cursor the gateway sessions do not). Read them into the same view so the
15
+ * table shows every host, not just the new ones.
16
+ */
17
+ export declare function readHookSessions(home: string): GatewaySessionView[];
18
+ export declare function renderHostHealth(hosts: HostHealth[]): string;
19
+ export declare function renderMachineStatus(sessions: GatewaySessionView[], compute: ComputeSnapshot, now?: number): string;