@agentguard-run/burn 0.2.3 → 0.2.6

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 (54) hide show
  1. package/CHANGELOG.md +30 -2
  2. package/README.md +105 -20
  3. package/dist/src/adapters/codex.js +11 -1
  4. package/dist/src/adapters/cursor.js +2 -2
  5. package/dist/src/calibrate.js +2 -3
  6. package/dist/src/cli.js +53 -15
  7. package/dist/src/conformance.d.ts +5 -2
  8. package/dist/src/conformance.js +30 -17
  9. package/dist/src/defaults.d.ts +7 -4
  10. package/dist/src/defaults.js +9 -6
  11. package/dist/src/detectors/evaluate.d.ts +4 -5
  12. package/dist/src/detectors/evaluate.js +13 -11
  13. package/dist/src/eligibility.d.ts +17 -0
  14. package/dist/src/eligibility.js +29 -0
  15. package/dist/src/gateway.d.ts +2 -0
  16. package/dist/src/gateway.js +4 -8
  17. package/dist/src/history/claude-transcript.d.ts +20 -2
  18. package/dist/src/history/claude-transcript.js +56 -15
  19. package/dist/src/hook/pre-tool-use.d.ts +13 -9
  20. package/dist/src/hook/pre-tool-use.js +63 -31
  21. package/dist/src/insights/attribution.d.ts +4 -0
  22. package/dist/src/insights/attribution.js +151 -0
  23. package/dist/src/insights/blocks.d.ts +61 -0
  24. package/dist/src/insights/blocks.js +243 -0
  25. package/dist/src/insights/live.d.ts +53 -0
  26. package/dist/src/insights/live.js +211 -0
  27. package/dist/src/insights/pace.d.ts +34 -0
  28. package/dist/src/insights/pace.js +54 -0
  29. package/dist/src/insights/pricing.d.ts +48 -0
  30. package/dist/src/insights/pricing.js +139 -0
  31. package/dist/src/insights/render.d.ts +8 -0
  32. package/dist/src/insights/render.js +126 -0
  33. package/dist/src/insights/sessions.d.ts +12 -0
  34. package/dist/src/insights/sessions.js +51 -0
  35. package/dist/src/insights/transcript.d.ts +14 -0
  36. package/dist/src/insights/transcript.js +505 -0
  37. package/dist/src/insights/types.d.ts +164 -0
  38. package/dist/src/insights/types.js +4 -0
  39. package/dist/src/install.js +14 -5
  40. package/dist/src/policy.d.ts +4 -0
  41. package/dist/src/policy.js +57 -0
  42. package/dist/src/replay/render.js +4 -2
  43. package/dist/src/replay/simulate.d.ts +5 -0
  44. package/dist/src/replay/simulate.js +23 -8
  45. package/dist/src/state/reservations.d.ts +7 -5
  46. package/dist/src/state/reservations.js +60 -45
  47. package/dist/src/state/spawn-window.d.ts +10 -0
  48. package/dist/src/state/spawn-window.js +25 -0
  49. package/dist/src/types.d.ts +8 -1
  50. package/docs/USAGE_AND_PRICING.md +132 -0
  51. package/fixtures/usage-dedup-session/subagents/agent-synthetic-first.jsonl +5 -0
  52. package/fixtures/usage-dedup-session/subagents/agent-synthetic-second.jsonl +4 -0
  53. package/fixtures/usage-dedup-session.jsonl +4 -0
  54. package/package.json +4 -3
