@agentguard-run/burn 0.2.2 → 0.2.5

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 (43) hide show
  1. package/CHANGELOG.md +39 -2
  2. package/LICENSE +9 -10
  3. package/README.md +96 -16
  4. package/dist/src/adapters/codex.js +11 -1
  5. package/dist/src/adapters/cursor.js +2 -2
  6. package/dist/src/cli.js +40 -13
  7. package/dist/src/conformance.js +3 -1
  8. package/dist/src/defaults.d.ts +9 -8
  9. package/dist/src/defaults.js +9 -8
  10. package/dist/src/detectors/evaluate.d.ts +5 -2
  11. package/dist/src/detectors/evaluate.js +26 -2
  12. package/dist/src/eligibility.d.ts +17 -0
  13. package/dist/src/eligibility.js +29 -0
  14. package/dist/src/gateway.d.ts +2 -0
  15. package/dist/src/gateway.js +6 -3
  16. package/dist/src/hook/pre-tool-use.d.ts +2 -0
  17. package/dist/src/hook/pre-tool-use.js +93 -63
  18. package/dist/src/insights/attribution.d.ts +4 -0
  19. package/dist/src/insights/attribution.js +151 -0
  20. package/dist/src/insights/live.d.ts +53 -0
  21. package/dist/src/insights/live.js +211 -0
  22. package/dist/src/insights/pace.d.ts +34 -0
  23. package/dist/src/insights/pace.js +54 -0
  24. package/dist/src/insights/pricing.d.ts +48 -0
  25. package/dist/src/insights/pricing.js +139 -0
  26. package/dist/src/insights/render.d.ts +8 -0
  27. package/dist/src/insights/render.js +126 -0
  28. package/dist/src/insights/sessions.d.ts +12 -0
  29. package/dist/src/insights/sessions.js +51 -0
  30. package/dist/src/insights/transcript.d.ts +12 -0
  31. package/dist/src/insights/transcript.js +492 -0
  32. package/dist/src/insights/types.d.ts +157 -0
  33. package/dist/src/insights/types.js +4 -0
  34. package/dist/src/install.js +14 -5
  35. package/dist/src/replay/render.js +1 -1
  36. package/dist/src/state/account.d.ts +3 -0
  37. package/dist/src/state/account.js +39 -0
  38. package/dist/src/state/reservations.d.ts +5 -5
  39. package/dist/src/state/reservations.js +60 -45
  40. package/dist/src/types.d.ts +6 -0
  41. package/docs/USAGE_AND_PRICING.md +132 -0
  42. package/fixtures/codex-0.151.0-pretooluse.json +11 -11
  43. package/package.json +4 -3
