@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.
- package/CHANGELOG.md +39 -2
- package/LICENSE +9 -10
- package/README.md +96 -16
- package/dist/src/adapters/codex.js +11 -1
- package/dist/src/adapters/cursor.js +2 -2
- package/dist/src/cli.js +40 -13
- package/dist/src/conformance.js +3 -1
- package/dist/src/defaults.d.ts +9 -8
- package/dist/src/defaults.js +9 -8
- package/dist/src/detectors/evaluate.d.ts +5 -2
- package/dist/src/detectors/evaluate.js +26 -2
- package/dist/src/eligibility.d.ts +17 -0
- package/dist/src/eligibility.js +29 -0
- package/dist/src/gateway.d.ts +2 -0
- package/dist/src/gateway.js +6 -3
- package/dist/src/hook/pre-tool-use.d.ts +2 -0
- package/dist/src/hook/pre-tool-use.js +93 -63
- package/dist/src/insights/attribution.d.ts +4 -0
- package/dist/src/insights/attribution.js +151 -0
- package/dist/src/insights/live.d.ts +53 -0
- package/dist/src/insights/live.js +211 -0
- package/dist/src/insights/pace.d.ts +34 -0
- package/dist/src/insights/pace.js +54 -0
- package/dist/src/insights/pricing.d.ts +48 -0
- package/dist/src/insights/pricing.js +139 -0
- package/dist/src/insights/render.d.ts +8 -0
- package/dist/src/insights/render.js +126 -0
- package/dist/src/insights/sessions.d.ts +12 -0
- package/dist/src/insights/sessions.js +51 -0
- package/dist/src/insights/transcript.d.ts +12 -0
- package/dist/src/insights/transcript.js +492 -0
- package/dist/src/insights/types.d.ts +157 -0
- package/dist/src/insights/types.js +4 -0
- package/dist/src/install.js +14 -5
- package/dist/src/replay/render.js +1 -1
- package/dist/src/state/account.d.ts +3 -0
- package/dist/src/state/account.js +39 -0
- package/dist/src/state/reservations.d.ts +5 -5
- package/dist/src/state/reservations.js +60 -45
- package/dist/src/types.d.ts +6 -0
- package/docs/USAGE_AND_PRICING.md +132 -0
- package/fixtures/codex-0.151.0-pretooluse.json +11 -11
- 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'];
|
package/dist/src/install.js
CHANGED
|
@@ -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:
|
|
80
|
-
subagentStop: { command, timeout:
|
|
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', '
|
|
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', '
|
|
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
|
|
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,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
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
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.
|
|
138
|
-
|
|
139
|
-
return false;
|
|
132
|
+
(0, node_fs_1.mkdirSync)(claim, { mode: 0o700 });
|
|
133
|
+
break;
|
|
140
134
|
}
|
|
141
|
-
catch {
|
|
142
|
-
|
|
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.
|
|
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
|
-
|
|
149
|
-
|
|
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)
|
|
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
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
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
|
package/dist/src/types.d.ts
CHANGED
|
@@ -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
|
|
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": "
|
|
6
|
-
"turn_id": "
|
|
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": "
|
|
16
|
+
"tool_use_id": "call_SYNTHETIC000000000000001"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
|
-
"session_id": "
|
|
20
|
-
"turn_id": "
|
|
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": "
|
|
31
|
+
"tool_use_id": "call_SYNTHETIC000000000000002"
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
|
-
"session_id": "
|
|
35
|
-
"turn_id": "
|
|
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": "
|
|
46
|
+
"tool_use_id": "call_SYNTHETIC000000000000003"
|
|
47
47
|
}
|
|
48
48
|
]
|
|
49
|
-
}
|
|
49
|
+
}
|