@agentguard-run/burn 0.2.3 → 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 CHANGED
@@ -1,3 +1,23 @@
1
+ # Changelog
2
+
3
+ ## 0.2.5 (2026-09-18)
4
+
5
+ - Attribute session usage from measured deltas: fixed-prefix context, cached history, tool output, repeated Read results and conversation. Use bytes only to split a measured increment, with mixed intervals counted as shared.
6
+ - Preserve every recorded token across output, rewrites, child fan-out and the residual. The Method footer explains each bucket, baseline resets and the prior-output subtraction retained in the residual.
7
+ - Keep baseline and event state across responses for correct last-turn shares and category pricing. Repeated usage snapshots retain their original event interval; old pace caches rebuild local metadata once.
8
+ - Validate every bucket against a constructed session, including UTF-8 weights, integer rounding, prefix changes, duplicate events and per-category conservation.
9
+ - Tests: 120 passing by default, one opt-in stress test skipped.
10
+
11
+ ## 0.2.4 (2026-09-18)
12
+
13
+ - Add local `why`, `rewrites`, `pace`, `statusline` and `pricing` commands. Assistant usage is reconciled by response identity, with unknown attribution kept explicit and child usage identified from transcript metadata.
14
+ - Explain full-prefix cache writes, measured lifetimes, cause evidence and API list cost. Warn on configurable rewrite spend and heavy context crossings without blocking ordinary tools.
15
+ - Observe every Claude Code and Codex tool call. Track cached and uncached ten-minute pace. Forecast limits only from fresh host percentage observations; provide a companion terminal line where Codex cannot run a custom footer command.
16
+ - Bundle dated model pricing and local overrides. Keep unknown models unpriced, content out of persisted metadata, and new reporting paths free of network calls.
17
+ - Raise reservation lock wait to eight seconds, serialize stale-lock retirement, and exclude operational lock-wait failures from enforce eligibility. Old ledgers and receipts remain readable.
18
+ - Validate a 240-process burst: exactly 40 admitted, 200 denied, 240 signed receipts, zero lock failures and a valid chain.
19
+ - Tests: 106 passing by default, one opt-in stress test skipped. The separate stress run also passes.
20
+
1
21
  ## 0.2.3 (2026-09-15)
2
22
 
3
23
  - Replay evidence refreshed from this machine: 4 STOP, 3 WARN, 431 clean across
@@ -16,8 +36,6 @@
16
36
  identifiers, and stop labelling every omitted replay session as clean.
17
37
  - Tests: 47 passing by default, one stress test skipped unless AGENTGUARD_STRESS=1.
18
38
 
19
- # Changelog
20
-
21
39
  ## 0.2.2 (2026-09-03)
22
40
 
23
41
  - Codex: `status` and `init codex --write` now say, every time, that the
package/README.md CHANGED
@@ -1,16 +1,63 @@
1
1
  # @agentguard-run/burn
2
2
 
3
- Local runaway-agent circuit breaker for AI coding agents.
3
+ See where a coding session's tokens went and what the next heavy turn could cost in time and limits. Burn explains recorded usage, warns about large cache rewrites and keeps its existing runaway-session circuit breaker. Everything stays on this machine.
4
4
 
