@agentguard-run/burn 0.1.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.
@@ -0,0 +1,10 @@
1
+ export * from './types';
2
+ export { DEFAULT_POLICY, DEFAULT_THRESHOLDS } from './defaults';
3
+ export { evaluate, fmt } from './detectors/evaluate';
4
+ export { readIncremental, readAll, newCursor, normaliseLine, type ReaderCursor } from './history/claude-transcript';
5
+ export { applyEvent, newSessionState, windowSum, medianCompletedMinute, cacheReadRatio } from './state/session';
6
+ export { ReservationStore, RESERVATION_TTL_MS } from './state/reservations';
7
+ export { replayAll, replaySession, replayEvents, discoverTranscripts, type ReplaySummary, type SessionReplay } from './replay/simulate';
8
+ export { renderReplay } from './replay/render';
9
+ export { calibrate, type CalibrationResult } from './calibrate';
10
+ export { handlePreToolUse, refreshSession, loadPolicy, settingsSnippet, type HookInput, type HookOutput } from './hook/pre-tool-use';
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.settingsSnippet = exports.loadPolicy = exports.refreshSession = exports.handlePreToolUse = exports.calibrate = exports.renderReplay = exports.discoverTranscripts = exports.replayEvents = exports.replaySession = exports.replayAll = exports.RESERVATION_TTL_MS = exports.ReservationStore = exports.cacheReadRatio = exports.medianCompletedMinute = exports.windowSum = exports.newSessionState = exports.applyEvent = exports.normaliseLine = exports.newCursor = exports.readAll = exports.readIncremental = exports.fmt = exports.evaluate = exports.DEFAULT_THRESHOLDS = exports.DEFAULT_POLICY = void 0;
18
+ __exportStar(require("./types"), exports);
19
+ var defaults_1 = require("./defaults");
20
+ Object.defineProperty(exports, "DEFAULT_POLICY", { enumerable: true, get: function () { return defaults_1.DEFAULT_POLICY; } });
21
+ Object.defineProperty(exports, "DEFAULT_THRESHOLDS", { enumerable: true, get: function () { return defaults_1.DEFAULT_THRESHOLDS; } });
22
+ var evaluate_1 = require("./detectors/evaluate");
23
+ Object.defineProperty(exports, "evaluate", { enumerable: true, get: function () { return evaluate_1.evaluate; } });
24
+ Object.defineProperty(exports, "fmt", { enumerable: true, get: function () { return evaluate_1.fmt; } });
25
+ var claude_transcript_1 = require("./history/claude-transcript");
26
+ Object.defineProperty(exports, "readIncremental", { enumerable: true, get: function () { return claude_transcript_1.readIncremental; } });
27
+ Object.defineProperty(exports, "readAll", { enumerable: true, get: function () { return claude_transcript_1.readAll; } });
28
+ Object.defineProperty(exports, "newCursor", { enumerable: true, get: function () { return claude_transcript_1.newCursor; } });
29
+ Object.defineProperty(exports, "normaliseLine", { enumerable: true, get: function () { return claude_transcript_1.normaliseLine; } });
30
+ var session_1 = require("./state/session");
31
+ Object.defineProperty(exports, "applyEvent", { enumerable: true, get: function () { return session_1.applyEvent; } });
32
+ Object.defineProperty(exports, "newSessionState", { enumerable: true, get: function () { return session_1.newSessionState; } });
33
+ Object.defineProperty(exports, "windowSum", { enumerable: true, get: function () { return session_1.windowSum; } });
34
+ Object.defineProperty(exports, "medianCompletedMinute", { enumerable: true, get: function () { return session_1.medianCompletedMinute; } });
35
+ Object.defineProperty(exports, "cacheReadRatio", { enumerable: true, get: function () { return session_1.cacheReadRatio; } });
36
+ var reservations_1 = require("./state/reservations");
37
+ Object.defineProperty(exports, "ReservationStore", { enumerable: true, get: function () { return reservations_1.ReservationStore; } });
38
+ Object.defineProperty(exports, "RESERVATION_TTL_MS", { enumerable: true, get: function () { return reservations_1.RESERVATION_TTL_MS; } });
39
+ var simulate_1 = require("./replay/simulate");
40
+ Object.defineProperty(exports, "replayAll", { enumerable: true, get: function () { return simulate_1.replayAll; } });
41
+ Object.defineProperty(exports, "replaySession", { enumerable: true, get: function () { return simulate_1.replaySession; } });
42
+ Object.defineProperty(exports, "replayEvents", { enumerable: true, get: function () { return simulate_1.replayEvents; } });
43
+ Object.defineProperty(exports, "discoverTranscripts", { enumerable: true, get: function () { return simulate_1.discoverTranscripts; } });
44
+ var render_1 = require("./replay/render");
45
+ Object.defineProperty(exports, "renderReplay", { enumerable: true, get: function () { return render_1.renderReplay; } });
46
+ var calibrate_1 = require("./calibrate");
47
+ Object.defineProperty(exports, "calibrate", { enumerable: true, get: function () { return calibrate_1.calibrate; } });
48
+ var pre_tool_use_1 = require("./hook/pre-tool-use");
49
+ Object.defineProperty(exports, "handlePreToolUse", { enumerable: true, get: function () { return pre_tool_use_1.handlePreToolUse; } });
50
+ Object.defineProperty(exports, "refreshSession", { enumerable: true, get: function () { return pre_tool_use_1.refreshSession; } });
51
+ Object.defineProperty(exports, "loadPolicy", { enumerable: true, get: function () { return pre_tool_use_1.loadPolicy; } });
52
+ Object.defineProperty(exports, "settingsSnippet", { enumerable: true, get: function () { return pre_tool_use_1.settingsSnippet; } });
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Terminal rendering for replay. This output is the marketing artifact, so it
3
+ * has to survive a screenshot: pure ASCII box drawing, ANSI colour that
4
+ * degrades cleanly, nothing that depends on a font.
5
+ */
6
+ import type { ReplaySummary, SessionReplay } from './simulate';
7
+ export declare function renderReplay(summary: ReplaySummary, opts?: {
8
+ colour?: boolean;
9
+ top?: number;
10
+ }): string;
11
+ export declare function renderSessionRow(s: SessionReplay): string;
@@ -0,0 +1,89 @@
1
+ "use strict";
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.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.renderReplay = renderReplay;
9
+ exports.renderSessionRow = renderSessionRow;
10
+ const evaluate_1 = require("../detectors/evaluate");
11
+ const C = {
12
+ reset: '\x1b[0m',
13
+ bold: '\x1b[1m',
14
+ dim: '\x1b[2m',
15
+ red: '\x1b[31m',
16
+ yellow: '\x1b[33m',
17
+ green: '\x1b[32m',
18
+ cyan: '\x1b[36m',
19
+ };
20
+ function colour(enabled, code, text) {
21
+ return enabled ? `${code}${text}${C.reset}` : text;
22
+ }
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);
27
+ return `│ ${text}${' '.repeat(pad)} │`;
28
+ }
29
+ const TOP = `╭${'─'.repeat(W)}╮`;
30
+ const MID = `├${'─'.repeat(W)}┤`;
31
+ const BOT = `╰${'─'.repeat(W)}╯`;
32
+ /** API-list equivalent. Labelled as a scenario, never as the user's bill. */
33
+ function scenarioUsd(tokens, cacheShare) {
34
+ const cached = tokens * cacheShare;
35
+ return (cached * 0.3 + (tokens - cached) * 5) / 1e6;
36
+ }
37
+ function renderReplay(summary, opts = {}) {
38
+ const on = opts.colour ?? Boolean(process.stdout.isTTY);
39
+ const top = opts.top ?? 8;
40
+ 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)
43
+ : 0;
44
+ 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')));
47
+ 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.`)));
50
+ 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`)}`));
60
+ out.push(MID);
61
+ 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
+ }
78
+ }
79
+ if (summary.sessions.length > top) {
80
+ out.push(line(colour(on, C.dim, `… ${summary.sessions.length - top} more sessions below the warning line`)));
81
+ }
82
+ out.push(MID);
83
+ out.push(line(colour(on, C.cyan, 'Next: agentguard-burn init (shadow mode; blocks nothing yet)')));
84
+ out.push(BOT);
85
+ return out.join('\n');
86
+ }
87
+ function renderSessionRow(s) {
88
+ return `${s.sessionId.slice(0, 8)} ${(0, evaluate_1.fmt)(s.totalTokens).padStart(8)} ${String(s.spawns).padStart(4)} spawns ${s.finalVerdict}`;
89
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Replay: run the detectors over recorded history.
3
+ *
4
+ * This is the front door. Nobody installs a blocker cold. They install it after
5
+ * seeing, on their own history, what it would have stopped and when. It is
6
+ * also the honest validation surface: the thresholds shipped in defaults.ts
7
+ * were fitted by exactly this code against 412 real sessions.
8
+ *
9
+ * What replay measures is the *observed tail* after the first enforceable
10
+ * boundary. That is an upper bound on what enforcement would have prevented,
11
+ * because the user might have overridden, restarted, or continued in another
12
+ * session. It is reported as "retrospectively catchable", never as "saved".
13
+ */
14
+ import type { BurnEvent, Thresholds, Verdict } from '../types';
15
+ export interface SessionReplay {
16
+ sessionId: string;
17
+ path: string;
18
+ totalTokens: number;
19
+ spawns: number;
20
+ maxDepth: number;
21
+ activeMinutes: number;
22
+ cacheReadRatio: number;
23
+ finalVerdict: Verdict;
24
+ /** First boundary at which each plane reached STOP, if it did. */
25
+ fanoutStop: {
26
+ atSpawn: number;
27
+ tokensAtStop: number;
28
+ } | null;
29
+ sustainedStop: {
30
+ atSpawn: number;
31
+ tokensAtStop: number;
32
+ } | null;
33
+ /** Tokens observed after the earliest STOP boundary. */
34
+ catchableTail: number;
35
+ firstWarn: {
36
+ detector: string;
37
+ tokensAt: number;
38
+ spawnsAt: number;
39
+ } | null;
40
+ }
41
+ export declare function discoverTranscripts(root?: string): string[];
42
+ export declare function replaySession(path: string, thresholds: Thresholds): SessionReplay | null;
43
+ export declare function replayEvents(sessionId: string, path: string, events: BurnEvent[], thresholds: Thresholds): SessionReplay;
44
+ export interface ReplaySummary {
45
+ sessions: SessionReplay[];
46
+ totalTokens: number;
47
+ totalSpawns: number;
48
+ catchableTail: number;
49
+ catchableShare: number;
50
+ stops: number;
51
+ warns: number;
52
+ clean: number;
53
+ }
54
+ export declare function replayAll(paths: string[], thresholds: Thresholds, minTokens?: number): ReplaySummary;
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ /**
3
+ * Replay: run the detectors over recorded history.
4
+ *
5
+ * This is the front door. Nobody installs a blocker cold. They install it after
6
+ * seeing, on their own history, what it would have stopped and when. It is
7
+ * also the honest validation surface: the thresholds shipped in defaults.ts
8
+ * were fitted by exactly this code against 412 real sessions.
9
+ *
10
+ * What replay measures is the *observed tail* after the first enforceable
11
+ * boundary. That is an upper bound on what enforcement would have prevented,
12
+ * because the user might have overridden, restarted, or continued in another
13
+ * session. It is reported as "retrospectively catchable", never as "saved".
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.discoverTranscripts = discoverTranscripts;
17
+ exports.replaySession = replaySession;
18
+ exports.replayEvents = replayEvents;
19
+ exports.replayAll = replayAll;
20
+ const node_fs_1 = require("node:fs");
21
+ const node_path_1 = require("node:path");
22
+ const node_os_1 = require("node:os");
23
+ const evaluate_1 = require("../detectors/evaluate");
24
+ const claude_transcript_1 = require("../history/claude-transcript");
25
+ const session_1 = require("../state/session");
26
+ function discoverTranscripts(root = (0, node_path_1.join)((0, node_os_1.homedir)(), '.claude', 'projects')) {
27
+ const out = [];
28
+ let projects = [];
29
+ try {
30
+ projects = (0, node_fs_1.readdirSync)(root);
31
+ }
32
+ catch {
33
+ return out;
34
+ }
35
+ for (const project of projects) {
36
+ const dir = (0, node_path_1.join)(root, project);
37
+ try {
38
+ for (const file of (0, node_fs_1.readdirSync)(dir)) {
39
+ if (file.endsWith('.jsonl'))
40
+ out.push((0, node_path_1.join)(dir, file));
41
+ }
42
+ }
43
+ catch {
44
+ /* not a directory, skip */
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+ function replaySession(path, thresholds) {
50
+ const { events } = (0, claude_transcript_1.readAll)(path);
51
+ if (events.length === 0)
52
+ return null;
53
+ events.sort((a, b) => a.at - b.at);
54
+ return replayEvents((0, node_path_1.basename)(path, '.jsonl'), path, events, thresholds);
55
+ }
56
+ function replayEvents(sessionId, path, events, thresholds) {
57
+ const first = events[0];
58
+ const state = (0, session_1.newSessionState)(sessionId, first.at);
59
+ let fanoutStop = null;
60
+ let sustainedStop = null;
61
+ let firstWarn = null;
62
+ let tokensAtEarliestStop = null;
63
+ for (const event of events) {
64
+ // A hook boundary exists only where a spawn was attempted. Evaluate the
65
+ // *proposal* before applying the event, then apply it.
66
+ for (const spawn of event.spawns) {
67
+ const report = (0, evaluate_1.evaluate)(state, thresholds, spawn.issuerDepth + 1);
68
+ if (!firstWarn && report.verdict !== 'OK') {
69
+ firstWarn = { detector: report.findings[0]?.detector ?? 'unknown', tokensAt: state.totalTokens, spawnsAt: state.spawnCount + 1 };
70
+ }
71
+ for (const finding of report.findings) {
72
+ if (finding.verdict !== 'STOP')
73
+ continue;
74
+ if (finding.detector === 'fanout' && !fanoutStop) {
75
+ fanoutStop = { atSpawn: state.spawnCount + 1, tokensAtStop: state.totalTokens };
76
+ }
77
+ if ((finding.detector === 'sustained_burn' || finding.detector === 'burn_debt') && !sustainedStop) {
78
+ sustainedStop = { atSpawn: state.spawnCount + 1, tokensAtStop: state.totalTokens };
79
+ }
80
+ }
81
+ if ((fanoutStop || sustainedStop) && tokensAtEarliestStop === null) {
82
+ tokensAtEarliestStop = state.totalTokens;
83
+ }
84
+ }
85
+ // The sustained plane also has a boundary at every tool call, not just
86
+ // spawns. Approximate: any event with surfaces is a tool boundary.
87
+ if (!sustainedStop && event.surfaces.length > 0) {
88
+ const report = (0, evaluate_1.evaluate)(state, thresholds, null);
89
+ if (!firstWarn && report.verdict !== 'OK') {
90
+ firstWarn = { detector: report.findings[0]?.detector ?? 'unknown', tokensAt: state.totalTokens, spawnsAt: state.spawnCount };
91
+ }
92
+ const hit = report.findings.find((f) => f.verdict === 'STOP' && (f.detector === 'sustained_burn' || f.detector === 'burn_debt'));
93
+ if (hit) {
94
+ sustainedStop = { atSpawn: state.spawnCount, tokensAtStop: state.totalTokens };
95
+ if (tokensAtEarliestStop === null)
96
+ tokensAtEarliestStop = state.totalTokens;
97
+ }
98
+ }
99
+ (0, session_1.applyEvent)(state, event);
100
+ }
101
+ const final = (0, evaluate_1.evaluate)(state, thresholds, null);
102
+ return {
103
+ sessionId,
104
+ path,
105
+ totalTokens: state.totalTokens,
106
+ spawns: state.spawnCount,
107
+ maxDepth: state.maxDepth,
108
+ activeMinutes: state.activeMinutes,
109
+ cacheReadRatio: final.cacheReadRatio,
110
+ finalVerdict: final.verdict,
111
+ fanoutStop,
112
+ sustainedStop,
113
+ catchableTail: tokensAtEarliestStop === null ? 0 : Math.max(0, state.totalTokens - tokensAtEarliestStop),
114
+ firstWarn,
115
+ };
116
+ }
117
+ function replayAll(paths, thresholds, minTokens = 0) {
118
+ const sessions = [];
119
+ for (const path of paths) {
120
+ try {
121
+ if ((0, node_fs_1.statSync)(path).size === 0)
122
+ continue;
123
+ const result = replaySession(path, thresholds);
124
+ if (result && result.totalTokens >= minTokens)
125
+ sessions.push(result);
126
+ }
127
+ catch {
128
+ /* unreadable transcript: skip, never crash the report */
129
+ }
130
+ }
131
+ sessions.sort((a, b) => b.totalTokens - a.totalTokens);
132
+ const totalTokens = sessions.reduce((s, r) => s + r.totalTokens, 0);
133
+ const catchableTail = sessions.reduce((s, r) => s + r.catchableTail, 0);
134
+ return {
135
+ sessions,
136
+ totalTokens,
137
+ totalSpawns: sessions.reduce((s, r) => s + r.spawns, 0),
138
+ catchableTail,
139
+ catchableShare: totalTokens > 0 ? catchableTail / totalTokens : 0,
140
+ stops: sessions.filter((r) => r.fanoutStop || r.sustainedStop).length,
141
+ warns: sessions.filter((r) => !r.fanoutStop && !r.sustainedStop && r.firstWarn).length,
142
+ clean: sessions.filter((r) => !r.fanoutStop && !r.sustainedStop && !r.firstWarn).length,
143
+ };
144
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Atomic spawn reservations.
3
+ *
4
+ * The naive design is broken in exactly the case it exists for. When Claude
5
+ * Code launches ten Agent calls in one batch, ten hook processes start at the
6
+ * same moment, each reads the transcript, and each sees the same count, say 39.
7
+ * Every one of them concludes "40 is fine" and allows. The cap was cosmetic
8
+ * precisely during the burst it was meant to stop.
9
+ *
10
+ * Fix: before a spawn is allowed, the hook takes a machine-wide lock and writes
11
+ * a reservation. The effective count is
12
+ *
13
+ * observed transcript spawns + unexpired reservations
14
+ *
15
+ * so the second hook process sees the first one's reservation immediately.
16
+ * Reservations expire in case a hook allowed a spawn that never happened, and
17
+ * are reconciled away as the transcript catches up.
18
+ *
19
+ * The lock is a directory created with O_EXCL semantics (mkdir is atomic on
20
+ * every filesystem we care about). A stale lock whose owner PID is dead is
21
+ * recovered. This is a single-machine primitive by design; two laptops on one
22
+ * account do not share it, and that is stated rather than papered over.
23
+ */
24
+ export interface Reservation {
25
+ sessionId: string;
26
+ toolUseId: string;
27
+ at: number;
28
+ expiresAt: number;
29
+ }
30
+ export declare const RESERVATION_TTL_MS = 90000;
31
+ export declare class ReservationStore {
32
+ private readonly home;
33
+ private readonly lockDir;
34
+ private readonly file;
35
+ constructor(home: string);
36
+ /** Acquire the lock or throw. Callers must fail closed on throw. */
37
+ private acquire;
38
+ private recoverIfStale;
39
+ private release;
40
+ private load;
41
+ private save;
42
+ /**
43
+ * Try to reserve one spawn slot. `observedSpawns` is what the transcript
44
+ * shows; the decision is made against observed + pending, under the lock.
45
+ *
46
+ * Returns the effective count that was evaluated, so the caller can report
47
+ * exactly why a spawn was denied.
48
+ */
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
+ };
60
+ /** Drop reservations the transcript has now accounted for. */
61
+ reconcile(sessionId: string, observedSpawns: number, previouslyObserved: number): void;
62
+ pendingFor(sessionId: string, now?: number): number;
63
+ clear(): void;
64
+ }
@@ -0,0 +1,176 @@
1
+ "use strict";
2
+ /**
3
+ * Atomic spawn reservations.
4
+ *
5
+ * The naive design is broken in exactly the case it exists for. When Claude
6
+ * Code launches ten Agent calls in one batch, ten hook processes start at the
7
+ * same moment, each reads the transcript, and each sees the same count, say 39.
8
+ * Every one of them concludes "40 is fine" and allows. The cap was cosmetic
9
+ * precisely during the burst it was meant to stop.
10
+ *
11
+ * Fix: before a spawn is allowed, the hook takes a machine-wide lock and writes
12
+ * a reservation. The effective count is
13
+ *
14
+ * observed transcript spawns + unexpired reservations
15
+ *
16
+ * so the second hook process sees the first one's reservation immediately.
17
+ * Reservations expire in case a hook allowed a spawn that never happened, and
18
+ * are reconciled away as the transcript catches up.
19
+ *
20
+ * The lock is a directory created with O_EXCL semantics (mkdir is atomic on
21
+ * every filesystem we care about). A stale lock whose owner PID is dead is
22
+ * recovered. This is a single-machine primitive by design; two laptops on one
23
+ * account do not share it, and that is stated rather than papered over.
24
+ */
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.ReservationStore = exports.RESERVATION_TTL_MS = void 0;
27
+ const node_fs_1 = require("node:fs");
28
+ const node_path_1 = require("node:path");
29
+ const LOCK_STALE_MS = 10_000;
30
+ const LOCK_WAIT_MS = 3_000;
31
+ const LOCK_SPIN_MS = 15;
32
+ exports.RESERVATION_TTL_MS = 90_000;
33
+ function pidAlive(pid) {
34
+ try {
35
+ process.kill(pid, 0);
36
+ return true;
37
+ }
38
+ catch (error) {
39
+ return error.code === 'EPERM';
40
+ }
41
+ }
42
+ function sleepSync(ms) {
43
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
44
+ }
45
+ class ReservationStore {
46
+ home;
47
+ lockDir;
48
+ file;
49
+ constructor(home) {
50
+ this.home = home;
51
+ (0, node_fs_1.mkdirSync)(home, { recursive: true, mode: 0o700 });
52
+ this.lockDir = (0, node_path_1.join)(home, 'burn.lock');
53
+ this.file = (0, node_path_1.join)(home, 'reservations.json');
54
+ }
55
+ /** Acquire the lock or throw. Callers must fail closed on throw. */
56
+ acquire() {
57
+ const deadline = Date.now() + LOCK_WAIT_MS;
58
+ for (;;) {
59
+ try {
60
+ (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 });
62
+ return;
63
+ }
64
+ catch (error) {
65
+ if (error.code !== 'EEXIST')
66
+ throw error;
67
+ this.recoverIfStale();
68
+ if (Date.now() > deadline) {
69
+ throw new Error('AgentGuard could not acquire the reservation lock; failing closed.');
70
+ }
71
+ sleepSync(LOCK_SPIN_MS);
72
+ }
73
+ }
74
+ }
75
+ recoverIfStale() {
76
+ 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 });
83
+ }
84
+ 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 });
88
+ }
89
+ }
90
+ release() {
91
+ (0, node_fs_1.rmSync)(this.lockDir, { recursive: true, force: true });
92
+ }
93
+ load() {
94
+ try {
95
+ 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;
98
+ }
99
+ catch {
100
+ /* corrupt or missing: start clean, never trust partial state */
101
+ }
102
+ return { version: 1, reservations: [] };
103
+ }
104
+ save(data) {
105
+ const tmp = `${this.file}.${process.pid}.tmp`;
106
+ (0, node_fs_1.writeFileSync)(tmp, JSON.stringify(data), { mode: 0o600 });
107
+ (0, node_fs_1.renameSync)(tmp, this.file);
108
+ }
109
+ /**
110
+ * Try to reserve one spawn slot. `observedSpawns` is what the transcript
111
+ * shows; the decision is made against observed + pending, under the lock.
112
+ *
113
+ * Returns the effective count that was evaluated, so the caller can report
114
+ * exactly why a spawn was denied.
115
+ */
116
+ reserve(args) {
117
+ const now = args.now ?? Date.now();
118
+ this.acquire();
119
+ try {
120
+ 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 };
140
+ }
141
+ finally {
142
+ this.release();
143
+ }
144
+ }
145
+ /** Drop reservations the transcript has now accounted for. */
146
+ reconcile(sessionId, observedSpawns, previouslyObserved) {
147
+ const settled = Math.max(0, observedSpawns - previouslyObserved);
148
+ if (settled === 0)
149
+ 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
+ }
166
+ }
167
+ pendingFor(sessionId, now = Date.now()) {
168
+ return this.load().reservations.filter((r) => r.sessionId === sessionId && r.expiresAt > now).length;
169
+ }
170
+ clear() {
171
+ if ((0, node_fs_1.existsSync)(this.file))
172
+ (0, node_fs_1.rmSync)(this.file, { force: true });
173
+ (0, node_fs_1.rmSync)(this.lockDir, { recursive: true, force: true });
174
+ }
175
+ }
176
+ exports.ReservationStore = ReservationStore;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Session state: fold events into the numbers detectors need.
3
+ *
4
+ * Active time is the key idea. These sessions are resumed over days. A
5
+ * wall-clock rolling window dilutes real activity with hours of idleness, so a
6
+ * "16 spawns in 5 minutes" rule never fires on a session that spawned 143
7
+ * agents over an evening with coffee breaks. Active time caps every gap
8
+ * between events, so a burst looks like a burst regardless of when the session
9
+ * started.
10
+ */
11
+ import type { BurnEvent, SessionState } from '../types';
12
+ export declare function newSessionState(sessionId: string, firstEventAt: number): SessionState;
13
+ /** Apply one event. Returns the active minutes that elapsed for debt accounting. */
14
+ export declare function applyEvent(state: SessionState, event: BurnEvent): number;
15
+ /** Sum of a rolling window over the last N active minutes. */
16
+ export declare function windowSum(byMinute: Map<number, number>, activeMinutes: number, windowMinutes: number): number;
17
+ /** Median of completed, nonzero active minutes. The current partial minute is excluded. */
18
+ export declare function medianCompletedMinute(state: SessionState): number;
19
+ export declare function cacheReadRatio(state: SessionState): number;