@agentguard-run/burn 0.1.0

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.
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ /**
3
+ * Session state: fold events into the numbers detectors need.
4
+ *
5
+ * Active time is the key idea. These sessions are resumed over days. A
6
+ * wall-clock rolling window dilutes real activity with hours of idleness, so a
7
+ * "16 spawns in 5 minutes" rule never fires on a session that spawned 143
8
+ * agents over an evening with coffee breaks. Active time caps every gap
9
+ * between events, so a burst looks like a burst regardless of when the session
10
+ * started.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.newSessionState = newSessionState;
14
+ exports.applyEvent = applyEvent;
15
+ exports.windowSum = windowSum;
16
+ exports.medianCompletedMinute = medianCompletedMinute;
17
+ exports.cacheReadRatio = cacheReadRatio;
18
+ const defaults_1 = require("../defaults");
19
+ function newSessionState(sessionId, firstEventAt) {
20
+ return {
21
+ sessionId,
22
+ startedAt: firstEventAt,
23
+ lastEventAt: firstEventAt,
24
+ totalTokens: 0,
25
+ totalCacheRead: 0,
26
+ spawnCount: 0,
27
+ maxDepth: 0,
28
+ activeMinutes: 0,
29
+ tokensByActiveMinute: new Map(),
30
+ spawnsByActiveMinute: new Map(),
31
+ surfaceReaders: new Map(),
32
+ burnDebt: 0,
33
+ lastDebtEventAt: firstEventAt,
34
+ };
35
+ }
36
+ /** Apply one event. Returns the active minutes that elapsed for debt accounting. */
37
+ function applyEvent(state, event) {
38
+ const gap = Math.max(0, event.at - state.lastEventAt);
39
+ const activeGapMs = Math.min(gap, defaults_1.ACTIVE_GAP_CAP_MS);
40
+ const activeGapMinutes = activeGapMs / 60_000;
41
+ state.activeMinutes += activeGapMinutes;
42
+ state.lastEventAt = Math.max(state.lastEventAt, event.at);
43
+ const bucket = Math.floor(state.activeMinutes);
44
+ state.totalTokens += event.tokens;
45
+ state.totalCacheRead += event.cacheRead;
46
+ if (event.tokens > 0) {
47
+ state.tokensByActiveMinute.set(bucket, (state.tokensByActiveMinute.get(bucket) ?? 0) + event.tokens);
48
+ }
49
+ for (const spawn of event.spawns) {
50
+ state.spawnCount += 1;
51
+ state.spawnsByActiveMinute.set(bucket, (state.spawnsByActiveMinute.get(bucket) ?? 0) + 1);
52
+ // The child will live one level below the issuer.
53
+ state.maxDepth = Math.max(state.maxDepth, spawn.issuerDepth + 1);
54
+ }
55
+ for (const surface of event.surfaces) {
56
+ const readers = state.surfaceReaders.get(surface) ?? new Set();
57
+ // Attribute to the current spawn era: a cheap proxy for "distinct agent"
58
+ // that is honest about its imprecision. Duplicate work is WARN-only for
59
+ // exactly this reason.
60
+ readers.add(state.spawnCount);
61
+ state.surfaceReaders.set(surface, readers);
62
+ }
63
+ return activeGapMinutes;
64
+ }
65
+ /** Sum of a rolling window over the last N active minutes. */
66
+ function windowSum(byMinute, activeMinutes, windowMinutes) {
67
+ const from = Math.max(0, Math.floor(activeMinutes) - windowMinutes);
68
+ let total = 0;
69
+ for (const [bucket, value] of byMinute) {
70
+ if (bucket >= from)
71
+ total += value;
72
+ }
73
+ return total;
74
+ }
75
+ /** Median of completed, nonzero active minutes. The current partial minute is excluded. */
76
+ function medianCompletedMinute(state) {
77
+ const current = Math.floor(state.activeMinutes);
78
+ const values = [];
79
+ for (const [bucket, value] of state.tokensByActiveMinute) {
80
+ if (bucket < current && value > 0)
81
+ values.push(value);
82
+ }
83
+ if (values.length < 3)
84
+ return 0;
85
+ values.sort((a, b) => a - b);
86
+ const mid = Math.floor(values.length / 2);
87
+ return values.length % 2 ? values[mid] : (values[mid - 1] + values[mid]) / 2;
88
+ }
89
+ function cacheReadRatio(state) {
90
+ return state.totalTokens > 0 ? state.totalCacheRead / state.totalTokens : 0;
91
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Shared types for the burn detector.
3
+ *
4
+ * The core is host-agnostic on purpose. A Claude Code transcript, a Cursor hook
5
+ * payload, or raw API middleware each normalise into the same event stream and
6
+ * the same SessionState, so the detectors and the verdict logic are written
7
+ * once. That is the durable position: a single vendor will never monitor its
8
+ * competitors, so the policy layer has to sit above all of them.
9
+ */
10
+ export type Verdict = 'OK' | 'WARN' | 'STOP';
11
+ export type Detector = 'fanout' | 'sustained_burn' | 'burn_debt' | 'spawn_rate' | 'account' | 'duplicate_work';
12
+ /** One normalised observation from a host transcript. */
13
+ export interface BurnEvent {
14
+ /** Unix milliseconds. */
15
+ at: number;
16
+ /** All token classes summed. Zero for non-usage lines. */
17
+ tokens: number;
18
+ /** cache_read_input_tokens. Reported for explanation only, never a detector input. */
19
+ cacheRead: number;
20
+ /** Spawn descriptors emitted by this event, if any. */
21
+ spawns: SpawnEvent[];
22
+ /** Files or patterns read by this event, if any. */
23
+ surfaces: string[];
24
+ /** True when the host marks this line as belonging to a subagent. */
25
+ sidechain: boolean;
26
+ /** Host-supplied ids, used to reconstruct ancestry when present. */
27
+ uuid?: string;
28
+ parentUuid?: string;
29
+ }
30
+ export interface SpawnEvent {
31
+ description: string;
32
+ model?: string;
33
+ /** Depth of the agent that issued the spawn. Root is 0. */
34
+ issuerDepth: number;
35
+ }
36
+ /**
37
+ * Live state for one session. Everything a detector needs is here, and nothing
38
+ * else: no prompt text, no response text, no file contents.
39
+ */
40
+ export interface SessionState {
41
+ sessionId: string;
42
+ startedAt: number;
43
+ lastEventAt: number;
44
+ totalTokens: number;
45
+ totalCacheRead: number;
46
+ spawnCount: number;
47
+ maxDepth: number;
48
+ /** Active-time accounting. Gaps are capped so a resumed session does not
49
+ * hide current activity behind hours of wall-clock idleness. */
50
+ activeMinutes: number;
51
+ /** Rolling windows keyed by active-minute bucket. */
52
+ tokensByActiveMinute: Map<number, number>;
53
+ spawnsByActiveMinute: Map<number, number>;
54
+ /** Surfaces read, with the set of spawn eras that read them. */
55
+ surfaceReaders: Map<string, Set<number>>;
56
+ /** Burn-debt accumulator, see detectors/sustained-burn.ts. */
57
+ burnDebt: number;
58
+ lastDebtEventAt: number;
59
+ }
60
+ export interface Finding {
61
+ detector: Detector;
62
+ verdict: Verdict;
63
+ /** Short, factual, no advice. Advice lives in prescriptions. */
64
+ summary: string;
65
+ observed: number;
66
+ threshold: number;
67
+ }
68
+ export interface BurnReport {
69
+ sessionId: string;
70
+ verdict: Verdict;
71
+ findings: Finding[];
72
+ prescriptions: string[];
73
+ /** Explanation only. Shown to the user, never used to decide. */
74
+ cacheReadRatio: number;
75
+ totals: {
76
+ tokens: number;
77
+ spawns: number;
78
+ maxDepth: number;
79
+ activeMinutes: number;
80
+ };
81
+ }
82
+ export interface Thresholds {
83
+ fanout: {
84
+ warn: number;
85
+ stop: number;
86
+ maxDepth: number;
87
+ };
88
+ sustained: {
89
+ warnTokens: number;
90
+ stopTokens: number;
91
+ };
92
+ burnDebt: {
93
+ /** Tokens per active minute the user is "allowed" before debt accrues. */
94
+ baselinePerActiveMinute: number;
95
+ tolerance: number;
96
+ warnDebt: number;
97
+ stopDebt: number;
98
+ /** Debt is not evaluated until calibration has produced a baseline. */
99
+ enabled: boolean;
100
+ };
101
+ spawnRate: {
102
+ windowActiveMinutes: number;
103
+ warn: number;
104
+ stop: number;
105
+ enforce: boolean;
106
+ };
107
+ duplicate: {
108
+ warnReaders: number;
109
+ };
110
+ account: {
111
+ /** No shipped default. The provider's real allowance is unknown to us. */
112
+ stopTokens: number | null;
113
+ warnTokens: number | null;
114
+ windowActiveMinutes: number;
115
+ warnConcurrentSessions: number;
116
+ };
117
+ }
118
+ export type Mode = 'shadow' | 'enforce';
119
+ export interface Policy {
120
+ mode: Mode;
121
+ thresholds: Thresholds;
122
+ /** Calibration provenance, so a report can say where its numbers came from. */
123
+ calibration?: {
124
+ sessionsSampled: number;
125
+ generatedAt: number;
126
+ method: string;
127
+ };
128
+ }
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ /**
3
+ * Shared types for the burn detector.
4
+ *
5
+ * The core is host-agnostic on purpose. A Claude Code transcript, a Cursor hook
6
+ * payload, or raw API middleware each normalise into the same event stream and
7
+ * the same SessionState, so the detectors and the verdict logic are written
8
+ * once. That is the durable position: a single vendor will never monitor its
9
+ * competitors, so the policy layer has to sit above all of them.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@agentguard-run/burn",
3
+ "version": "0.1.0",
4
+ "description": "Local runaway-agent circuit breaker for AI coding agents. Detects fan-out storms and sustained token burn, blocks the next spawn, and proves what happened. Nothing leaves the machine.",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "type": "commonjs",
7
+ "main": "dist/src/index.js",
8
+ "types": "dist/src/index.d.ts",
9
+ "bin": {
10
+ "agentguard-burn": "dist/src/cli.js"
11
+ },
12
+ "files": [
13
+ "dist/src",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "engines": {
18
+ "node": ">=20.0.0"
19
+ },
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.json",
22
+ "test": "tsc -p tsconfig.json && node --test \"dist/tests/**/*.test.js\"",
23
+ "typecheck": "tsc -p tsconfig.json --noEmit",
24
+ "replay": "node dist/cli.js replay"
25
+ },
26
+ "dependencies": {
27
+ "@noble/ed25519": "^3.0.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22",
31
+ "typescript": "^5.0.0"
32
+ },
33
+ "keywords": [
34
+ "agentguard",
35
+ "claude-code",
36
+ "agents",
37
+ "token-budget",
38
+ "circuit-breaker",
39
+ "runaway",
40
+ "fan-out"
41
+ ]
42
+ }