@@ -0,0 +1,157 @@
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
+ inheritedBeforeOrdinal?: number;
81
+ forkHistoryBoundaryUnknown?: boolean;
82
+ inheritedTotalDigest?: string;
83
+ codexTurnIdDigest?: string;
84
+ forkThreadIdDigest?: string;
85
+ awaitingForkBoundary?: boolean;
86
+ intervalStateVersion?: 1;
87
+ pendingInterval?: InsightInterval;
88
+ inputEventsSeen?: Record<string, true>;
89
+ readPathsSeen?: Record<string, true>;
90
+ readToolCalls?: Record<string, {
91
+ fileKey: string;
92
+ repeated: boolean;
93
+ }>;
94
+ conversationEcho?: {
95
+ digest: string;
96
+ source: 'response' | 'event';
97
+ };
98
+ }
99
+ export interface InsightObservations {
100
+ readCalls: number;
101
+ repeatedReadCalls: number;
102
+ uniqueReadFiles: number;
103
+ spawnCalls: number;
104
+ toolResultRecords: number;
105
+ }
106
+ export interface InsightTranscript {
107
+ host: InsightHost;
108
+ sessionId: string;
109
+ turns: InsightTurn[];
110
+ /** New or amended turns, suitable for an incremental observer keyed by id. */
111
+ updatedTurns: InsightTurn[];
112
+ diagnostics: InsightDiagnostics;
113
+ state: InsightParserState;
114
+ observations: InsightObservations;
115
+ /** Zero-based line indexes in this parsed chunk, after fork ownership checks. */
116
+ acceptedQuotaLineNumbers?: number[];
117
+ }
118
+ export interface InsightParseOptions {
119
+ host?: InsightHost;
120
+ sessionId?: string;
121
+ state?: InsightParserState;
122
+ spawnedSession?: boolean;
123
+ }
124
+ export type RewriteCause = 'idle_ttl_expired' | 'compaction' | 'first_spawned_turn' | 'prefix_change' | 'unknown_ttl';
125
+ export interface RewriteEvidence {
126
+ turnId: string;
127
+ cause: RewriteCause;
128
+ cacheWriteTokens: number;
129
+ contextTokens: number;
130
+ idleMs?: number;
131
+ idleOver60Minutes: boolean;
132
+ ttlSeconds?: 300 | 3600;
133
+ ttlUnknown: boolean;
134
+ explanation: string;
135
+ }
136
+ export interface BucketAttribution {
137
+ bucket: InsightBucket;
138
+ tokens: number;
139
+ categories: Record<TokenCategory, number>;
140
+ }
141
+ export interface AttributionSummary {
142
+ buckets: BucketAttribution[];
143
+ totalTokens: number;
144
+ rewrites: RewriteEvidence[];
145
+ rewriteCounts: Record<RewriteCause, number>;
146
+ idleOver60MinuteRewrites: number;
147
+ uncertainties: string[];
148
+ turnAttributions: Array<{
149
+ turnId: string;
150
+ sessionId: string;
151
+ buckets: BucketAttribution[];
152
+ totalTokens: number;
153
+ }>;
154
+ sharedTurns: number;
155
+ prefixRebaselines: number;
156
+ previousOutputTokensReserved: number;
157
+ }
@@ -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`;
@@ -160,7 +160,7 @@ function renderReplay(summary, opts = {}) {
160
160
  out.push(row(paint(on, C.dim, `${s.sessionId.slice(0, 8)} ${(0, evaluate_1.fmt)(s.totalTokens).padStart(6)} · ${String(s.spawns).padStart(3)} spawns ${detail}`)));
161
161
  }
162
162
  if (summary.sessions.length > top) {
163
- out.push(row(paint(on, C.dim, `… ${summary.sessions.length - top} more, all clean`)));
163
+ out.push(row(paint(on, C.dim, `… ${summary.sessions.length - top} more sessions (included in totals)`)));
164
164
  }
165
165
  out.push(MID);
166
166
  out.push(row(paint(on, C.dim, 'Upper bound; assumes no override or restart. Nothing left this machine.')));
@@ -0,0 +1,3 @@
1
+ import type { SessionState } from '../types';
2
+ export declare const ACTIVE_SESSION_WINDOW_MS: number;
3
+ export declare function readAccountSessions(home: string, current: SessionState, now: number): SessionState[];
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ACTIVE_SESSION_WINDOW_MS = void 0;
4
+ exports.readAccountSessions = readAccountSessions;
5
+ /** Read the same hook and gateway session files used by machine status. */
6
+ const node_fs_1 = require("node:fs");
7
+ const node_path_1 = require("node:path");
8
+ exports.ACTIVE_SESSION_WINDOW_MS = 30 * 60 * 1000;
9
+ function readAccountSessions(home, current, now) {
10
+ const sessions = new Map();
11
+ let names;
12
+ try {
13
+ names = (0, node_fs_1.readdirSync)((0, node_path_1.join)(home, 'sessions'));
14
+ }
15
+ catch {
16
+ names = [];
17
+ }
18
+ for (const name of names) {
19
+ if (!name.endsWith('.json'))
20
+ continue;
21
+ try {
22
+ const raw = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(home, 'sessions', name), 'utf8'));
23
+ if (!raw.state || (raw.closedAt !== undefined && raw.closedAt !== null))
24
+ continue;
25
+ const state = raw.state;
26
+ if (typeof state.sessionId !== 'string' || !Number.isFinite(state.lastEventAt))
27
+ continue;
28
+ const prior = sessions.get(state.sessionId);
29
+ if (!prior || state.lastEventAt > prior.lastEventAt) {
30
+ sessions.set(state.sessionId, { ...state, tokensByActiveMinute: new Map(state.tokensByActiveMinute) });
31
+ }
32
+ }
33
+ catch { /* Ignore malformed or foreign session files, as status does. */ }
34
+ }
35
+ // Include this admission, even when its transcript has no new usage yet.
36
+ // Replacing by ID prevents double counting the caller's persisted snapshot.
37
+ sessions.set(current.sessionId, { ...current, lastEventAt: now });
38
+ return [...sessions.values()];
39
+ }
@@ -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;
@@ -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) {
@@ -69,6 +69,7 @@ class ReservationStore {
69
69
  /** Acquire the lock or throw. Callers must fail closed on throw. */
70
70
  acquire() {
71
71
  const deadline = Date.now() + LOCK_WAIT_MS;
72
+ let attempt = 0;
72
73
  for (;;) {
73
74
  try {
74
75
  (0, node_fs_1.mkdirSync)(this.lockDir, { mode: 0o700 });
@@ -90,7 +91,10 @@ class ReservationStore {
90
91
  if (Date.now() > deadline) {
91
92
  throw new Error('AgentGuard could not acquire the reservation lock; failing closed.');
92
93
  }
93
- sleepSync(LOCK_SPIN_MS);
94
+ // Hundreds of waiters polling together can starve the holder. Spread
95
+ // retries with bounded backoff while retaining the existing deadline.
96
+ const backoff = Math.min(250, LOCK_SPIN_MS * 2 ** Math.min(attempt++, 4));
97
+ sleepSync(Math.min(Math.max(1, deadline - Date.now()), backoff * (0.5 + Math.random() / 2)));
94
98
  }
95
99
  }
96
100
  }
@@ -114,42 +118,63 @@ class ReservationStore {
114
118
  }
115
119
  }
116
120
  /**
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.
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.
122
126
  */
123
127
  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.
128
+ const claim = (0, node_path_1.join)(this.lockDir, 'retiring');
129
+ const deadline = Date.now() + LOCK_WAIT_MS;
130
+ for (;;) {
136
131
  try {
137
- (0, node_fs_1.renameSync)(quarantine, this.lockDir);
138
- this.trace(`discard ${reason}: wrong instance, returned`);
139
- return false;
132
+ (0, node_fs_1.mkdirSync)(claim, { mode: 0o700 });
133
+ break;
140
134
  }
141
- catch {
142
- 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);
143
141
  }
144
142
  }
143
+ let retired = false;
145
144
  try {
146
- (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;
147
162
  }
148
- catch {
149
- /* 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
+ }
150
177
  }
151
- this.trace(`discard ${reason} ok`);
152
- return true;
153
178
  }
154
179
  trace(line) {
155
180
  if (!process.env.AGENTGUARD_DEBUG_LOCK)
@@ -164,27 +189,17 @@ class ReservationStore {
164
189
  recoverIfStale() {
165
190
  const owner = this.readOwnerAt(this.lockDir);
166
191
  if (owner) {
167
- const stale = !pidAlive(owner.pid) || Date.now() - owner.since > LOCK_STALE_MS;
192
+ const stale = !pidAlive(owner.pid);
168
193
  if (stale) {
169
194
  this.trace(`reclaim: owner ${owner.pid} alive=${pidAlive(owner.pid)} age=${Date.now() - owner.since}ms`);
170
195
  this.discard('stale', owner.nonce);
171
196
  }
172
197
  return;
173
198
  }
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.
178
- try {
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
- }
184
- }
185
- catch {
186
- /* directory vanished between checks: the next mkdir attempt decides */
187
- }
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.
188
203
  }
189
204
  release() {
190
205
  // Only our own instance is released. If it was reclaimed and a sibling
@@ -132,6 +132,12 @@ export type Mode = 'shadow' | 'enforce';
132
132
  export interface Policy {
133
133
  mode: Mode;
134
134
  thresholds: Thresholds;
135
+ /** Additive local usage advisories. Older policy files use these defaults. */
136
+ insights?: {
137
+ rewriteWarnDollarsPerHour?: number;
138
+ heavyTurnTokens?: number;
139
+ pricingFile?: string;
140
+ };
135
141
  /** Calibration provenance, so a report can say where its numbers came from. */
136
142
  calibration?: {
137
143
  sessionsSampled: number;
@@ -0,0 +1,132 @@
1
+ # Understand local usage
2
+
3
+ Burn reads the usage metadata already saved by your coding agent. It separates fresh input, cache writes, cache reads and output. The dollar estimate is the equivalent cost at the recorded API list rates. It is not a subscription bill, an account allowance or a conversion from tokens into your plan's limits.
4
+
5
+ The observer and reports run locally without network requests. Reports contain usage counts, model identifiers, timestamps and explanations supported by metadata. They do not copy prompt text, file contents, tool arguments or model output into the usage report.
6
+
7
+ ## Commands
8
+
9
+ ```sh
10
+ agentguard-burn why
11
+ agentguard-burn why SESSION_ID json
12
+ agentguard-burn rewrites all
13
+ agentguard-burn pace SESSION_ID
14
+ agentguard-burn statusline SESSION_ID
15
+ agentguard-burn pricing
16
+ ```
17
+
18
+ `why` breaks down a session's recorded usage. `rewrites` lists large prefix writes and the evidence available for their causes. `pace` and `statusline` show a short local summary. The optional final `json` argument on `why` and `rewrites` makes the report suitable for another local program.
19
+
20
+ ## Attribution method in 0.2.5
21
+
22
+ The first distinct assistant response establishes its session's fixed-prefix baseline from input, cache creation and cache read tokens. The instruction stack + system row uses that initial measured context and the corresponding prefix portion of later cache reads. This baseline includes the initial user message. A rewrite classified as prefix change resets it to that response's context, which can include existing history. The label is a baseline attribution rule, not a claim that instruction files alone contain that many tokens.
23
+
24
+ History re-sent is each response's cache reads above the fixed prefix, floored at zero. The arriving increment is fresh input plus cache creation after subtracting the previous response's output. Intervening user messages receive conversation tokens. Tool results receive tool output tokens, except a recorded Read result for a previously read path receives re-read file tokens. When several results or user messages share an increment, their recorded UTF-8 byte sizes determine proportions of the measured token total. Integer rounding uses largest remainders so no token disappears. Mixed user and tool intervals are marked shared and counted in the footer.
25
+
26
+ A qualifying full-prefix rewrite retains its measured cache-creation tokens and is excluded from event-increment attribution. Explicit child transcripts retain their existing fan-out attribution after rewrites. Generated output is counted once. To conserve all recorded usage, the input tokens reserved by previous-output subtraction remain in the unattributed residual, together with intervals without usable event evidence. A single event category can own an increment even when its payload is empty; mixed empty payloads have no measurable byte proportions and remain residual.
27
+
28
+ Repeated usage snapshots for one provider response keep the original interval rather than consuming later events again. Read paths, tool IDs and event identities are hashed in local state. Only byte counts and metadata survive parsing, never text. Shell commands that happen to read a file remain tool output unless the host records a Read call. Existing explicit host token-category instrumentation takes precedence where available.
29
+
30
+ The table's Method footer has one sentence per bucket. Zero now means a computed zero rather than a missing measurement. Old pace caches without interval metadata are rebuilt once from the local transcript. When auditing a date range, establish baselines from complete session histories before selecting the responses in that range.
31
+
32
+ ## Cache writes and long sessions
33
+
34
+ For Claude, `cache_creation_input_tokens` is the total written. When the transcript also includes `cache_creation.ephemeral_5m_input_tokens` and `cache_creation.ephemeral_1h_input_tokens`, Burn uses that split. If the lifetime is unavailable, Burn returns a range between the two published write rates. A long pause alone does not establish the requested cache lifetime. See the [provider's cache usage fields](https://platform.claude.com/docs/en/build-with-claude/prompt-caching#1-hour-cache-duration).
35
+
36
+ A large rewrite after a recorded compaction, a first subagent turn, a recorded prefix change or an idle interval can explain expensive turns. These are evidence labels, not proof that one cause was solely responsible. When metadata does not identify a cause or lifetime, the report says so.
37
+
38
+ For `claude-fable-5-1`, 500,000 cache-read tokens have a $0.125 API equivalent. Writing that many tokens costs $6.25 with a five-minute lifetime or $10 with a one-hour lifetime. At the same verified cache-read rate, 1.69 billion reads cost $422.50. Multiplying all token categories by the fresh-input rate would give the wrong result.
39
+
40
+ ## Pricing sources and overrides
41
+
42
+ Every bundled row includes its exact model identifier, `sourceUrl` and `verifiedAt`. The rates were checked on 2026-09-17. The runtime never downloads pricing. Run `agentguard-burn pricing` to inspect the installed table.
43
+
44
+ The Claude rows use the [official model price list](https://platform.claude.com/docs/en/about-claude/pricing). Amounts below are USD per million tokens in the order fresh input, cache read, five-minute write, one-hour write, output:
45
+
46
+ * `claude-fable-5-1`: 10, 0.25, 12.5, 20, 50.
47
+ * `claude-fable-5`: 10, 1, 12.5, 20, 50.
48
+ * `claude-opus-5` and `claude-opus-4-8`: 5, 0.5, 6.25, 10, 25.
49
+ * `claude-sonnet-5`: 2, 0.2, 2.5, 4, 10.
50
+ * `claude-haiku-4-5-20251001`: 1, 0.1, 1.25, 2, 5.
51
+
52
+ The [OpenAI API price list](https://developers.openai.com/api/docs/pricing) supplies these standard rates in the order fresh input, cache read, cache write, output:
53
+
54
+ * `gpt-6-astra`: 10, 1, 12.5, 50.
55
+ * `gpt-5.6-sol`: 4, 0.4, 5, 20. These are the published promotional rates on the verification date.
56
+ * `gpt-5.6-luna`: 0.2, 0.02, 0.25, 1.2.
57
+
58
+ These OpenAI rows have one published cache-write price rather than separate lifetime prices. For requests with more than 272,000 input tokens, Burn applies the documented API multiplier of two to fresh input, reads and writes, and 1.5 to output. It uses the request's reported input counts, not the model's advertised context capacity.
59
+
60
+ The [GPT-5.4 Mini model page](https://developers.openai.com/api/docs/models/gpt-5.4-mini) supplies 0.75 input, 0.075 cached input and 4.5 output. Its separate cache-write rate is unverified and remains `null`. Unknown model identifiers, including unlisted aliases, keep their token counts but have no dollar estimate. Burn does not infer prices from a model family name.
61
+
62
+ **Codex charges can differ from the API equivalent.** OpenAI's [Enterprise rate card](https://help.openai.com/en/articles/20001415) says that Codex does not charge for cache writes and that Astra in Codex does not incur the API long-context surcharge. Its other billing rules and included subscription allowances also differ. Burn's default table intentionally describes API list prices, not what a Codex subscriber owes. Fast processing, regional premiums, batch discounts, tools, taxes and negotiated rates are outside this standard estimate.
63
+
64
+ To use different rates, create a local JSON file and set `AGENTGUARD_BURN_PRICING_FILE` to its path. You can also set `insights.pricingFile` in the Burn policy. Otherwise Burn checks `burn-pricing.json` under `AGENTGUARD_HOME`, or the normal Burn home when that variable is absent. Listed models replace the matching bundled row; other bundled rows remain available.
65
+
66
+ ```json
67
+ {
68
+ "models": {
69
+ "example-model": {
70
+ "input": 2,
71
+ "cacheRead": 0.2,
72
+ "cacheWrite5m": null,
73
+ "cacheWrite1h": null,
74
+ "output": 8,
75
+ "sourceUrl": "https://example.com/your-agreed-rates",
76
+ "verifiedAt": "2026-09-17"
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ These example prices are placeholders. Use a source that establishes your actual rates. Every category must be a nonnegative number or `null`; `null` means unknown, and zero means a verified zero price. If a turn has tokens in an unknown category, its full dollar estimate remains unavailable. Partial known categories remain visible in the pricing result.
83
+
84
+ For a single write rate, set equal `cacheWrite5m` and `cacheWrite1h` values and add `"cacheWriteTtl": "not_tiered"`. An optional `longContext` object takes `aboveInputTokens`, `inputMultiplier` and `outputMultiplier`. An override without that object has no automatic context surcharge. This lets an operator express a verified contract without inheriting an incompatible API rule.
85
+
86
+ ## What the hosts expose
87
+
88
+ Claude Code supplies `session_id` and `transcript_path` to hooks. Its transcript is written asynchronously, so a hook may observe usage a little after the corresponding response. The hook schema does not promise account rate-limit percentages. See [Claude Code hook input](https://code.claude.com/docs/en/hooks#common-input-fields).
89
+
90
+ The [Claude Code statusline contract](https://code.claude.com/docs/en/statusline) separately exposes `rate_limits.five_hour.used_percentage`, `rate_limits.seven_day.used_percentage` and each window's `resets_at` in Unix seconds. A window may be absent, and expired windows are removed. `context_window.used_percentage` measures current context occupancy; it is not an account-limit percentage. A custom statusline can invoke Burn with the JSON supplied on stdin:
91
+
92
+ ```json
93
+ {
94
+ "statusLine": {
95
+ "type": "command",
96
+ "command": "agentguard-burn statusline"
97
+ }
98
+ }
99
+ ```
100
+
101
+ This is an example for operator review. Running the reporting commands does not install this setting. If another statusline is already configured, combine its output with Burn in your existing script instead of replacing it.
102
+
103
+ In Codex 0.154.0, transcript `token_count` records can contain `rate_limits.primary` and `rate_limits.secondary`. Each window uses `used_percent`, optional `window_minutes` and optional `resets_at` in Unix seconds. Primary and secondary names alone do not establish a five-hour or weekly duration. The released [protocol definitions](https://github.com/openai/codex/blob/rust-v0.154.0/codex-rs/protocol/src/protocol.rs#L2318) establish these fields. The hook contract supplies a transcript path, not a separate promise of quota fields.
104
+
105
+ Codex input totals include cache reads and writes. Burn separates them before pricing fresh input. Reasoning output is already part of total output and is not added again. The released [usage decoder test](https://github.com/openai/codex/blob/rust-v0.154.0/codex-rs/codex-api/src/sse/responses.rs#L898) demonstrates an input total of 100 consisting of 40 reads and 60 writes, plus 10 output tokens that include five reasoning tokens.
106
+
107
+ Codex 0.154.0 supports a fixed list of built-in footer items, not an external status command. Its [released statusline implementation](https://github.com/openai/codex/blob/rust-v0.154.0/codex-rs/tui/src/bottom_pane/status_line_setup.rs#L56) and [configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference#tuistatus_line) document that boundary. Use its native limit indicators and run the one-line Burn companion in a terminal:
108
+
109
+ ```toml
110
+ [tui]
111
+ status_line = ["model", "context-remaining", "five-hour-limit", "weekly-limit"]
112
+ ```
113
+
114
+ ```sh
115
+ agentguard-burn statusline SESSION_ID
116
+ ```
117
+
118
+ ## Forecast limits without inventing a quota
119
+
120
+ The last ten minutes of recorded usage establish cached and uncached token pace and a next-hour projection. An account-limit forecast requires multiple explicit percentage observations from the same unexpired host window. It follows the observed change in percentage over time. Neither a token maximum nor a cache discount is inferred from subscription price, context size or API cost.
121
+
122
+ When snapshots are missing, stale, unchanged, reset or otherwise insufficient, the line reports an unknown limit with a reason. A forecast reflects recent activity only; other sessions or a change in workload can change the result. The host's own usage screen remains the authority for the remaining allowance.
123
+
124
+ ## Unknown inherited history
125
+
126
+ Some older Codex fork transcripts copy a parent's usage without an authoritative boundary. Burn excludes that inherited history and reports usage unavailable rather than charging the copy again. Reports across sessions identify how many histories were excluded. Those totals are incomplete. Newer transcripts with an explicit inherited-history ordinal or a child-owned thread settings event can separate the copied prefix from new usage.
127
+
128
+ ## Reservation lock recovery
129
+
130
+ The reservation lock waits up to eight seconds. A process that fails closed because it could not acquire that lock does not contribute to the decision count or the seven-day age required for enforcement eligibility. Other old ledger rows keep their existing interpretation.
131
+
132
+ Retiring an abandoned lock is serialized before renaming it, so a stale observer cannot temporarily move a live replacement lock. If a process crashes during the retirement claim itself, the conservative result can be continued fail-closed lock waits. A directory without a valid owner record also requires manual recovery: its creator might be paused before publishing ownership, and directory age cannot prove that process is dead. Recovery requires stopping all clients using that Burn home, confirming that every PID recorded in `burn.lock/owner` and `burn.lock/retiring/owner` is dead, moving that abandoned lock directory aside, then restarting the clients. If either owner record is absent or unreadable, keep every client stopped during recovery. Do not remove a lock while any of those clients is running.
@@ -1,9 +1,9 @@
1
1
  {
2
- "captured_from": "codex-cli 0.151.0, 2026-09-03, codex exec with -c hooks.PreToolUse inline; --dangerously-bypass-hook-trust",
2
+ "captured_from": "Synthetic identifiers; payload shape based on codex-cli 0.151.0 PreToolUse, 2026-09-03",
3
3
  "payloads": [
4
4
  {
5
- "session_id": "01a069d3-5fc8-7470-8c95-1b42e7206746",
6
- "turn_id": "01a069d3-6018-7523-a26e-eea10123676c",
5
+ "session_id": "00000000-0000-7000-8000-000000000001",
6
+ "turn_id": "00000000-0000-7000-8000-000000000011",
7
7
  "transcript_path": "/Users/example/.codex/sessions/2026/09/03/rollout-example.jsonl",
8
8
  "cwd": "/Users/example/project",
9
9
  "hook_event_name": "PreToolUse",
@@ -13,11 +13,11 @@
13
13
  "tool_input": {
14
14
  "command": "echo CANARY_OK"
15
15
  },
16
- "tool_use_id": "call_NmxrZob0I2KFC8ChNe2PduGX"
16
+ "tool_use_id": "call_SYNTHETIC000000000000001"
17
17
  },
18
18
  {
19
- "session_id": "01a069d4-d356-73b3-9440-1c4440319de8",
20
- "turn_id": "01a069d4-d3b2-7593-85ec-8a0281f51bf1",
19
+ "session_id": "00000000-0000-7000-8000-000000000002",
20
+ "turn_id": "00000000-0000-7000-8000-000000000012",
21
21
  "transcript_path": "/Users/example/.codex/sessions/2026/09/03/rollout-example.jsonl",
22
22
  "cwd": "/Users/example/project",
23
23
  "hook_event_name": "PreToolUse",
@@ -28,11 +28,11 @@
28
28
  "message": "<redacted>",
29
29
  "fork_context": "<redacted>"
30
30
  },
31
- "tool_use_id": "call_oktUPQ5jcosNPOcGnEaWqRcv"
31
+ "tool_use_id": "call_SYNTHETIC000000000000002"
32
32
  },
33
33
  {
34
- "session_id": "01a069d4-d356-73b3-9440-1c4440319de8",
35
- "turn_id": "01a069d4-d3b2-7593-85ec-8a0281f51bf1",
34
+ "session_id": "00000000-0000-7000-8000-000000000002",
35
+ "turn_id": "00000000-0000-7000-8000-000000000012",
36
36
  "transcript_path": "/Users/example/.codex/sessions/2026/09/03/rollout-example.jsonl",
37
37
  "cwd": "/Users/example/project",
38
38
  "hook_event_name": "PreToolUse",
@@ -43,7 +43,7 @@
43
43
  "targets": "<redacted>",
44
44
  "timeout_ms": "<redacted>"
45
45
  },
46
- "tool_use_id": "call_RK95A6i4UUhTigIGXF24gMMO"
46
+ "tool_use_id": "call_SYNTHETIC000000000000003"
47
47
  }
48
48
  ]
49
- }
49
+ }