@agentguard-run/burn 0.2.3 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -2
- package/README.md +105 -20
- package/dist/src/adapters/codex.js +11 -1
- package/dist/src/adapters/cursor.js +2 -2
- package/dist/src/calibrate.js +2 -3
- package/dist/src/cli.js +53 -15
- package/dist/src/conformance.d.ts +5 -2
- package/dist/src/conformance.js +30 -17
- package/dist/src/defaults.d.ts +7 -4
- package/dist/src/defaults.js +9 -6
- package/dist/src/detectors/evaluate.d.ts +4 -5
- package/dist/src/detectors/evaluate.js +13 -11
- 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 +4 -8
- package/dist/src/history/claude-transcript.d.ts +20 -2
- package/dist/src/history/claude-transcript.js +56 -15
- package/dist/src/hook/pre-tool-use.d.ts +13 -9
- package/dist/src/hook/pre-tool-use.js +63 -31
- package/dist/src/insights/attribution.d.ts +4 -0
- package/dist/src/insights/attribution.js +151 -0
- package/dist/src/insights/blocks.d.ts +61 -0
- package/dist/src/insights/blocks.js +243 -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 +14 -0
- package/dist/src/insights/transcript.js +505 -0
- package/dist/src/insights/types.d.ts +164 -0
- package/dist/src/insights/types.js +4 -0
- package/dist/src/install.js +14 -5
- package/dist/src/policy.d.ts +4 -0
- package/dist/src/policy.js +57 -0
- package/dist/src/replay/render.js +4 -2
- package/dist/src/replay/simulate.d.ts +5 -0
- package/dist/src/replay/simulate.js +23 -8
- package/dist/src/state/reservations.d.ts +7 -5
- package/dist/src/state/reservations.js +60 -45
- package/dist/src/state/spawn-window.d.ts +10 -0
- package/dist/src/state/spawn-window.js +25 -0
- package/dist/src/types.d.ts +8 -1
- package/docs/USAGE_AND_PRICING.md +132 -0
- package/fixtures/usage-dedup-session/subagents/agent-synthetic-first.jsonl +5 -0
- package/fixtures/usage-dedup-session/subagents/agent-synthetic-second.jsonl +4 -0
- package/fixtures/usage-dedup-session.jsonl +4 -0
- package/package.json +4 -3
|
@@ -6,20 +6,20 @@
|
|
|
6
6
|
* failure modes:
|
|
7
7
|
*
|
|
8
8
|
* structural - fan-out. Many agents, each re-sending context. The 190-spawn
|
|
9
|
-
* session. Caught by
|
|
9
|
+
* session. Caught by spawn windows over active time.
|
|
10
10
|
* economic - sustained burn. Few agents, long session, enormous total. The
|
|
11
11
|
* 9.15B session with only 26 spawns, which a spawn cap cannot
|
|
12
12
|
* see. Caught by a cumulative token ceiling and burn debt.
|
|
13
13
|
*
|
|
14
|
-
* Verdict is the maximum severity across findings.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* safety tool uninstalled.
|
|
14
|
+
* Verdict is the maximum severity across findings. Lifetime fan-out and
|
|
15
|
+
* duplicate work are advisory; only a recent burst, depth or economic
|
|
16
|
+
* threshold can stop new work. Spawn rate can be made advisory by policy.
|
|
18
17
|
*/
|
|
19
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
19
|
exports.evaluate = evaluate;
|
|
21
20
|
exports.fmt = fmt;
|
|
22
21
|
const account_1 = require("../state/account");
|
|
22
|
+
const defaults_1 = require("../defaults");
|
|
23
23
|
const session_1 = require("../state/session");
|
|
24
24
|
const RANK = { OK: 0, WARN: 1, STOP: 2 };
|
|
25
25
|
function worst(a, b) {
|
|
@@ -36,12 +36,14 @@ function evaluate(state, thresholds, proposedSpawnDepth = null, account) {
|
|
|
36
36
|
// ---- structural plane: fan-out --------------------------------------
|
|
37
37
|
// Count the proposal itself. "Allow through 40, deny candidate 41."
|
|
38
38
|
const effectiveSpawns = state.spawnCount + (proposedSpawnDepth !== null ? 1 : 0);
|
|
39
|
-
|
|
39
|
+
const fanoutWindow = thresholds.fanout.windowActiveMinutes ?? defaults_1.DEFAULT_THRESHOLDS.fanout.windowActiveMinutes;
|
|
40
|
+
const recentFanout = (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, fanoutWindow) + (proposedSpawnDepth !== null ? 1 : 0);
|
|
41
|
+
if (recentFanout > thresholds.fanout.stop) {
|
|
40
42
|
findings.push({
|
|
41
43
|
detector: 'fanout',
|
|
42
44
|
verdict: 'STOP',
|
|
43
|
-
summary: `Spawn ${
|
|
44
|
-
observed:
|
|
45
|
+
summary: `Spawn ${recentFanout} would exceed the fan-out ceiling of ${thresholds.fanout.stop}.`,
|
|
46
|
+
observed: recentFanout,
|
|
45
47
|
threshold: thresholds.fanout.stop,
|
|
46
48
|
});
|
|
47
49
|
verdict = worst(verdict, 'STOP');
|
|
@@ -110,8 +112,8 @@ function evaluate(state, thresholds, proposedSpawnDepth = null, account) {
|
|
|
110
112
|
verdict = worst(verdict, 'WARN');
|
|
111
113
|
}
|
|
112
114
|
}
|
|
113
|
-
// ----
|
|
114
|
-
const recentSpawns = (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, thresholds.spawnRate.windowActiveMinutes);
|
|
115
|
+
// ---- structural plane: spawn rate over active time -----------------
|
|
116
|
+
const recentSpawns = (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, thresholds.spawnRate.windowActiveMinutes) + (proposedSpawnDepth !== null ? 1 : 0);
|
|
115
117
|
if (recentSpawns >= thresholds.spawnRate.stop) {
|
|
116
118
|
const severity = thresholds.spawnRate.enforce ? 'STOP' : 'WARN';
|
|
117
119
|
findings.push({
|
|
@@ -199,7 +201,7 @@ function prescribe(state, findings, t) {
|
|
|
199
201
|
out.push(`Let the ${Math.min(state.spawnCount, 4)} most useful running agents finish; do not replace them.`);
|
|
200
202
|
}
|
|
201
203
|
else if (has('fanout')) {
|
|
202
|
-
out.push(`You are at ${state.spawnCount} spawns; ${t.fanout.stop - state.
|
|
204
|
+
out.push(`You are at ${state.spawnCount} spawns; ${Math.max(0, t.fanout.stop - (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, t.fanout.windowActiveMinutes ?? defaults_1.DEFAULT_THRESHOLDS.fanout.windowActiveMinutes))} remain before the ceiling. Plan for it.`);
|
|
203
205
|
}
|
|
204
206
|
if (state.maxDepth >= 2) {
|
|
205
207
|
out.push('Agents are spawning agents. Make the root orchestrator the only process allowed to spawn.');
|
|
@@ -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
|
+
}
|
package/dist/src/gateway.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/gateway.js
CHANGED
|
@@ -41,6 +41,7 @@ const pre_tool_use_1 = require("./hook/pre-tool-use");
|
|
|
41
41
|
const override_1 = require("./override");
|
|
42
42
|
const receipt_1 = require("./receipt");
|
|
43
43
|
const reservations_1 = require("./state/reservations");
|
|
44
|
+
const spawn_window_1 = require("./state/spawn-window");
|
|
44
45
|
const account_1 = require("./state/account");
|
|
45
46
|
const session_1 = require("./state/session");
|
|
46
47
|
const MAX_SEEN_EVENTS = 4000;
|
|
@@ -50,6 +51,8 @@ function safeName(sessionId) {
|
|
|
50
51
|
}
|
|
51
52
|
class Gateway {
|
|
52
53
|
home;
|
|
54
|
+
/** Shared local storage for optional content-free usage observations. */
|
|
55
|
+
get dataDirectory() { return this.home; }
|
|
53
56
|
store;
|
|
54
57
|
signer;
|
|
55
58
|
now;
|
|
@@ -215,14 +218,7 @@ class Gateway {
|
|
|
215
218
|
}
|
|
216
219
|
const live = new Map(meta.liveSpawns);
|
|
217
220
|
const proposedDepth = event.proposedDepth ?? (event.issuerId !== undefined && live.has(event.issuerId) ? live.get(event.issuerId) + 1 : 1);
|
|
218
|
-
const report = (0,
|
|
219
|
-
const reservation = tx.reserve({
|
|
220
|
-
sessionId: event.sessionId,
|
|
221
|
-
toolUseId: event.spawnId,
|
|
222
|
-
observedSpawns: state.spawnCount,
|
|
223
|
-
ceiling: policy.thresholds.fanout.stop,
|
|
224
|
-
now: event.at,
|
|
225
|
-
});
|
|
221
|
+
const { report, reservation } = (0, spawn_window_1.evaluateSpawnReservation)(tx, state, policy.thresholds, proposedDepth, event.spawnId, event.at, { sessions: (0, account_1.readAccountSessions)(this.home, state, event.at), now: event.at });
|
|
226
222
|
const wouldBlock = report.verdict === 'STOP' || !reservation.allowed;
|
|
227
223
|
const verdict = wouldBlock ? 'STOP' : report.verdict;
|
|
228
224
|
// Session-scope STOPs need a session we trust. Fan-out is session scope.
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* truncated or replaced file resets the cursor rather than reading garbage.
|
|
13
13
|
*/
|
|
14
14
|
import type { BurnEvent } from '../types';
|
|
15
|
+
/** Component maxima for one provider response, never transcript contents. */
|
|
16
|
+
export type UsageSnapshot = [input: number, output: number, cacheCreation: number, cacheRead: number];
|
|
15
17
|
export interface ReaderCursor {
|
|
16
18
|
/** Byte just after the last complete newline we processed. */
|
|
17
19
|
offset: number;
|
|
@@ -20,6 +22,11 @@ export interface ReaderCursor {
|
|
|
20
22
|
malformedLines: number;
|
|
21
23
|
/** Resolved depth per line uuid, for spawn-depth attribution. */
|
|
22
24
|
depthByUuid: Map<string, number>;
|
|
25
|
+
/** Optional and additive so callers with pre-0.2.6 cursors still load. */
|
|
26
|
+
usageVersion?: 1;
|
|
27
|
+
usageByMessage?: Map<string, UsageSnapshot>;
|
|
28
|
+
/** Child file cursors share usageByMessage with their parent. */
|
|
29
|
+
children?: Map<string, ReaderCursor>;
|
|
23
30
|
}
|
|
24
31
|
export declare function newCursor(): ReaderCursor;
|
|
25
32
|
interface RawLine {
|
|
@@ -28,6 +35,7 @@ interface RawLine {
|
|
|
28
35
|
parentUuid?: string;
|
|
29
36
|
isSidechain?: boolean;
|
|
30
37
|
message?: {
|
|
38
|
+
id?: unknown;
|
|
31
39
|
usage?: Record<string, number | undefined>;
|
|
32
40
|
content?: unknown;
|
|
33
41
|
};
|
|
@@ -39,8 +47,18 @@ export declare function normaliseLine(raw: RawLine, cursor: ReaderCursor): BurnE
|
|
|
39
47
|
* and advances the cursor. O(new bytes), never O(file size) after the first
|
|
40
48
|
* read.
|
|
41
49
|
*/
|
|
42
|
-
export
|
|
43
|
-
/**
|
|
50
|
+
export interface ReadOptions {
|
|
51
|
+
/** Legacy-state rebuilds must not replace totals after an unreadable file. */
|
|
52
|
+
throwOnOpenError?: boolean;
|
|
53
|
+
}
|
|
54
|
+
export declare function readIncremental(path: string, cursor: ReaderCursor, options?: ReadOptions): BurnEvent[];
|
|
55
|
+
/**
|
|
56
|
+
* Fold the parent's tool events and its stored children's usage into one
|
|
57
|
+
* session. Child histories can contain copied parent tool calls, so only their
|
|
58
|
+
* usage deltas contribute here. The parent's observed spawn count stays intact.
|
|
59
|
+
*/
|
|
60
|
+
export declare function readSessionIncremental(path: string, cursor: ReaderCursor, options?: ReadOptions): BurnEvent[];
|
|
61
|
+
/** Convenience for replay and tests: read a session and its own stored children. */
|
|
44
62
|
export declare function readAll(path: string): {
|
|
45
63
|
events: BurnEvent[];
|
|
46
64
|
cursor: ReaderCursor;
|
|
@@ -16,14 +16,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
16
16
|
exports.newCursor = newCursor;
|
|
17
17
|
exports.normaliseLine = normaliseLine;
|
|
18
18
|
exports.readIncremental = readIncremental;
|
|
19
|
+
exports.readSessionIncremental = readSessionIncremental;
|
|
19
20
|
exports.readAll = readAll;
|
|
20
21
|
const node_fs_1 = require("node:fs");
|
|
22
|
+
const node_path_1 = require("node:path");
|
|
23
|
+
const transcript_1 = require("../insights/transcript");
|
|
21
24
|
const CHUNK = 64 * 1024;
|
|
22
25
|
const MAX_LINE = 4 * 1024 * 1024;
|
|
23
26
|
const SPAWN_TOOLS = new Set(['Agent', 'Task']);
|
|
24
27
|
const SURFACE_TOOLS = new Set(['Read', 'Grep', 'Glob']);
|
|
25
28
|
function newCursor() {
|
|
26
|
-
return { offset: 0, size: 0, malformedLines: 0, depthByUuid: new Map() };
|
|
29
|
+
return { offset: 0, size: 0, malformedLines: 0, depthByUuid: new Map(), usageVersion: 1, usageByMessage: new Map() };
|
|
27
30
|
}
|
|
28
31
|
function toMillis(value) {
|
|
29
32
|
if (typeof value !== 'string' || value.length === 0)
|
|
@@ -40,11 +43,22 @@ function normaliseLine(raw, cursor) {
|
|
|
40
43
|
if (at === null)
|
|
41
44
|
return null;
|
|
42
45
|
const usage = raw.message?.usage ?? {};
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
const snapshot = [nonNegative(usage.input_tokens), nonNegative(usage.output_tokens),
|
|
47
|
+
nonNegative(usage.cache_creation_input_tokens), nonNegative(usage.cache_read_input_tokens)];
|
|
48
|
+
let tokens = snapshot.reduce((sum, value) => sum + value, 0);
|
|
49
|
+
let cacheRead = snapshot[3];
|
|
50
|
+
// Claude writes one row per content block, all carrying the same message.id.
|
|
51
|
+
// Fork copies preserve that id too. Match the attribution parser's global
|
|
52
|
+
// identity and per-category maxima, emitting only newly observed usage.
|
|
53
|
+
if (raw.message?.id !== undefined && tokens > 0) {
|
|
54
|
+
const id = (0, transcript_1.providerResponseIdentity)('claude', raw.message.id);
|
|
55
|
+
cursor.usageByMessage ??= new Map();
|
|
56
|
+
const previous = cursor.usageByMessage.get(id) ?? [0, 0, 0, 0];
|
|
57
|
+
const merged = snapshot.map((value, index) => Math.max(value, previous[index]));
|
|
58
|
+
tokens = merged.reduce((sum, value, index) => sum + value - previous[index], 0);
|
|
59
|
+
cacheRead = merged[3] - previous[3];
|
|
60
|
+
cursor.usageByMessage.set(id, merged);
|
|
61
|
+
}
|
|
48
62
|
// Depth attribution: a line's depth is its parent's depth, plus one if the
|
|
49
63
|
// host flagged it as a sidechain (subagent) line. Unknown parents are root.
|
|
50
64
|
const parentDepth = raw.parentUuid ? cursor.depthByUuid.get(raw.parentUuid) ?? 0 : 0;
|
|
@@ -91,17 +105,14 @@ function normaliseLine(raw, cursor) {
|
|
|
91
105
|
parentUuid: raw.parentUuid,
|
|
92
106
|
};
|
|
93
107
|
}
|
|
94
|
-
|
|
95
|
-
* Read every complete line appended since the cursor. Returns the new events
|
|
96
|
-
* and advances the cursor. O(new bytes), never O(file size) after the first
|
|
97
|
-
* read.
|
|
98
|
-
*/
|
|
99
|
-
function readIncremental(path, cursor) {
|
|
108
|
+
function readIncremental(path, cursor, options = {}) {
|
|
100
109
|
let fd;
|
|
101
110
|
try {
|
|
102
111
|
fd = (0, node_fs_1.openSync)(path, 'r');
|
|
103
112
|
}
|
|
104
|
-
catch {
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (options.throwOnOpenError)
|
|
115
|
+
throw error;
|
|
105
116
|
return [];
|
|
106
117
|
}
|
|
107
118
|
try {
|
|
@@ -167,9 +178,39 @@ function readIncremental(path, cursor) {
|
|
|
167
178
|
(0, node_fs_1.closeSync)(fd);
|
|
168
179
|
}
|
|
169
180
|
}
|
|
170
|
-
/**
|
|
181
|
+
/**
|
|
182
|
+
* Fold the parent's tool events and its stored children's usage into one
|
|
183
|
+
* session. Child histories can contain copied parent tool calls, so only their
|
|
184
|
+
* usage deltas contribute here. The parent's observed spawn count stays intact.
|
|
185
|
+
*/
|
|
186
|
+
function readSessionIncremental(path, cursor, options = {}) {
|
|
187
|
+
cursor.usageByMessage ??= new Map();
|
|
188
|
+
cursor.children ??= new Map();
|
|
189
|
+
const events = readIncremental(path, cursor, options);
|
|
190
|
+
const directory = (0, node_path_1.join)(path.replace(/\.jsonl$/, ''), 'subagents');
|
|
191
|
+
let names;
|
|
192
|
+
try {
|
|
193
|
+
names = (0, node_fs_1.readdirSync)(directory).filter(name => name.endsWith('.jsonl')).sort();
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (error.code === 'ENOENT')
|
|
197
|
+
return events;
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
for (const name of names) {
|
|
201
|
+
const child = cursor.children.get(name) ?? newCursor();
|
|
202
|
+
child.usageByMessage = cursor.usageByMessage;
|
|
203
|
+
cursor.children.set(name, child);
|
|
204
|
+
for (const event of readIncremental((0, node_path_1.join)(directory, name), child, options)) {
|
|
205
|
+
if (event.tokens > 0 || event.cacheRead > 0)
|
|
206
|
+
events.push({ ...event, spawns: [], surfaces: [], sidechain: true });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return events.sort((a, b) => a.at - b.at);
|
|
210
|
+
}
|
|
211
|
+
/** Convenience for replay and tests: read a session and its own stored children. */
|
|
171
212
|
function readAll(path) {
|
|
172
213
|
const cursor = newCursor();
|
|
173
|
-
const events =
|
|
214
|
+
const events = readSessionIncremental(path, cursor);
|
|
174
215
|
return { events, cursor };
|
|
175
216
|
}
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* cannot fail closed if Claude Code times the hook out, so the hot path does
|
|
15
15
|
* as little as possible.
|
|
16
16
|
*/
|
|
17
|
-
import { type ReaderCursor } from '../history/claude-transcript';
|
|
18
|
-
import type { BurnReport,
|
|
17
|
+
import { type ReaderCursor, type UsageSnapshot } from '../history/claude-transcript';
|
|
18
|
+
import type { BurnReport, SessionState } from '../types';
|
|
19
19
|
export interface HookInput {
|
|
20
20
|
session_id?: string;
|
|
21
21
|
transcript_path?: string;
|
|
@@ -35,13 +35,17 @@ export interface HookOutput {
|
|
|
35
35
|
permissionDecisionReason: string;
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
|
+
export interface PersistedReaderCursor {
|
|
39
|
+
offset: number;
|
|
40
|
+
size: number;
|
|
41
|
+
malformedLines: number;
|
|
42
|
+
depthByUuid: [string, number][];
|
|
43
|
+
usageVersion?: 1;
|
|
44
|
+
usageByMessage?: [string, UsageSnapshot][];
|
|
45
|
+
children?: [string, PersistedReaderCursor][];
|
|
46
|
+
}
|
|
38
47
|
export interface PersistedSession {
|
|
39
|
-
cursor:
|
|
40
|
-
offset: number;
|
|
41
|
-
size: number;
|
|
42
|
-
malformedLines: number;
|
|
43
|
-
depthByUuid: [string, number][];
|
|
44
|
-
};
|
|
48
|
+
cursor: PersistedReaderCursor;
|
|
45
49
|
state: Omit<SessionState, 'tokensByActiveMinute' | 'spawnsByActiveMinute' | 'surfaceReaders'> & {
|
|
46
50
|
tokensByActiveMinute: [number, number][];
|
|
47
51
|
spawnsByActiveMinute: [number, number][];
|
|
@@ -60,7 +64,7 @@ export declare function inflateHookSession(raw: PersistedSession): SessionState;
|
|
|
60
64
|
* and the one that matters is the nineteenth.
|
|
61
65
|
*/
|
|
62
66
|
export declare function findingSignature(report: BurnReport): string;
|
|
63
|
-
export
|
|
67
|
+
export { loadPolicy } from '../policy';
|
|
64
68
|
/** Refresh session state from the transcript. Cheap: only new bytes are read. */
|
|
65
69
|
export declare function refreshSession(home: string, sessionId: string, transcriptPath: string): {
|
|
66
70
|
cursor: ReaderCursor;
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
* as little as possible.
|
|
17
17
|
*/
|
|
18
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.loadPolicy = void 0;
|
|
19
20
|
exports.inflateHookSession = inflateHookSession;
|
|
20
21
|
exports.findingSignature = findingSignature;
|
|
21
|
-
exports.loadPolicy = loadPolicy;
|
|
22
22
|
exports.refreshSession = refreshSession;
|
|
23
23
|
exports.handlePreToolUse = handlePreToolUse;
|
|
24
24
|
exports.settingsSnippet = settingsSnippet;
|
|
@@ -28,13 +28,14 @@ const receipt_1 = require("../receipt");
|
|
|
28
28
|
const account_1 = require("../state/account");
|
|
29
29
|
const node_fs_1 = require("node:fs");
|
|
30
30
|
const node_path_1 = require("node:path");
|
|
31
|
-
const
|
|
31
|
+
const spawn_window_1 = require("../state/spawn-window");
|
|
32
32
|
const claude_transcript_1 = require("../history/claude-transcript");
|
|
33
33
|
const reservations_1 = require("../state/reservations");
|
|
34
34
|
const session_1 = require("../state/session");
|
|
35
|
-
const
|
|
35
|
+
const policy_1 = require("../policy");
|
|
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`);
|
|
@@ -48,20 +49,31 @@ function inflateHookSession(raw) {
|
|
|
48
49
|
surfaceReaders: new Map(raw.state.surfaceReaders.map(([k, v]) => [k, new Set(v)])),
|
|
49
50
|
};
|
|
50
51
|
}
|
|
52
|
+
function inflateCursor(raw, shared) {
|
|
53
|
+
const usageByMessage = shared ?? new Map(raw.usageByMessage ?? []);
|
|
54
|
+
return { ...raw, depthByUuid: new Map(raw.depthByUuid), usageByMessage,
|
|
55
|
+
children: new Map((raw.children ?? []).map(([name, child]) => [name, inflateCursor(child, usageByMessage)])) };
|
|
56
|
+
}
|
|
57
|
+
function persistCursor(cursor, child = false) {
|
|
58
|
+
return { offset: cursor.offset, size: cursor.size, malformedLines: cursor.malformedLines, depthByUuid: [...cursor.depthByUuid],
|
|
59
|
+
usageVersion: cursor.usageVersion,
|
|
60
|
+
...(!child ? { usageByMessage: [...(cursor.usageByMessage ?? [])] } : {}),
|
|
61
|
+
...(cursor.children?.size ? { children: [...cursor.children].map(([name, value]) => [name, persistCursor(value, true)]) } : {}) };
|
|
62
|
+
}
|
|
51
63
|
function loadSession(home, sessionId, firstEventAt) {
|
|
52
64
|
try {
|
|
53
65
|
const raw = JSON.parse((0, node_fs_1.readFileSync)(sessionFile(home, sessionId), 'utf8'));
|
|
54
|
-
|
|
55
|
-
|
|
66
|
+
return { cursor: inflateCursor(raw.cursor), state: inflateHookSession(raw), notified: raw.notified ?? '', lastReceipt: raw.lastReceipt ?? null,
|
|
67
|
+
fresh: false, needsRebuild: raw.cursor.usageVersion !== 1 || !Array.isArray(raw.cursor.usageByMessage) };
|
|
56
68
|
}
|
|
57
69
|
catch {
|
|
58
|
-
return { cursor: (0, claude_transcript_1.newCursor)(), state: (0, session_1.newSessionState)(sessionId, firstEventAt), notified: '', lastReceipt: null };
|
|
70
|
+
return { cursor: (0, claude_transcript_1.newCursor)(), state: (0, session_1.newSessionState)(sessionId, firstEventAt), notified: '', lastReceipt: null, fresh: true, needsRebuild: false };
|
|
59
71
|
}
|
|
60
72
|
}
|
|
61
73
|
function saveSession(home, cursor, state, notified, lastReceipt) {
|
|
62
74
|
(0, node_fs_1.mkdirSync)((0, node_path_1.join)(home, 'sessions'), { recursive: true, mode: 0o700 });
|
|
63
75
|
const persisted = {
|
|
64
|
-
cursor:
|
|
76
|
+
cursor: persistCursor(cursor),
|
|
65
77
|
state: {
|
|
66
78
|
...state,
|
|
67
79
|
tokensByActiveMinute: [...state.tokensByActiveMinute],
|
|
@@ -87,32 +99,52 @@ function findingSignature(report) {
|
|
|
87
99
|
.sort()
|
|
88
100
|
.join('|');
|
|
89
101
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const parsed = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(home, 'burn-policy.json'), 'utf8'));
|
|
93
|
-
if (parsed && (parsed.mode === 'shadow' || parsed.mode === 'enforce') && parsed.thresholds)
|
|
94
|
-
return parsed;
|
|
95
|
-
}
|
|
96
|
-
catch {
|
|
97
|
-
/* no policy yet: shadow defaults */
|
|
98
|
-
}
|
|
99
|
-
return defaults_1.DEFAULT_POLICY;
|
|
100
|
-
}
|
|
102
|
+
var policy_2 = require("../policy");
|
|
103
|
+
Object.defineProperty(exports, "loadPolicy", { enumerable: true, get: function () { return policy_2.loadPolicy; } });
|
|
101
104
|
function recordDecision(home, entry) {
|
|
102
105
|
(0, node_fs_1.mkdirSync)(home, { recursive: true, mode: 0o700 });
|
|
103
106
|
(0, node_fs_1.appendFileSync)((0, node_path_1.join)(home, 'decisions.ndjson'), `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
104
107
|
}
|
|
105
108
|
/** Refresh session state from the transcript. Cheap: only new bytes are read. */
|
|
106
109
|
function refreshSession(home, sessionId, transcriptPath) {
|
|
107
|
-
const
|
|
110
|
+
const loaded = loadSession(home, sessionId, Date.now());
|
|
111
|
+
let { cursor, state, fresh } = loaded;
|
|
112
|
+
const { notified, lastReceipt } = loaded;
|
|
108
113
|
const before = state.spawnCount;
|
|
109
|
-
|
|
114
|
+
// Old offsets cannot tell us which repeated rows inflated their totals.
|
|
115
|
+
// Rebuild once from locally recorded evidence, retaining the signed chain
|
|
116
|
+
// head and notification state. Never replace old counts with an empty read
|
|
117
|
+
// when the transcript has disappeared or cannot be opened.
|
|
118
|
+
if (loaded.needsRebuild) {
|
|
119
|
+
try {
|
|
120
|
+
(0, node_fs_1.accessSync)(transcriptPath, node_fs_1.constants.R_OK);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return { cursor, state, newSpawns: 0, notified };
|
|
124
|
+
}
|
|
125
|
+
cursor = (0, claude_transcript_1.newCursor)();
|
|
126
|
+
state = (0, session_1.newSessionState)(sessionId, Date.now());
|
|
127
|
+
fresh = true;
|
|
128
|
+
}
|
|
129
|
+
let events;
|
|
130
|
+
try {
|
|
131
|
+
events = (0, claude_transcript_1.readSessionIncremental)(transcriptPath, cursor, { throwOnOpenError: loaded.needsRebuild });
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (!loaded.needsRebuild)
|
|
135
|
+
throw error;
|
|
136
|
+
// Opening can fail after the access check, including on a stored child.
|
|
137
|
+
// Retain the old cursor so the next refresh retries the complete rebuild.
|
|
138
|
+
return { cursor: loaded.cursor, state: loaded.state, newSpawns: 0, notified };
|
|
139
|
+
}
|
|
140
|
+
if (fresh && events.length > 0)
|
|
141
|
+
state = (0, session_1.newSessionState)(sessionId, events[0].at);
|
|
110
142
|
for (const event of events)
|
|
111
143
|
(0, session_1.applyEvent)(state, event);
|
|
112
144
|
if (events.length > 0 && state.startedAt > events[0].at)
|
|
113
145
|
state.startedAt = events[0].at;
|
|
114
146
|
saveSession(home, cursor, state, notified, lastReceipt);
|
|
115
|
-
return { cursor, state, newSpawns: state.spawnCount - before, notified };
|
|
147
|
+
return { cursor, state, newSpawns: Math.max(0, state.spawnCount - before), notified };
|
|
116
148
|
}
|
|
117
149
|
function rememberNotified(home, sessionId, signature) {
|
|
118
150
|
try {
|
|
@@ -127,11 +159,18 @@ function rememberNotified(home, sessionId, signature) {
|
|
|
127
159
|
}
|
|
128
160
|
}
|
|
129
161
|
function handlePreToolUse(input, home, now = Date.now()) {
|
|
162
|
+
const observation = (0, live_1.observeTool)(home, input, 'claude', (0, policy_1.loadPolicy)(home), now);
|
|
163
|
+
const output = handleSpawnPreToolUse(input, home, now);
|
|
164
|
+
if (!observation.messages.length)
|
|
165
|
+
return output;
|
|
166
|
+
return { ...output, suppressOutput: false, systemMessage: [output.systemMessage, ...observation.messages].filter(Boolean).join('\n') };
|
|
167
|
+
}
|
|
168
|
+
function handleSpawnPreToolUse(input, home, now) {
|
|
130
169
|
const toolName = input.tool_name ?? '';
|
|
131
170
|
if (!SPAWN_TOOLS.has(toolName) || !input.session_id || !input.transcript_path) {
|
|
132
171
|
return { continue: true, suppressOutput: true };
|
|
133
172
|
}
|
|
134
|
-
const policy = loadPolicy(home);
|
|
173
|
+
const policy = (0, policy_1.loadPolicy)(home);
|
|
135
174
|
const store = new reservations_1.ReservationStore(home);
|
|
136
175
|
try {
|
|
137
176
|
const signer = receipt_1.ReceiptSigner.loadOrCreate(home);
|
|
@@ -144,14 +183,7 @@ function handlePreToolUse(input, home, now = Date.now()) {
|
|
|
144
183
|
// Depth of the proposed child: the issuing agent's depth plus one. A hook
|
|
145
184
|
// fired inside a subagent carries agent_id; treat that as depth 1 issuer.
|
|
146
185
|
const proposedDepth = input.agent_id ? 2 : 1;
|
|
147
|
-
const report = (0,
|
|
148
|
-
const reservation = tx.reserve({
|
|
149
|
-
sessionId: input.session_id,
|
|
150
|
-
toolUseId: input.tool_use_id ?? `${input.session_id}:${now}`,
|
|
151
|
-
observedSpawns: state.spawnCount,
|
|
152
|
-
ceiling: policy.thresholds.fanout.stop,
|
|
153
|
-
now,
|
|
154
|
-
});
|
|
186
|
+
const { report, reservation } = (0, spawn_window_1.evaluateSpawnReservation)(tx, state, policy.thresholds, proposedDepth, input.tool_use_id ?? `${input.session_id}:${now}`, now, { sessions: (0, account_1.readAccountSessions)(home, state, now), now });
|
|
155
187
|
const shouldDeny = report.verdict === 'STOP' || !reservation.allowed;
|
|
156
188
|
const reason = shouldDeny ? buildDenyReason(report, reservation) : '';
|
|
157
189
|
// The audited override: only consulted when a block is about to happen.
|
|
@@ -231,7 +263,7 @@ function deny(reason) {
|
|
|
231
263
|
function settingsSnippet(command) {
|
|
232
264
|
return {
|
|
233
265
|
hooks: {
|
|
234
|
-
PreToolUse: [{ matcher: '
|
|
266
|
+
PreToolUse: [{ matcher: '.*', hooks: [{ type: 'command', command, timeout: 15 }] }],
|
|
235
267
|
},
|
|
236
268
|
};
|
|
237
269
|
}
|
|
@@ -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;
|