@@ -0,0 +1,164 @@
1
+ /** Content-free transcript usage. No prompt, output text, path, or tool input. */
2
+ export type InsightHost = 'claude' | 'codex' | 'unknown';
3
+ export type TokenCategory = 'input' | 'cacheCreation' | 'cacheRead' | 'output';
4
+ export type InsightBucket = 'instruction_stack' | 'history_resent' | 'reread_files' | 'subagent_fanout' | 'tool_output' | 'conversation' | 'full_prefix_rewrites' | 'output' | 'unattributed';
5
+ export declare const INSIGHT_BUCKETS: InsightBucket[];
6
+ export interface ExplicitTokenAttribution {
7
+ bucket: InsightBucket;
8
+ category: TokenCategory;
9
+ tokens: number;
10
+ }
11
+ /** Bytes divide a measured usage increment; they are never converted to tokens. */
12
+ export interface InsightInterval {
13
+ toolOutputBytes: number;
14
+ reReadBytes: number;
15
+ conversationBytes: number;
16
+ toolResults: number;
17
+ reReadResults?: number;
18
+ userMessages: number;
19
+ shared: boolean;
20
+ }
21
+ export interface InsightTurn {
22
+ id: string;
23
+ sessionId: string;
24
+ host: InsightHost;
25
+ subagent?: boolean;
26
+ at?: number;
27
+ model?: string;
28
+ /** Fresh input only. Codex cached input is subtracted from its inclusive input. */
29
+ inputTokens: number;
30
+ cacheWriteTokens: number;
31
+ cacheReadTokens: number;
32
+ outputTokens: number;
33
+ cacheWrite5mTokens?: number;
34
+ cacheWrite1hTokens?: number;
35
+ cacheTtlSeconds?: 300 | 3600;
36
+ cacheTtlSource?: 'usage_split' | 'explicit_metadata';
37
+ /** Actual input context, never the model's advertised maximum window. */
38
+ contextTokens: number;
39
+ signals: {
40
+ idleMs?: number;
41
+ afterCompaction: boolean;
42
+ firstSpawnedTurn: boolean;
43
+ prefixChanged?: boolean;
44
+ };
45
+ explicitAttribution: ExplicitTokenAttribution[];
46
+ uncertainties: string[];
47
+ /** Input events received since the previous distinct assistant response. */
48
+ interval?: InsightInterval;
49
+ }
50
+ export interface InsightDiagnostics {
51
+ lines: number;
52
+ malformedLines: number;
53
+ duplicateUsageRecords: number;
54
+ unsupportedUsageRecords: number;
55
+ inheritedUsageRecords?: number;
56
+ }
57
+ export interface InsightParserState {
58
+ host: InsightHost;
59
+ sessionId: string;
60
+ model?: string;
61
+ lastAt?: number;
62
+ compactionPending: boolean;
63
+ spawnedSession: boolean;
64
+ seen: Record<string, InsightTurn>;
65
+ /** Codex token_count reports both cumulative totals and last response usage. */
66
+ codexTotals?: {
67
+ input: number;
68
+ cached: number;
69
+ output: number;
70
+ };
71
+ prefixDigest?: string;
72
+ prefixChangedPending?: boolean;
73
+ recordsSeen?: number;
74
+ codexEpoch?: number;
75
+ observedCalls?: Record<string, {
76
+ kind: 'read' | 'spawn' | 'tool_result';
77
+ fileKey?: string;
78
+ }>;
79
+ sessionMetadataSeen?: boolean;
80
+ /** Host-recorded lineage identifiers only, never inherited prompt content. */
81
+ lineage?: {
82
+ sessionId?: string;
83
+ parentSessionId?: string;
84
+ forkContextRef?: boolean;
85
+ forked?: boolean;
86
+ };
87
+ inheritedBeforeOrdinal?: number;
88
+ forkHistoryBoundaryUnknown?: boolean;
89
+ inheritedTotalDigest?: string;
90
+ codexTurnIdDigest?: string;
91
+ forkThreadIdDigest?: string;
92
+ awaitingForkBoundary?: boolean;
93
+ intervalStateVersion?: 1;
94
+ pendingInterval?: InsightInterval;
95
+ inputEventsSeen?: Record<string, true>;
96
+ readPathsSeen?: Record<string, true>;
97
+ readToolCalls?: Record<string, {
98
+ fileKey: string;
99
+ repeated: boolean;
100
+ }>;
101
+ conversationEcho?: {
102
+ digest: string;
103
+ source: 'response' | 'event';
104
+ };
105
+ }
106
+ export interface InsightObservations {
107
+ readCalls: number;
108
+ repeatedReadCalls: number;
109
+ uniqueReadFiles: number;
110
+ spawnCalls: number;
111
+ toolResultRecords: number;
112
+ }
113
+ export interface InsightTranscript {
114
+ host: InsightHost;
115
+ sessionId: string;
116
+ turns: InsightTurn[];
117
+ /** New or amended turns, suitable for an incremental observer keyed by id. */
118
+ updatedTurns: InsightTurn[];
119
+ diagnostics: InsightDiagnostics;
120
+ state: InsightParserState;
121
+ observations: InsightObservations;
122
+ /** Zero-based line indexes in this parsed chunk, after fork ownership checks. */
123
+ acceptedQuotaLineNumbers?: number[];
124
+ }
125
+ export interface InsightParseOptions {
126
+ host?: InsightHost;
127
+ sessionId?: string;
128
+ state?: InsightParserState;
129
+ spawnedSession?: boolean;
130
+ }
131
+ export type RewriteCause = 'idle_ttl_expired' | 'compaction' | 'first_spawned_turn' | 'prefix_change' | 'unknown_ttl';
132
+ export interface RewriteEvidence {
133
+ turnId: string;
134
+ cause: RewriteCause;
135
+ cacheWriteTokens: number;
136
+ contextTokens: number;
137
+ idleMs?: number;
138
+ idleOver60Minutes: boolean;
139
+ ttlSeconds?: 300 | 3600;
140
+ ttlUnknown: boolean;
141
+ explanation: string;
142
+ }
143
+ export interface BucketAttribution {
144
+ bucket: InsightBucket;
145
+ tokens: number;
146
+ categories: Record<TokenCategory, number>;
147
+ }
148
+ export interface AttributionSummary {
149
+ buckets: BucketAttribution[];
150
+ totalTokens: number;
151
+ rewrites: RewriteEvidence[];
152
+ rewriteCounts: Record<RewriteCause, number>;
153
+ idleOver60MinuteRewrites: number;
154
+ uncertainties: string[];
155
+ turnAttributions: Array<{
156
+ turnId: string;
157
+ sessionId: string;
158
+ buckets: BucketAttribution[];
159
+ totalTokens: number;
160
+ }>;
161
+ sharedTurns: number;
162
+ prefixRebaselines: number;
163
+ previousOutputTokensReserved: number;
164
+ }
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.INSIGHT_BUCKETS = void 0;
4
+ exports.INSIGHT_BUCKETS = ['instruction_stack', 'history_resent', 'reread_files', 'subagent_fanout', 'tool_output', 'conversation', 'full_prefix_rewrites', 'output', 'unattributed'];
@@ -48,7 +48,16 @@ function mergeMatcherStyle(cfg, event, matcher, command, timeout) {
48
48
  const list = (hooks[event] ??= []);
49
49
  let changed = false;
50
50
  let found = false;
51
- for (const m of list) {
51
+ for (const m of [...list]) {
52
+ if (m.matcher !== matcher && m.hooks?.some(h => typeof h.command === 'string' && MARK.test(h.command)) &&
53
+ m.hooks.some(h => typeof h.command !== 'string' || !MARK.test(h.command))) {
54
+ const mine = m.hooks.filter(h => typeof h.command === 'string' && MARK.test(h.command));
55
+ m.hooks = m.hooks.filter(h => !mine.includes(h));
56
+ list.push({ matcher, hooks: mine.map(h => ({ ...h, command, timeout })) });
57
+ found = true;
58
+ changed = true;
59
+ continue;
60
+ }
52
61
  for (const h of m.hooks ?? []) {
53
62
  if (typeof h.command === 'string' && MARK.test(h.command)) {
54
63
  found = true;
@@ -76,8 +85,8 @@ function mergeCursor(cfg, command) {
76
85
  }
77
86
  const hooks = (cfg.hooks ??= {});
78
87
  const want = {
79
- subagentStart: { command, timeout: 5, failClosed: true },
80
- subagentStop: { command, timeout: 5 },
88
+ subagentStart: { command, timeout: 15, failClosed: true },
89
+ subagentStop: { command, timeout: 15 },
81
90
  sessionEnd: { command, timeout: 3 },
82
91
  };
83
92
  for (const [event, entry] of Object.entries(want)) {
@@ -101,11 +110,11 @@ function install(host, cliPath, home = (0, node_os_1.homedir)()) {
101
110
  let changed;
102
111
  if (host === 'claude') {
103
112
  command = `node ${cliPath} hook`;
104
- changed = mergeMatcherStyle(cfg, 'PreToolUse', '^(Agent|Task)$', command, 5);
113
+ changed = mergeMatcherStyle(cfg, 'PreToolUse', '.*', command, 15);
105
114
  }
106
115
  else if (host === 'codex') {
107
116
  command = `node ${cliPath} codex-hook`;
108
- changed = mergeMatcherStyle(cfg, 'PreToolUse', '^(Agent|spawn_agent)$', command, 5);
117
+ changed = mergeMatcherStyle(cfg, 'PreToolUse', '.*', command, 15);
109
118
  }
110
119
  else {
111
120
  command = `node ${cliPath} cursor-hook`;
@@ -0,0 +1,4 @@
1
+ import type { Policy } from './types';
2
+ /** Normalize before evaluating or hashing so receipts bind the actual policy. */
3
+ export declare function normalizePolicy(policy: Policy): Policy;
4
+ export declare function loadPolicy(home: string): Policy;
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizePolicy = normalizePolicy;
4
+ exports.loadPolicy = loadPolicy;
5
+ /** Load additive policy defaults without rewriting the operator's policy. */
6
+ const node_fs_1 = require("node:fs");
7
+ const node_path_1 = require("node:path");
8
+ const defaults_1 = require("./defaults");
9
+ const noticed = new Set();
10
+ /** Normalize before evaluating or hashing so receipts bind the actual policy. */
11
+ function normalizePolicy(policy) {
12
+ const thresholds = policy.thresholds;
13
+ const normalized = {
14
+ fanout: { ...defaults_1.DEFAULT_THRESHOLDS.fanout, ...thresholds.fanout },
15
+ sustained: { ...defaults_1.DEFAULT_THRESHOLDS.sustained, ...thresholds.sustained },
16
+ burnDebt: { ...defaults_1.DEFAULT_THRESHOLDS.burnDebt, ...thresholds.burnDebt },
17
+ spawnRate: { ...defaults_1.DEFAULT_THRESHOLDS.spawnRate, ...thresholds.spawnRate },
18
+ duplicate: { ...defaults_1.DEFAULT_THRESHOLDS.duplicate, ...thresholds.duplicate },
19
+ account: { ...defaults_1.DEFAULT_THRESHOLDS.account, ...thresholds.account },
20
+ localCompute: { ...defaults_1.DEFAULT_THRESHOLDS.localCompute, ...thresholds.localCompute },
21
+ };
22
+ return { ...policy, thresholds: normalized };
23
+ }
24
+ function noticeOnce(home, fields) {
25
+ if (noticed.has(home))
26
+ return;
27
+ noticed.add(home);
28
+ try {
29
+ (0, node_fs_1.mkdirSync)(home, { recursive: true, mode: 0o700 });
30
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(home, '.burn-policy-0.2.6-notice'), fields.join(', ') + '\n', { flag: 'wx', mode: 0o600 });
31
+ }
32
+ catch (error) {
33
+ if (error.code === 'EEXIST')
34
+ return;
35
+ // A read-only home still gets the notice once in this process.
36
+ }
37
+ process.stderr.write(`AgentGuard loaded missing policy fields from defaults: ${fields.join(', ')}; existing overrides are unchanged.\n`);
38
+ }
39
+ function loadPolicy(home) {
40
+ try {
41
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(home, 'burn-policy.json'), 'utf8'));
42
+ if (parsed && (parsed.mode === 'shadow' || parsed.mode === 'enforce') && parsed.thresholds) {
43
+ const missing = [];
44
+ if (parsed.thresholds.fanout?.windowActiveMinutes === undefined)
45
+ missing.push('fanout.windowActiveMinutes=120');
46
+ if (parsed.thresholds.spawnRate?.enforce === undefined)
47
+ missing.push('spawnRate.enforce=true');
48
+ if (missing.length)
49
+ noticeOnce(home, missing);
50
+ return normalizePolicy(parsed);
51
+ }
52
+ }
53
+ catch {
54
+ /* no policy yet: shadow defaults */
55
+ }
56
+ return normalizePolicy(defaults_1.DEFAULT_POLICY);
57
+ }
@@ -150,13 +150,15 @@ function renderReplay(summary, opts = {}) {
150
150
  paint(on, C.dim, ` across ${summary.sessions.length} sessions, ${summary.totalSpawns} spawns`)));
151
151
  out.push(MID);
152
152
  for (const s of summary.sessions.slice(0, top)) {
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');
153
+ const tag = s.fanoutStop ? paint(on, C.red, 'FAN-OUT STOP') : s.sustainedStop ? paint(on, C.red, 'SUSTAINED STOP') : s.spawnRateStop ? paint(on, C.red, 'SPAWN-RATE STOP') : s.firstWarn ? paint(on, C.yellow, 'WARN') : paint(on, C.green, 'clean');
154
154
  out.push(row(`${sparkline(s.curve, s.stopAtIndex, on)} ${tag}`));
155
155
  const detail = s.fanoutStop
156
156
  ? `before spawn ${s.fanoutStop.atSpawn}, tail ${(0, evaluate_1.fmt)(s.catchableTail)}`
157
157
  : s.sustainedStop
158
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`;
159
+ : s.spawnRateStop
160
+ ? `before spawn ${s.spawnRateStop.atSpawn}, tail ${(0, evaluate_1.fmt)(s.catchableTail)}`
161
+ : `${(0, evaluate_1.fmt)(s.totalTokens)} · ${s.spawns} spawns`;
160
162
  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}`)));
161
163
  }
162
164
  if (summary.sessions.length > top) {
@@ -30,6 +30,11 @@ export interface SessionReplay {
30
30
  atSpawn: number;
31
31
  tokensAtStop: number;
32
32
  } | null;
33
+ /** Additive boundary for policies that enforce short-window spawn rate. */
34
+ spawnRateStop?: {
35
+ atSpawn: number;
36
+ tokensAtStop: number;
37
+ } | null;
33
38
  /** Tokens observed after the earliest STOP boundary. */
34
39
  catchableTail: number;
35
40
  firstWarn: {
@@ -58,6 +58,7 @@ function replayEvents(sessionId, path, events, thresholds) {
58
58
  const state = (0, session_1.newSessionState)(sessionId, first.at);
59
59
  let fanoutStop = null;
60
60
  let sustainedStop = null;
61
+ let spawnRateStop = null;
61
62
  let firstWarn = null;
62
63
  let tokensAtEarliestStop = null;
63
64
  // Cumulative tokens after every event, for the sparkline.
@@ -66,25 +67,38 @@ function replayEvents(sessionId, path, events, thresholds) {
66
67
  for (const event of events) {
67
68
  // A hook boundary exists only where a spawn was attempted. Evaluate the
68
69
  // *proposal* before applying the event, then apply it.
70
+ const candidateState = event.spawns.length > 0
71
+ ? { ...state, spawnsByActiveMinute: new Map(state.spawnsByActiveMinute) }
72
+ : state;
73
+ if (event.spawns.length > 0) {
74
+ // One assistant row can propose several agents. Advance its active-time
75
+ // clock once, then account for each earlier proposal in this row. Usage
76
+ // and the durable state are still folded only once below.
77
+ (0, session_1.applyEvent)(candidateState, { at: event.at, tokens: 0, cacheRead: 0, spawns: [], surfaces: [], sidechain: event.sidechain });
78
+ }
69
79
  for (const spawn of event.spawns) {
70
- const report = (0, evaluate_1.evaluate)(state, thresholds, spawn.issuerDepth + 1);
80
+ const report = (0, evaluate_1.evaluate)(candidateState, thresholds, spawn.issuerDepth + 1);
71
81
  if (!firstWarn && report.verdict !== 'OK') {
72
- firstWarn = { detector: report.findings[0]?.detector ?? 'unknown', tokensAt: state.totalTokens, spawnsAt: state.spawnCount + 1 };
82
+ firstWarn = { detector: report.findings[0]?.detector ?? 'unknown', tokensAt: state.totalTokens, spawnsAt: candidateState.spawnCount + 1 };
73
83
  }
74
84
  for (const finding of report.findings) {
75
85
  if (finding.verdict !== 'STOP')
76
86
  continue;
77
87
  if (finding.detector === 'fanout' && !fanoutStop) {
78
- fanoutStop = { atSpawn: state.spawnCount + 1, tokensAtStop: state.totalTokens };
88
+ fanoutStop = { atSpawn: candidateState.spawnCount + 1, tokensAtStop: state.totalTokens };
89
+ }
90
+ if (finding.detector === 'spawn_rate' && !spawnRateStop) {
91
+ spawnRateStop = { atSpawn: candidateState.spawnCount + 1, tokensAtStop: state.totalTokens };
79
92
  }
80
93
  if ((finding.detector === 'sustained_burn' || finding.detector === 'burn_debt') && !sustainedStop) {
81
- sustainedStop = { atSpawn: state.spawnCount + 1, tokensAtStop: state.totalTokens };
94
+ sustainedStop = { atSpawn: candidateState.spawnCount + 1, tokensAtStop: state.totalTokens };
82
95
  }
83
96
  }
84
- if ((fanoutStop || sustainedStop) && tokensAtEarliestStop === null) {
97
+ if ((fanoutStop || sustainedStop || spawnRateStop) && tokensAtEarliestStop === null) {
85
98
  tokensAtEarliestStop = state.totalTokens;
86
99
  stopEventIndex = timeline.length;
87
100
  }
101
+ (0, session_1.applyEvent)(candidateState, { at: event.at, tokens: 0, cacheRead: 0, spawns: [spawn], surfaces: [], sidechain: event.sidechain });
88
102
  }
89
103
  // The sustained plane also has a boundary at every tool call, not just
90
104
  // spawns. Approximate: any event with surfaces is a tool boundary.
@@ -118,6 +132,7 @@ function replayEvents(sessionId, path, events, thresholds) {
118
132
  finalVerdict: final.verdict,
119
133
  fanoutStop,
120
134
  sustainedStop,
135
+ spawnRateStop,
121
136
  catchableTail: tokensAtEarliestStop === null ? 0 : Math.max(0, state.totalTokens - tokensAtEarliestStop),
122
137
  firstWarn,
123
138
  curve,
@@ -160,8 +175,8 @@ function replayAll(paths, thresholds, minTokens = 0) {
160
175
  totalSpawns: sessions.reduce((s, r) => s + r.spawns, 0),
161
176
  catchableTail,
162
177
  catchableShare: totalTokens > 0 ? catchableTail / totalTokens : 0,
163
- stops: sessions.filter((r) => r.fanoutStop || r.sustainedStop).length,
164
- warns: sessions.filter((r) => !r.fanoutStop && !r.sustainedStop && r.firstWarn).length,
165
- clean: sessions.filter((r) => !r.fanoutStop && !r.sustainedStop && !r.firstWarn).length,
178
+ stops: sessions.filter((r) => r.fanoutStop || r.sustainedStop || r.spawnRateStop).length,
179
+ warns: sessions.filter((r) => !r.fanoutStop && !r.sustainedStop && !r.spawnRateStop && r.firstWarn).length,
180
+ clean: sessions.filter((r) => !r.fanoutStop && !r.sustainedStop && !r.spawnRateStop && !r.firstWarn).length,
166
181
  };
167
182
  }
@@ -83,11 +83,11 @@ export declare class ReservationStore {
83
83
  /** The holder's own instance is still the one on the path. */
84
84
  private fence;
85
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.
86
+ * Serialize retirement inside the existing lock before checking its owner.
87
+ * A delayed observer must never temporarily rename a newer live lock: even
88
+ * restoring it can trip that holder's final fence after audit rows were
89
+ * appended. Every release/recovery uses this claim, so the nonce is stable
90
+ * from this check through the atomic rename. Delete only the retired path.
91
91
  */
92
92
  private discard;
93
93
  private trace;
@@ -152,6 +152,8 @@ export declare class Transaction {
152
152
  private readonly data;
153
153
  dirty: boolean;
154
154
  constructor(data: ReservationFile);
155
+ /** Pending proposals participate in every spawn window under the same lock. */
156
+ pendingSpawns(sessionId: string, toolUseId: string, now: number): number;
155
157
  reserve(args: ReserveArgs): ReserveResult;
156
158
  reconcile(sessionId: string, observedSpawns: number, previouslyObserved: number): void;
157
159
  /**
@@ -25,9 +25,9 @@
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
26
  exports.Transaction = exports.ReservationStore = exports.RESERVATION_TTL_MS = void 0;
27
27
  const node_fs_1 = require("node:fs");
28
+ const node_crypto_1 = require("node:crypto");
28
29
  const node_path_1 = require("node:path");
29
- const LOCK_STALE_MS = 10_000;
30
- const LOCK_WAIT_MS = 3_000;
30
+ const LOCK_WAIT_MS = 8_000;
31
31
  const LOCK_SPIN_MS = 15;
32
32
  exports.RESERVATION_TTL_MS = 90_000;
33
33
  function pidAlive(pid) {
@@ -118,42 +118,63 @@ class ReservationStore {
118
118
  }
119
119
  }
120
120
  /**
121
- * Take the lock directory off its path atomically, then verify it is the
122
- * instance we meant. rmSync on the live path is readdir + unlink + rmdir,
123
- * and a sibling can mkdir the same path between those steps, so removal
124
- * is always rename-then-delete. If the instance we grabbed is not the one
125
- * we judged (`expect`), it is a live sibling's: put it back.
121
+ * Serialize retirement inside the existing lock before checking its owner.
122
+ * A delayed observer must never temporarily rename a newer live lock: even
123
+ * restoring it can trip that holder's final fence after audit rows were
124
+ * appended. Every release/recovery uses this claim, so the nonce is stable
125
+ * from this check through the atomic rename. Delete only the retired path.
126
126
  */
127
127
  discard(reason, expect) {
128
- const quarantine = `${this.lockDir}.${reason}.${process.pid}.${Date.now().toString(36)}`;
129
- try {
130
- (0, node_fs_1.renameSync)(this.lockDir, quarantine);
131
- }
132
- catch {
133
- return false; // somebody else already took it off the path
134
- }
135
- const got = this.readOwnerAt(quarantine);
136
- if ((got?.nonce ?? null) !== expect) {
137
- // Not the instance we judged. Give it back; if a waiter slipped into
138
- // the freed path in between, the displaced holder's fence throws and
139
- // its transaction is discarded, so nothing double-commits.
128
+ const claim = (0, node_path_1.join)(this.lockDir, 'retiring');
129
+ const deadline = Date.now() + LOCK_WAIT_MS;
130
+ for (;;) {
140
131
  try {
141
- (0, node_fs_1.renameSync)(quarantine, this.lockDir);
142
- this.trace(`discard ${reason}: wrong instance, returned`);
143
- return false;
132
+ (0, node_fs_1.mkdirSync)(claim, { mode: 0o700 });
133
+ break;
144
134
  }
145
- catch {
146
- this.trace(`discard ${reason}: wrong instance, could not return`);
135
+ catch (error) {
136
+ if (error.code !== 'EEXIST' || reason !== 'released' || Date.now() >= deadline)
137
+ return false;
138
+ // A stale observer may briefly claim a newer live instance, notice the
139
+ // nonce mismatch, and leave it untouched. Let its owner release next.
140
+ sleepSync(LOCK_SPIN_MS);
147
141
  }
148
142
  }
143
+ let retired = false;
149
144
  try {
150
- (0, node_fs_1.rmSync)(quarantine, { recursive: true, force: true });
145
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(claim, 'owner'), JSON.stringify({ pid: process.pid, since: Date.now(), nonce: (0, node_crypto_1.randomUUID)(), lockNonce: expect }), { mode: 0o600 });
146
+ const got = this.readOwnerAt(this.lockDir);
147
+ if ((got?.nonce ?? null) !== expect) {
148
+ this.trace(`discard ${reason}: wrong instance, untouched`);
149
+ return false;
150
+ }
151
+ if (reason === 'stale' && got && pidAlive(got.pid))
152
+ return false;
153
+ const quarantine = `${this.lockDir}.${reason}.${process.pid}.${(0, node_crypto_1.randomUUID)()}`;
154
+ (0, node_fs_1.renameSync)(this.lockDir, quarantine);
155
+ retired = true;
156
+ try {
157
+ (0, node_fs_1.rmSync)(quarantine, { recursive: true, force: true });
158
+ }
159
+ catch { /* Never remove a replacement live lock. */ }
160
+ this.trace(`discard ${reason} ok`);
161
+ return true;
151
162
  }
152
- catch {
153
- /* late write by the old holder; unique name, harmless */
163
+ finally {
164
+ // If the process dies while holding this tiny retirement claim, later
165
+ // contenders fail closed. Never guess that a claim is abandoned and
166
+ // risk removing another process's active coordination primitive.
167
+ if (!retired) {
168
+ try {
169
+ (0, node_fs_1.unlinkSync)((0, node_path_1.join)(claim, 'owner'));
170
+ }
171
+ catch { /* A missing owner record is harmless while this process holds the claim. */ }
172
+ try {
173
+ (0, node_fs_1.rmdirSync)(claim);
174
+ }
175
+ catch { /* Conservative failure; future callers fail closed. */ }
176
+ }
154
177
  }
155
- this.trace(`discard ${reason} ok`);
156
- return true;
157
178
  }
158
179
  trace(line) {
159
180
  if (!process.env.AGENTGUARD_DEBUG_LOCK)
@@ -168,27 +189,17 @@ class ReservationStore {
168
189
  recoverIfStale() {
169
190
  const owner = this.readOwnerAt(this.lockDir);
170
191
  if (owner) {
171
- const stale = !pidAlive(owner.pid) || Date.now() - owner.since > LOCK_STALE_MS;
192
+ const stale = !pidAlive(owner.pid);
172
193
  if (stale) {
173
194
  this.trace(`reclaim: owner ${owner.pid} alive=${pidAlive(owner.pid)} age=${Date.now() - owner.since}ms`);
174
195
  this.discard('stale', owner.nonce);
175
196
  }
176
197
  return;
177
198
  }
178
- // No trustworthy owner record: the holder is between mkdir and rename, or
179
- // it died in that gap. Only the directory's age can tell those apart.
180
- // Reclaiming immediately here was the race that let a 41st spawn through
181
- // under 60 concurrent processes.
182
- try {
183
- const age = Date.now() - (0, node_fs_1.statSync)(this.lockDir).mtimeMs;
184
- if (age > LOCK_STALE_MS) {
185
- this.trace(`reclaim ownerless dir age=${age}ms`);
186
- this.discard('orphan', null);
187
- }
188
- }
189
- catch {
190
- /* directory vanished between checks: the next mkdir attempt decides */
191
- }
199
+ // An ownerless directory may belong to a creator paused between mkdir and
200
+ // publishing its owner record. Age cannot prove that creator is dead, and
201
+ // publication does not take the retirement claim. Never reclaim this gap
202
+ // automatically: an abandoned ownerless lock requires manual recovery.
192
203
  }
193
204
  release() {
194
205
  // Only our own instance is released. If it was reclaimed and a sibling
@@ -299,6 +310,10 @@ class Transaction {
299
310
  if (!this.data.calls)
300
311
  this.data.calls = [];
301
312
  }
313
+ /** Pending proposals participate in every spawn window under the same lock. */
314
+ pendingSpawns(sessionId, toolUseId, now) {
315
+ return this.data.reservations.filter(r => r.sessionId === sessionId && r.toolUseId !== toolUseId && r.expiresAt > now).length;
316
+ }
302
317
  reserve(args) {
303
318
  const now = args.now ?? Date.now();
304
319
  const data = this.data;
@@ -307,7 +322,7 @@ class Transaction {
307
322
  if (data.reservations.length !== before)
308
323
  this.dirty = true;
309
324
  // Idempotent: the same tool_use_id evaluated twice must not double-count.
310
- const existing = data.reservations.find((r) => r.toolUseId === args.toolUseId);
325
+ const existing = data.reservations.find((r) => r.sessionId === args.sessionId && r.toolUseId === args.toolUseId);
311
326
  const pendingForSession = data.reservations.filter((r) => r.sessionId === args.sessionId && r.toolUseId !== args.toolUseId).length;
312
327
  const effective = args.observedSpawns + pendingForSession + 1;
313
328
  if (existing) {
@@ -0,0 +1,10 @@
1
+ import type { BurnReport, SessionState, Thresholds } from '../types';
2
+ import type { ReserveResult, Transaction } from './reservations';
3
+ /** Evaluate and reserve together. Pending forks must not bypass either window. */
4
+ export declare function evaluateSpawnReservation(tx: Transaction, state: SessionState, thresholds: Thresholds, proposedDepth: number, toolUseId: string, now: number, account?: {
5
+ sessions: SessionState[];
6
+ now: number;
7
+ }): {
8
+ report: BurnReport;
9
+ reservation: ReserveResult;
10
+ };
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.evaluateSpawnReservation = evaluateSpawnReservation;
4
+ const evaluate_1 = require("../detectors/evaluate");
5
+ const defaults_1 = require("../defaults");
6
+ const session_1 = require("./session");
7
+ /** Evaluate and reserve together. Pending forks must not bypass either window. */
8
+ function evaluateSpawnReservation(tx, state, thresholds, proposedDepth, toolUseId, now, account) {
9
+ const pending = tx.pendingSpawns(state.sessionId, toolUseId, now);
10
+ const minute = Math.floor(state.activeMinutes);
11
+ const spawns = new Map(state.spawnsByActiveMinute);
12
+ if (pending)
13
+ spawns.set(minute, (spawns.get(minute) ?? 0) + pending);
14
+ const candidateState = { ...state, spawnCount: state.spawnCount + pending, spawnsByActiveMinute: spawns };
15
+ const report = (0, evaluate_1.evaluate)(candidateState, thresholds, proposedDepth, account);
16
+ // Receipts retain the lifetime ordinal, while admission uses the active window.
17
+ const effectiveSpawns = state.spawnCount + pending + 1;
18
+ if (report.verdict === 'STOP') {
19
+ // A refused proposal never consumes a reservation. Its detector explains why.
20
+ return { report, reservation: { allowed: true, effectiveSpawns, pending } };
21
+ }
22
+ const recent = (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, thresholds.fanout.windowActiveMinutes ?? defaults_1.DEFAULT_THRESHOLDS.fanout.windowActiveMinutes);
23
+ const reservation = tx.reserve({ sessionId: state.sessionId, toolUseId, observedSpawns: recent, ceiling: thresholds.fanout.stop, now });
24
+ return { report, reservation: { ...reservation, effectiveSpawns } };
25
+ }
@@ -83,7 +83,8 @@ export interface Thresholds {
83
83
  fanout: {
84
84
  warn: number;
85
85
  stop: number;
86
- maxDepth: number;
86
+ maxDepth: number; /** Older policies use 120 active minutes. */
87
+ windowActiveMinutes?: number;
87
88
  };
88
89
  sustained: {
89
90
  warnTokens: number;
@@ -132,6 +133,12 @@ export type Mode = 'shadow' | 'enforce';
132
133
  export interface Policy {
133
134
  mode: Mode;
134
135
  thresholds: Thresholds;
136
+ /** Additive local usage advisories. Older policy files use these defaults. */
137
+ insights?: {
138
+ rewriteWarnDollarsPerHour?: number;
139
+ heavyTurnTokens?: number;
140
+ pricingFile?: string;
141
+ };
135
142
  /** Calibration provenance, so a report can say where its numbers came from. */
136
143
  calibration?: {
137
144
  sessionsSampled: number;