5
- Raw token counters did not help. You see "360k tokens" and keep going. This
6
- tool does the three things a counter does not: it decides whether a session is
7
- pathological, it blocks the next agent spawn when it is, and it tells you what
8
- to do about it. Nothing leaves the machine.
5
+ ## Install in five lines
6
+
7
+ ```sh
8
+ npm i -g @agentguard-run/burn
9
+ agentguard-burn init claude
10
+ agentguard-burn init codex
11
+ agentguard-burn statusline
12
+ agentguard-burn why
13
+ ```
14
+
15
+ The two init commands print hook configuration for review. Merge the relevant snippet into your host configuration. They do not write it. For Codex, review and trust the hook through `/hooks`. Upgrading an existing hook requires the new snippet: it matches every tool so pace can update on ordinary tool calls, while admission still gates spawns.
16
+
17
+ For Claude Code, configure its `statusLine` command as `agentguard-burn statusline`. It receives the host JSON on stdin. Codex 0.154.0 has no external status command slot. Use `agentguard-burn statusline SESSION_ID` in a companion terminal and the native Codex limit indicators. See [host fields and setup](docs/USAGE_AND_PRICING.md#what-the-hosts-expose).
18
+
19
+ ## Why this session
20
+
21
+ ```sh
22
+ agentguard-burn why
23
+ agentguard-burn why SESSION_ID
24
+ agentguard-burn rewrites SESSION_ID
25
+ agentguard-burn rewrites all
26
+ agentguard-burn pace SESSION_ID
27
+ agentguard-burn pricing
28
+ ```
29
+
30
+ A session argument can also be a transcript path. Without one, Burn uses the session environment variable when available, then the most recently modified local transcript. Claude child transcripts are included in `why`. Token shares are shares of recorded tokens, not shares of dollars. Repeated usage records for one provider response count once. Copied Codex history is reconciled before counting new usage.
31
+
32
+ The table covers instruction stack + system, history re-sent, repeated file reads, subagent fan-out, tool output, conversation, full-prefix rewrites, output and unattributed usage. The fixed-prefix baseline is the first assistant response's measured context. It includes the initial user message, so the label is an operational baseline rather than a direct measurement of instruction files. A rewrite classified as prefix change resets that baseline and is counted in the footer; the new baseline can include history already present at that point.
33
+
34
+ Later cache reads up to the baseline go to instruction stack + system. Reads above it go to history re-sent. Fresh input plus cache creation is the measured arriving increment. Burn subtracts the previous response's output before assigning that increment to intervening tool results and user messages. Result byte sizes only split that measured total; bytes are never converted into tokens. A result for a previously read Read path goes to re-read files. Mixed user and tool intervals are marked shared and counted in the footer.
35
+
36
+ Rewrite tokens and child transcript usage keep their own buckets. Prior output deducted from an input increment remains in the unattributed residual so every recorded input and output token is counted exactly once. Missing event evidence also remains there. Integer allocation preserves every token, and the Method footer explains each row.
37
+
38
+ A full-prefix rewrite writes more than 150,000 tokens and more than half the actual input context. Burn reports the observed cache lifetime and surrounding evidence: idle beyond that lifetime, immediate compaction, first subagent response, or residual prefix change. An unknown lifetime stays unknown. Each write is priced at its measured lifetime, or a range when that lifetime is unavailable.
39
+
40
+ Every tool hook updates a local pace file under the Burn home. Pace uses ten wall-clock minutes: cached means cache reads; uncached means fresh input, writes and output. The next-hour projection assumes that pace continues. A time-to-limit estimate requires rising, fresh host percentage samples from the same quota pool and reset window. No token-to-limit conversion or cache weighting is assumed. Without sufficient host data, the line says why the limit is unknown.
41
+
42
+ The new warnings are advisory in both modes. Cache rewrites warn once when their last-hour API list equivalent exceeds $5. A context crossing 500,000 tokens warns about processing time and compaction, estimates a cache rewrite, and suggests a fresh session with a handoff note. These warnings do not deny a tool call.
43
+
44
+ Optional settings in your existing `burn-policy.json`:
45
+
46
+ ```json
47
+ {
48
+ "insights": {
49
+ "rewriteWarnDollarsPerHour": 5,
50
+ "heavyTurnTokens": 500000
51
+ }
52
+ }
53
+ ```
54
+
55
+ Merge these fields into the existing policy. All existing thresholds and modes remain supported. Prices are exact-model API list equivalents, not a subscription bill. Unknown models show tokens only. Every rate has a source and verification date. A local override file and full pricing table are documented in [Usage and pricing](docs/USAGE_AND_PRICING.md).
9
56
 
10
57
  ## Two safety planes
11
58
 
12
59
  Originally fitted against 412 real Claude Code sessions, then replayed on
13
- 2026-09-15 against 438 sessions. The history shows two independent
60
+ 2026-09-18 against 453 sessions. The history shows two independent
14
61
  failure shapes that one detector cannot cover:
15
62
 
16
63
  | Plane | Catches | Rule | Validated on |
@@ -18,11 +65,14 @@ failure shapes that one detector cannot cover:
18
65
  | Structural | fan-out storms: many agents re-sending context | WARN 24 spawns, STOP before spawn 41, no agents-spawning-agents past depth 2 | 59-, 190- and 226-spawn sessions; the 190-spawn session has a 4.35B post-STOP tail (92%) |
19
66
  | Economic | slow grinds: few agents, enormous total | WARN 3.5B, STOP 5B tokens per session | the 9.15B / 26-spawn session the spawn cap could not see |
20
67
 
21
- The 2026-09-15 replay snapshot: **4 STOP, 3 WARN, 431 clean across 438
22
- sessions and 616 spawns**. Replay hero: **32%** **10.27B** of **32.58B**
23
- observed tokens came after a STOP boundary; API-list scenario **$4,639**.
68
+ The 2026-09-18 replay snapshot: **5 STOP, 3 WARN, 445 clean across 453
69
+ sessions and 662 spawns**. Replay hero: **30%**. **10.80B** of **36.30B**
70
+ recorded tokens came after a STOP boundary; API-list scenario **$4,832**.
24
71
  This is an upper bound assuming no override or restart. Replay does not label
25
- false positives. Active histories can change the next replay result.
72
+ false positives. Active histories can change the next replay result. The existing replay
73
+ counters retain their published behavior and can include repeated streaming
74
+ usage records. The new `why` report reconciles provider responses, so its
75
+ totals can differ. Do not treat replay as a reconciled invoice.
26
76
 
27
77
  Cache-read ratio was about 98% in the original calibration, healthy and pathological alike. It
28
78
  is shown as an explanation and never used to decide.
@@ -85,7 +135,7 @@ What each host can actually see is stated, not implied:
85
135
  | Ollama proxy | none | none | authoritative | `prompt_eval_count` + `eval_count` on the final chunk |
86
136
  | vLLM / LM Studio / OpenAI-compatible proxy | none | none | authoritative when the server sends `usage`, else reported missing | non-streaming `usage`, or the final SSE usage event |
87
137
  | Cursor (beta) | authoritative | estimated | none | native `subagentStart` deny; hosted-model usage is never exposed |
88
- | Codex (beta) | authoritative | estimated | estimated | `PreToolUse` on `spawn_agent`; live deny, allow and override canary passed on codex-cli 0.151.0; transcript parsed best-effort |
138
+ | Codex (beta) | authoritative | estimated | estimated | `PreToolUse` on every tool, with admission on `spawn_agent`; live deny, allow and override canary passed on codex-cli 0.151.0; transcript parsed best-effort |
89
139
 
90
140
  An `OK` from a host that cannot see usage is an OK about spawns, and `status`
91
141
  says `usage:n/a` next to it. Missing usage never becomes a guessed zero.
@@ -211,6 +261,11 @@ grabbed the instance it judged, and every write is fenced on the holder's
211
261
  own nonce still being on the path. In 0.2.3, bounded, staggered retries prevent
212
262
  lock waiters from starving the holder. The Sep 15 audit includes one successful
213
263
  240-process run after that fix; it does not establish a 192-run guarantee.
264
+ In 0.2.4, the wait is eight seconds and retirement is serialized before
265
+ renaming an abandoned lock. The fresh 240-process run admitted exactly 40,
266
+ denied 200, signed 240 receipts and had zero lock failures. Operational
267
+ lock-wait failures do not count toward enforce eligibility. See the
268
+ [conservative recovery procedure](docs/USAGE_AND_PRICING.md#reservation-lock-recovery).
214
269
 
215
270
  Single-machine by design. Two laptops on one account do not share state, and
216
271
  that is stated rather than hidden.
@@ -228,5 +283,4 @@ a would-block decision in shadow and blocks new work only in enforce mode.
228
283
  ## What it never does
229
284
 
230
285
  No prompts, responses, file contents, or tool inputs are persisted or rendered.
231
- No telemetry. No provider-quota guesses: it projects against your configured
232
- policy, never against a subscription allowance it cannot see.
286
+ No telemetry. No provider-quota guesses. Account forecasts use only observed host percentages, never an invented allowance or cache weighting.
@@ -33,11 +33,21 @@ const node_crypto_1 = require("node:crypto");
33
33
  const node_fs_1 = require("node:fs");
34
34
  const events_1 = require("../events");
35
35
  const render_1 = require("../replay/render");
36
+ const live_1 = require("../insights/live");
37
+ const pre_tool_use_1 = require("../hook/pre-tool-use");
36
38
  /** Fields this renderer must never emit. Codex rejects the whole object if they appear. */
37
39
  exports.CODEX_FORBIDDEN_FIELDS = ['continue', 'stopReason', 'suppressOutput', 'decision', 'reason'];
38
40
  const HOST = 'codex';
39
41
  const SPAWN_TOOLS = new Set(['Agent', 'spawn_agent', 'Task']);
40
42
  function handleCodexHook(raw, gateway, now = Date.now()) {
43
+ const parsed = parse(raw);
44
+ if (!parsed)
45
+ return {};
46
+ const observation = (0, live_1.observeTool)(gateway.dataDirectory, { session_id: parsed.sessionId, transcript_path: parsed.transcriptPath, tool_name: parsed.toolName }, 'codex', (0, pre_tool_use_1.loadPolicy)(gateway.dataDirectory), now);
47
+ const output = parsed.event === 'PreToolUse' && SPAWN_TOOLS.has(parsed.toolName) ? handleCodexSpawnHook(raw, gateway, now) : {};
48
+ return observation.messages.length ? { ...output, systemMessage: [output.systemMessage, ...observation.messages].filter(Boolean).join('\n') } : output;
49
+ }
50
+ function handleCodexSpawnHook(raw, gateway, now) {
41
51
  const input = parse(raw);
42
52
  if (!input)
43
53
  return {};
@@ -190,7 +200,7 @@ function walk(value, path) {
190
200
  function codexHooksSnippet(command) {
191
201
  return {
192
202
  hooks: {
193
- PreToolUse: [{ matcher: '^(Agent|spawn_agent)$', hooks: [{ type: 'command', command, timeout: 5 }] }],
203
+ PreToolUse: [{ matcher: '.*', hooks: [{ type: 'command', command, timeout: 15 }] }],
194
204
  },
195
205
  };
196
206
  }
@@ -127,8 +127,8 @@ function cursorHooksSnippet(command) {
127
127
  return {
128
128
  version: 1,
129
129
  hooks: {
130
- subagentStart: [{ command, timeout: 5, failClosed: true }],
131
- subagentStop: [{ command, timeout: 5 }],
130
+ subagentStart: [{ command, timeout: 15, failClosed: true }],
131
+ subagentStop: [{ command, timeout: 15 }],
132
132
  sessionEnd: [{ command, timeout: 3 }],
133
133
  },
134
134
  };
package/dist/src/cli.js CHANGED
@@ -33,6 +33,11 @@ const server_1 = require("./proxy/server");
33
33
  const render_1 = require("./replay/render");
34
34
  const simulate_1 = require("./replay/simulate");
35
35
  const status_1 = require("./status");
36
+ const eligibility_1 = require("./eligibility");
37
+ const sessions_1 = require("./insights/sessions");
38
+ const transcript_1 = require("./insights/transcript");
39
+ const live_1 = require("./insights/live");
40
+ const render_2 = require("./insights/render");
36
41
  const HOME = process.env.AGENTGUARD_HOME ?? (0, node_path_1.join)((0, node_os_1.homedir)(), '.agentguard');
37
42
  const PROXY_HOSTS = ['ollama', 'vllm', 'lm-studio', 'openai-compatible'];
38
43
  function readStdinJson() {
@@ -66,18 +71,7 @@ function readDecisions() {
66
71
  .filter((x) => x !== null);
67
72
  }
68
73
  function shadowEligibility() {
69
- const decisions = readDecisions();
70
- const first = decisions.length ? Number(decisions[0].at) : Date.now();
71
- const days = (Date.now() - first) / 86_400_000;
72
- const wouldBlock = decisions.filter((d) => d.wouldDeny === true).length;
73
- const warns = decisions.filter((d) => d.verdict === 'WARN').length;
74
- return {
75
- eligible: decisions.length >= defaults_1.SHADOW_MIN_DECISIONS && days >= defaults_1.SHADOW_MIN_DAYS,
76
- decisions: decisions.length,
77
- days,
78
- wouldBlock,
79
- warns,
80
- };
74
+ return (0, eligibility_1.computeShadowEligibility)(readDecisions());
81
75
  }
82
76
  async function main(argv) {
83
77
  const [command, ...rest] = argv;
@@ -87,6 +81,33 @@ async function main(argv) {
87
81
  };
88
82
  const has = (name) => rest.includes(name);
89
83
  switch (command) {
84
+ case 'why': {
85
+ const location = (0, sessions_1.selectInsightTranscript)(rest.find(value => value !== 'json'));
86
+ const transcript = (0, transcript_1.readInsightSession)(location.path, { host: location.host, sessionId: location.sessionId });
87
+ process.stdout.write(rest.includes('json') ? JSON.stringify(transcript, null, 2) + '\n' : (0, render_2.renderWhy)(transcript, (0, live_1.readPricing)(HOME, (0, pre_tool_use_1.loadPolicy)(HOME))) + '\n');
88
+ return 0;
89
+ }
90
+ case 'rewrites': {
91
+ const session = rest.find(value => value !== 'json');
92
+ const locations = session === 'all' ? (0, sessions_1.discoverInsightTranscripts)() : [(0, sessions_1.selectInsightTranscript)(session)];
93
+ const transcripts = locations.map(location => (0, transcript_1.readInsightTranscript)(location.path, { host: location.host, sessionId: location.sessionId }));
94
+ const rates = (0, live_1.readPricing)(HOME, (0, pre_tool_use_1.loadPolicy)(HOME));
95
+ process.stdout.write(rest.includes('json') ? JSON.stringify((0, render_2.rewriteReport)(transcripts, rates), null, 2) + '\n' : (0, render_2.renderRewrites)(transcripts, rates) + '\n');
96
+ return 0;
97
+ }
98
+ case 'pricing': {
99
+ process.stdout.write((0, render_2.renderPricing)((0, live_1.readPricing)(HOME, (0, pre_tool_use_1.loadPolicy)(HOME))) + '\n');
100
+ return 0;
101
+ }
102
+ case 'statusline':
103
+ case 'pace': {
104
+ const raw = command === 'statusline' && !process.stdin.isTTY ? readStdinJson() : null;
105
+ const payload = raw && typeof raw === 'object' ? raw : {};
106
+ const location = (0, sessions_1.selectInsightTranscript)(payload.transcript_path || rest[0] || payload.session_id);
107
+ const observation = (0, live_1.observeTool)(HOME, { ...payload, session_id: payload.session_id || location.sessionId, transcript_path: location.path }, location.host, (0, pre_tool_use_1.loadPolicy)(HOME));
108
+ process.stdout.write((observation.snapshot ? (0, live_1.renderStatusLine)(observation.snapshot) : 'AgentGuard pace unavailable: local usage metadata is not readable.') + '\n');
109
+ return observation.snapshot ? 0 : 1;
110
+ }
90
111
  case 'hook': {
91
112
  const raw = (0, node_fs_1.readFileSync)(0, 'utf8');
92
113
  let input = {};
@@ -141,6 +162,7 @@ async function main(argv) {
141
162
  `shadow observation: ${e.decisions} decisions over ${e.days.toFixed(1)} days`,
142
163
  ` would have warned: ${e.warns} would have blocked: ${e.wouldBlock}`,
143
164
  `eligible for enforcement: ${e.eligible ? 'yes (agentguard-burn enforce)' : `no (need ${defaults_1.SHADOW_MIN_DECISIONS} decisions and ${defaults_1.SHADOW_MIN_DAYS} days)`}`,
165
+ `lock-wait failures excluded from eligibility: ${e.excludedLockWaitFailures}`,
144
166
  override ? `override ACTIVE: ${override.once ? 'the next STOP' : `every STOP until ${new Date(override.until).toLocaleTimeString()}`} passes ("${override.reason}")` : '',
145
167
  '',
146
168
  (0, status_1.renderHostHealth)(['claude', 'cursor', 'codex'].map((h) => (0, install_1.health)(h))),
@@ -270,7 +292,12 @@ async function main(argv) {
270
292
  return 0;
271
293
  }
272
294
  default:
273
- process.stdout.write('agentguard-burn <replay|calibrate|status|init|enforce|shadow|resume|proxy|conformance>\n' +
295
+ process.stdout.write('agentguard-burn <why|rewrites|pace|statusline|pricing|replay|calibrate|status|init|enforce|shadow|resume|proxy|conformance>\n' +
296
+ ' why [session] token attribution and API list-price equivalents\n' +
297
+ ' rewrites [session|all] full-prefix cache writes by cause\n' +
298
+ ' pace [session] ten-minute pace and next-hour projection\n' +
299
+ ' statusline [session] one-line local status; accepts Claude status JSON\n' +
300
+ ' pricing verified model rates and local overrides\n' +
274
301
  ' replay [files...] [--json] [--top N] [--min-tokens N]\n' +
275
302
  ' calibrate fit thresholds to your history (writes shadow policy)\n' +
276
303
  ' status mode, shadow observations, eligibility, hook health, every host\n' +
@@ -3,8 +3,10 @@ import type { Policy, Thresholds } from './types';
3
3
  * Shipped thresholds.
4
4
  *
5
5
  * Originally fitted against 412 real Claude Code sessions. Replayed on the
6
- * author's machine on 2026-09-15: 438 sessions, 616 spawns, 32.58B tokens;
7
- * 4 STOP, 3 WARN, 431 clean. The observed post-STOP tail is 10.27B (32%).
6
+ * author's machine on 2026-09-18: 453 sessions, 662 spawns, 36.30B recorded tokens;
7
+ * 5 STOP, 3 WARN, 445 clean. The recorded post-STOP tail is 10.80B (30%).
8
+ * Legacy replay counters can include repeated streaming usage records. The
9
+ * new insights report reconciles provider responses instead.
8
10
  * Fan-out 24/40 stops before spawn 41 on the 59-, 190- and 226-spawn
9
11
  * sessions; sustained 3.5B/5B catches the 9.15B / 26-spawn slow grind.
10
12
  * This is a dated replay snapshot and an upper bound, not labelled evidence
@@ -5,8 +5,10 @@ exports.SHADOW_MIN_DAYS = exports.SHADOW_MIN_DECISIONS = exports.ACTIVE_GAP_CAP_
5
5
  * Shipped thresholds.
6
6
  *
7
7
  * Originally fitted against 412 real Claude Code sessions. Replayed on the
8
- * author's machine on 2026-09-15: 438 sessions, 616 spawns, 32.58B tokens;
9
- * 4 STOP, 3 WARN, 431 clean. The observed post-STOP tail is 10.27B (32%).
8
+ * author's machine on 2026-09-18: 453 sessions, 662 spawns, 36.30B recorded tokens;
9
+ * 5 STOP, 3 WARN, 445 clean. The recorded post-STOP tail is 10.80B (30%).
10
+ * Legacy replay counters can include repeated streaming usage records. The
11
+ * new insights report reconciles provider responses instead.
10
12
  * Fan-out 24/40 stops before spawn 41 on the 59-, 190- and 226-spawn
11
13
  * sessions; sustained 3.5B/5B catches the 9.15B / 26-spawn slow grind.
12
14
  * This is a dated replay snapshot and an upper bound, not labelled evidence
@@ -0,0 +1,17 @@
1
+ export interface ShadowEligibility {
2
+ eligible: boolean;
3
+ decisions: number;
4
+ days: number;
5
+ wouldBlock: number;
6
+ warns: number;
7
+ excludedLockWaitFailures: number;
8
+ }
9
+ /** Existing lock-failure ledger rows remain readable without a schema change. */
10
+ export declare function isLockWaitFailure(row: Readonly<Record<string, unknown>>): boolean;
11
+ /**
12
+ * A reservation-lock timeout is an operational failure, not an observation of
13
+ * a threshold. Keep it in the audit ledger but do not let a burst of timeouts
14
+ * satisfy the observation count or start the enforcement waiting period.
15
+ * Older decision rows need no new fields and retain their existing behavior.
16
+ */
17
+ export declare function computeShadowEligibility(rows: ReadonlyArray<Readonly<Record<string, unknown>>>, now?: number): ShadowEligibility;
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isLockWaitFailure = isLockWaitFailure;
4
+ exports.computeShadowEligibility = computeShadowEligibility;
5
+ const defaults_1 = require("./defaults");
6
+ /** Existing lock-failure ledger rows remain readable without a schema change. */
7
+ function isLockWaitFailure(row) {
8
+ return row.failClosed === true && typeof row.reason === 'string'
9
+ && /\bcould not acquire (?:the )?reservation lock\b/i.test(row.reason);
10
+ }
11
+ /**
12
+ * A reservation-lock timeout is an operational failure, not an observation of
13
+ * a threshold. Keep it in the audit ledger but do not let a burst of timeouts
14
+ * satisfy the observation count or start the enforcement waiting period.
15
+ * Older decision rows need no new fields and retain their existing behavior.
16
+ */
17
+ function computeShadowEligibility(rows, now = Date.now()) {
18
+ const decisions = rows.filter(row => !isLockWaitFailure(row));
19
+ const first = decisions.length ? Number(decisions[0].at) : now;
20
+ const days = (now - first) / 86_400_000;
21
+ return {
22
+ eligible: decisions.length >= defaults_1.SHADOW_MIN_DECISIONS && days >= defaults_1.SHADOW_MIN_DAYS,
23
+ decisions: decisions.length,
24
+ days,
25
+ wouldBlock: decisions.filter(row => row.wouldDeny === true).length,
26
+ warns: decisions.filter(row => row.verdict === 'WARN').length,
27
+ excludedLockWaitFailures: rows.length - decisions.length,
28
+ };
29
+ }
@@ -80,6 +80,8 @@ export interface GatewayOptions {
80
80
  }
81
81
  export declare class Gateway {
82
82
  private readonly home;
83
+ /** Shared local storage for optional content-free usage observations. */
84
+ get dataDirectory(): string;
83
85
  private readonly store;
84
86
  private readonly signer;
85
87
  private readonly now;
@@ -50,6 +50,8 @@ function safeName(sessionId) {
50
50
  }
51
51
  class Gateway {
52
52
  home;
53
+ /** Shared local storage for optional content-free usage observations. */
54
+ get dataDirectory() { return this.home; }
53
55
  store;
54
56
  signer;
55
57
  now;
@@ -35,6 +35,7 @@ const session_1 = require("../state/session");
35
35
  const defaults_1 = require("../defaults");
36
36
  const override_1 = require("../override");
37
37
  const render_1 = require("../replay/render");
38
+ const live_1 = require("../insights/live");
38
39
  const SPAWN_TOOLS = new Set(['Agent', 'Task']);
39
40
  function sessionFile(home, sessionId) {
40
41
  return (0, node_path_1.join)(home, 'sessions', `${sessionId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`);
@@ -127,6 +128,13 @@ function rememberNotified(home, sessionId, signature) {
127
128
  }
128
129
  }
129
130
  function handlePreToolUse(input, home, now = Date.now()) {
131
+ const observation = (0, live_1.observeTool)(home, input, 'claude', loadPolicy(home), now);
132
+ const output = handleSpawnPreToolUse(input, home, now);
133
+ if (!observation.messages.length)
134
+ return output;
135
+ return { ...output, suppressOutput: false, systemMessage: [output.systemMessage, ...observation.messages].filter(Boolean).join('\n') };
136
+ }
137
+ function handleSpawnPreToolUse(input, home, now) {
130
138
  const toolName = input.tool_name ?? '';
131
139
  if (!SPAWN_TOOLS.has(toolName) || !input.session_id || !input.transcript_path) {
132
140
  return { continue: true, suppressOutput: true };
@@ -231,7 +239,7 @@ function deny(reason) {
231
239
  function settingsSnippet(command) {
232
240
  return {
233
241
  hooks: {
234
- PreToolUse: [{ matcher: '^(Agent|Task)$', hooks: [{ type: 'command', command, timeout: 5 }] }],
242
+ PreToolUse: [{ matcher: '.*', hooks: [{ type: 'command', command, timeout: 15 }] }],
235
243
  },
236
244
  };
237
245
  }
@@ -0,0 +1,4 @@
1
+ import { type AttributionSummary, type InsightTurn, type RewriteEvidence } from './types';
2
+ export declare function classifyRewrite(turn: InsightTurn): RewriteEvidence | null;
3
+ /** Partition measured usage; byte lengths only divide a measured increment among its recorded events. */
4
+ export declare function attributeTurns(turns: InsightTurn[]): AttributionSummary;
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifyRewrite = classifyRewrite;
4
+ exports.attributeTurns = attributeTurns;
5
+ const types_1 = require("./types");
6
+ const transcript_1 = require("./transcript");
7
+ function classifyRewrite(turn) {
8
+ if (!(turn.cacheWriteTokens > 150_000 && turn.cacheWriteTokens > turn.contextTokens / 2))
9
+ return null;
10
+ const idleMs = turn.signals.idleMs;
11
+ const ttl = turn.cacheTtlSeconds;
12
+ const evidence = { turnId: turn.id, cacheWriteTokens: turn.cacheWriteTokens, contextTokens: turn.contextTokens,
13
+ ...(idleMs !== undefined ? { idleMs } : {}), idleOver60Minutes: idleMs !== undefined && idleMs > 3_600_000,
14
+ ...(ttl ? { ttlSeconds: ttl } : {}), ttlUnknown: !ttl };
15
+ if (ttl && idleMs !== undefined && idleMs > ttl * 1000)
16
+ return { ...evidence, cause: 'idle_ttl_expired', explanation: 'Idle gap exceeds the TTL reported for this cache-write class; timing is consistent with expiration.' };
17
+ if (turn.signals.afterCompaction)
18
+ return { ...evidence, cause: 'compaction', explanation: 'A compaction boundary immediately precedes this usage turn.' };
19
+ if (turn.signals.firstSpawnedTurn)
20
+ return { ...evidence, cause: 'first_spawned_turn', explanation: 'This is the first recorded usage turn in an explicitly identified subagent session.' };
21
+ if (!ttl)
22
+ return { ...evidence, cause: 'unknown_ttl', explanation: 'Cache TTL is absent or mixed; expiration cannot be distinguished from a prefix change.' };
23
+ return { ...evidence, cause: 'prefix_change', explanation: turn.signals.prefixChanged
24
+ ? 'The host reports a changed prefix identifier without an expired-TTL or compaction signal.'
25
+ : 'No expiration, compaction, or first-subagent-turn signal explains the rewrite; prefix change is a residual hypothesis, not an observed cause.' };
26
+ }
27
+ const tokenCategories = ['input', 'cacheCreation', 'cacheRead', 'output'];
28
+ const emptyBuckets = () => types_1.INSIGHT_BUCKETS.map(bucket => ({ bucket, tokens: 0,
29
+ categories: { input: 0, cacheCreation: 0, cacheRead: 0, output: 0 } }));
30
+ /** Largest remainders split an already measured token total without inventing tokens from bytes. */
31
+ function integerShares(total, weights) {
32
+ const weight = weights.reduce((sum, value) => sum + value, 0);
33
+ if (!weight || !total)
34
+ return weights.map(() => 0);
35
+ const exact = weights.map(value => total * (value / weight));
36
+ const shares = exact.map(Math.floor);
37
+ const order = exact.map((value, index) => ({ index, remainder: value - shares[index] }))
38
+ .sort((a, b) => b.remainder - a.remainder || a.index - b.index);
39
+ for (let left = total - shares.reduce((sum, value) => sum + value, 0), i = 0; left > 0; left--, i++)
40
+ shares[order[i % order.length].index]++;
41
+ return shares;
42
+ }
43
+ /** Partition measured usage; byte lengths only divide a measured increment among its recorded events. */
44
+ function attributeTurns(turns) {
45
+ const buckets = emptyBuckets();
46
+ const byBucket = new Map(buckets.map(bucket => [bucket.bucket, bucket]));
47
+ const result = { buckets, totalTokens: 0, rewrites: [],
48
+ rewriteCounts: { idle_ttl_expired: 0, compaction: 0, first_spawned_turn: 0, prefix_change: 0, unknown_ttl: 0 },
49
+ idleOver60MinuteRewrites: 0, uncertainties: [], turnAttributions: [], sharedTurns: 0,
50
+ prefixRebaselines: 0, previousOutputTokensReserved: 0 };
51
+ const uncertainties = new Set();
52
+ const sessions = new Map();
53
+ const ordered = (0, transcript_1.deduplicateTurns)(turns).sort((a, b) => (a.at ?? 0) - (b.at ?? 0));
54
+ for (const turn of ordered) {
55
+ const remaining = { input: turn.inputTokens, cacheCreation: turn.cacheWriteTokens, cacheRead: turn.cacheReadTokens, output: turn.outputTokens };
56
+ const turnBuckets = emptyBuckets();
57
+ const turnByBucket = new Map(turnBuckets.map(bucket => [bucket.bucket, bucket]));
58
+ const totalTokens = Object.values(remaining).reduce((sum, value) => sum + value, 0);
59
+ result.totalTokens += totalTokens;
60
+ const allocate = (bucket, category, tokens) => {
61
+ const amount = Math.min(remaining[category], Math.max(0, tokens));
62
+ if (amount < tokens)
63
+ uncertainties.add('attribution_evidence_exceeds_category_total');
64
+ for (const target of [byBucket.get(bucket), turnByBucket.get(bucket)]) {
65
+ target.tokens += amount;
66
+ target.categories[category] += amount;
67
+ }
68
+ remaining[category] -= amount;
69
+ };
70
+ const previous = sessions.get(turn.sessionId);
71
+ let prefix = previous?.prefix ?? turn.contextTokens;
72
+ allocate('output', 'output', remaining.output);
73
+ const rewrite = classifyRewrite(turn);
74
+ if (rewrite) {
75
+ result.rewrites.push(rewrite);
76
+ result.rewriteCounts[rewrite.cause]++;
77
+ if (rewrite.idleOver60Minutes)
78
+ result.idleOver60MinuteRewrites++;
79
+ allocate('full_prefix_rewrites', 'cacheCreation', remaining.cacheCreation);
80
+ if (rewrite.ttlUnknown)
81
+ uncertainties.add('rewrite_ttl_unknown');
82
+ if (rewrite.cause === 'prefix_change') {
83
+ prefix = turn.contextTokens;
84
+ if (previous)
85
+ result.prefixRebaselines++;
86
+ if (!turn.signals.prefixChanged)
87
+ uncertainties.add('prefix_change_is_residual_hypothesis');
88
+ }
89
+ }
90
+ for (const item of turn.explicitAttribution) {
91
+ if (item.bucket !== 'output' && item.bucket !== 'full_prefix_rewrites' && item.bucket !== 'unattributed' && Number.isSafeInteger(item.tokens) && item.tokens > 0)
92
+ allocate(item.bucket, item.category, item.tokens);
93
+ }
94
+ if (turn.subagent) {
95
+ for (const category of ['input', 'cacheCreation', 'cacheRead'])
96
+ allocate('subagent_fanout', category, remaining[category]);
97
+ }
98
+ else if (!turn.explicitAttribution.length) {
99
+ if (!previous || rewrite?.cause === 'prefix_change') {
100
+ // Rewrites retain their measured write tokens; the rest establishes the new fixed prefix.
101
+ for (const category of ['input', 'cacheCreation', 'cacheRead'])
102
+ allocate('instruction_stack', category, remaining[category]);
103
+ }
104
+ else {
105
+ const history = Math.max(0, turn.cacheReadTokens - prefix);
106
+ allocate('instruction_stack', 'cacheRead', Math.min(remaining.cacheRead, prefix));
107
+ allocate('history_resent', 'cacheRead', Math.min(remaining.cacheRead, history));
108
+ if (!rewrite) {
109
+ // Prior assistant output reappears in input usage but is not newly arrived content.
110
+ // Keep that measured input in the residual rather than count it as generated output twice.
111
+ let reserved = Math.min(previous.previousOutput, remaining.input + remaining.cacheCreation);
112
+ result.previousOutputTokensReserved += reserved;
113
+ for (const category of ['input', 'cacheCreation']) {
114
+ const amount = Math.min(reserved, remaining[category]);
115
+ allocate('unattributed', category, amount);
116
+ reserved -= amount;
117
+ }
118
+ const interval = turn.interval;
119
+ const eventBuckets = ['tool_output', 'reread_files', 'conversation'];
120
+ let weights = interval ? [interval.toolOutputBytes, interval.reReadBytes, interval.conversationBytes] : [0, 0, 0];
121
+ if (interval && !weights.some(value => value > 0)) {
122
+ // A sole event class owns the whole measured increment even for an
123
+ // empty result; mixed empty payloads have no byte-based split.
124
+ const present = [interval.toolResults > (interval.reReadResults ?? 0), (interval.reReadResults ?? 0) > 0, interval.userMessages > 0];
125
+ if (present.filter(Boolean).length === 1)
126
+ weights = present.map(value => value ? 1 : 0);
127
+ }
128
+ if (weights.some(value => value > 0)) {
129
+ const shares = integerShares(remaining.input + remaining.cacheCreation, weights);
130
+ const inputs = integerShares(remaining.input, shares);
131
+ if (interval.shared && shares.some(value => value > 0))
132
+ result.sharedTurns++;
133
+ eventBuckets.forEach((bucket, i) => {
134
+ allocate(bucket, 'input', inputs[i]);
135
+ allocate(bucket, 'cacheCreation', shares[i] - inputs[i]);
136
+ });
137
+ }
138
+ }
139
+ }
140
+ }
141
+ for (const category of tokenCategories)
142
+ allocate('unattributed', category, remaining[category]);
143
+ turn.uncertainties.forEach(value => uncertainties.add(value));
144
+ sessions.set(turn.sessionId, { prefix, previousOutput: turn.outputTokens });
145
+ result.turnAttributions.push({ turnId: turn.id, sessionId: turn.sessionId, buckets: turnBuckets, totalTokens });
146
+ }
147
+ if (byBucket.get('unattributed').tokens)
148
+ uncertainties.add('unattributed_usage_includes_prior_output_and_missing_event_evidence');
149
+ result.uncertainties = [...uncertainties].sort();
150
+ return result;
151
+ }
@@ -0,0 +1,53 @@
1
+ import type { Policy } from '../types';
2
+ import type { InsightHost, InsightParserState, InsightTurn } from './types';
3
+ import { type PricingTable } from './pricing';
4
+ import { type Pace, type UsageWindow, type LimitForecast } from './pace';
5
+ export interface InsightSettings {
6
+ rewriteWarnDollarsPerHour?: number;
7
+ heavyTurnTokens?: number;
8
+ pricingFile?: string;
9
+ }
10
+ export interface LiveSnapshot {
11
+ schema: 1;
12
+ host: InsightHost;
13
+ sessionId: string;
14
+ updatedAt: number;
15
+ toolEvents: number;
16
+ cursor: {
17
+ offset: number;
18
+ inode: number;
19
+ pathDigest: string;
20
+ };
21
+ parser?: InsightParserState;
22
+ pace: Pace;
23
+ windows: UsageWindow[];
24
+ forecast: LimitForecast;
25
+ lastTurnTokens: number;
26
+ rewriteDollarsToday: {
27
+ min: number;
28
+ max: number;
29
+ } | null;
30
+ unpricedRewritesToday: number;
31
+ heavyAbove: boolean;
32
+ rewriteAbove: boolean;
33
+ }
34
+ export interface LiveInput {
35
+ session_id?: string;
36
+ transcript_path?: string;
37
+ tool_name?: string;
38
+ rate_limits?: unknown;
39
+ }
40
+ export declare function pacePath(home: string, sessionId: string): string;
41
+ export declare function readPricing(home: string, policy?: Policy): PricingTable;
42
+ export declare function dollars(value: {
43
+ min: number;
44
+ max: number;
45
+ } | null): string;
46
+ export declare function hostWindows(value: unknown, source: UsageWindow['source'], at: number): UsageWindow[];
47
+ export declare function heavyTurnMessage(turn: InsightTurn, rates: PricingTable): string;
48
+ /** Local advisory observer. No tool input or output content enters this API. */
49
+ export declare function observeTool(home: string, input: LiveInput, host: InsightHost, policy: Policy, now?: number): {
50
+ snapshot?: LiveSnapshot;
51
+ messages: string[];
52
+ };
53
+ export declare function renderStatusLine(snapshot: LiveSnapshot): string;