@agentguard-run/burn 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +28 -0
  3. package/dist/src/cli.d.ts +1 -0
  4. package/dist/src/cli.js +20 -1
  5. package/dist/src/frames.d.ts +4 -0
  6. package/dist/src/frames.js +10 -0
  7. package/dist/src/gateway.js +5 -3
  8. package/dist/src/hook/pre-tool-use.js +2 -1
  9. package/dist/src/ledger-panel.d.ts +40 -0
  10. package/dist/src/ledger-panel.js +174 -0
  11. package/dist/src/live-panel.d.ts +54 -0
  12. package/dist/src/live-panel.js +203 -0
  13. package/dist/src/policy.d.ts +3 -1
  14. package/dist/src/policy.js +2 -2
  15. package/dist/src/record-ledger.d.ts +30 -0
  16. package/dist/src/record-ledger.js +165 -0
  17. package/dist/src/recording.js +26 -1
  18. package/dist/src/render-recording.d.ts +2 -0
  19. package/dist/src/render-recording.js +20 -13
  20. package/dist/src/state/spawn-window.d.ts +13 -1
  21. package/dist/src/state/spawn-window.js +18 -3
  22. package/docs/LIVE_PANEL_2026_09.md +79 -0
  23. package/docs/assets/burn-real-stop-sep20-stop-1920.png +0 -0
  24. package/docs/assets/burn-real-stop-sep20-stop-390.png +0 -0
  25. package/docs/assets/burn-real-stop-sep20.evidence.json +67 -0
  26. package/docs/assets/burn-real-stop-sep20.gif +0 -0
  27. package/docs/assets/burn-real-stop-sep20.jsonl +39 -0
  28. package/docs/assets/burn-real-stop-sep20.mp4 +0 -0
  29. package/docs/assets/burn-real-stop-sep20.mp4.provenance.json +256 -0
  30. package/docs/assets/burn-real-stop-sep20.policy.json +47 -0
  31. package/docs/assets/burn-real-stop-sep20.receipts.ndjson +18 -0
  32. package/docs/burn-render.md +12 -2
  33. package/fixtures/live-panel-104x35.txt +35 -0
  34. package/fixtures/live-panel-80x24.txt +24 -0
  35. package/fixtures/live-sep20-stop-104x35.txt +35 -0
  36. package/package.json +1 -1
@@ -0,0 +1,203 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.StopHold = exports.SessionFollow = void 0;
4
+ exports.panelDecisions = panelDecisions;
5
+ exports.collectLivePanel = collectLivePanel;
6
+ exports.spawnBarColor = spawnBarColor;
7
+ exports.renderLivePanel = renderLivePanel;
8
+ exports.panelFromReport = panelFromReport;
9
+ exports.runLivePanel = runLivePanel;
10
+ /** Local, content-free instrument panel. It observes; it never changes admission. */
11
+ const node_crypto_1 = require("node:crypto");
12
+ const node_fs_1 = require("node:fs");
13
+ const canvas_1 = require("./canvas");
14
+ const ledger_panel_1 = require("./ledger-panel");
15
+ const recording_1 = require("./recording");
16
+ const digest = (text) => (0, node_crypto_1.createHash)('sha256').update(text).digest('hex');
17
+ /** The ledger is the single source for decisions, counters and session selection. */
18
+ function panelDecisions(home, sessionId, at = Date.now()) {
19
+ return (0, ledger_panel_1.projectLedgerPanel)((0, ledger_panel_1.readPanelLedger)(home), sessionId, at).decisions;
20
+ }
21
+ function collectLivePanel(home, session, now = Date.now()) {
22
+ return (0, ledger_panel_1.projectLedgerPanel)((0, ledger_panel_1.readPanelLedger)(home), session, now);
23
+ }
24
+ /** Record changes of identity, including switches between sessions with the same short prefix. */
25
+ class SessionFollow {
26
+ previous;
27
+ update(snapshot, now) {
28
+ const identity = snapshot.sessionDigest ?? snapshot.session;
29
+ const sessionSwitch = this.previous !== undefined && this.previous !== identity
30
+ ? { from: this.previous, to: identity, at: now } : undefined;
31
+ this.previous = identity;
32
+ return { ...snapshot, ...(sessionSwitch ? { sessionSwitch } : {}) };
33
+ }
34
+ }
35
+ exports.SessionFollow = SessionFollow;
36
+ const compact = (v) => v === null ? { digits: '-', unit: 'unknown' } : v >= 1e9 ? { digits: (v / 1e9).toFixed(2), unit: 'B tokens' } : v >= 1e6 ? { digits: (v / 1e6).toFixed(2), unit: 'M tokens' } : v >= 1e3 ? { digits: (v / 1e3).toFixed(1), unit: 'K tokens' } : { digits: String(Math.round(v)), unit: 'tokens' };
37
+ function spawnBarColor(spawns, ceiling) {
38
+ return spawns === null || ceiling === null ? 'slate' : spawns >= ceiling ? 'red' : spawns >= ceiling * .8 ? 'amber' : 'mint';
39
+ }
40
+ function elapsed(ms) {
41
+ const seconds = Math.max(0, Math.floor(ms / 1000));
42
+ return `${String(Math.floor(seconds / 3600)).padStart(2, '0')}:${String(Math.floor(seconds / 60) % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
43
+ }
44
+ function renderLivePanel(snapshot, options = {}) {
45
+ const c = new canvas_1.Canvas(options), w = c.width, h = c.height, narrow = w < 80, split = narrow ? w : Math.floor(w * .45), right = narrow ? 0 : split + 3, room = w - right;
46
+ const at = options.at ?? new Date(snapshot.observedAt).toISOString(), color = spawnBarColor(snapshot.spawns, snapshot.spawnCeiling);
47
+ c.header('AGENTGUARD / BURN', options.recorded ? 'RECORDED RUN · 1x' : 'LIVE');
48
+ c.footer(snapshot.controls === false ? 'local observation only' : narrow ? 'q quit · p pause' : 'q quit · p pause · local only', `${narrow ? '' : 'elapsed '}${snapshot.elapsedMs === null ? 'unknown' : elapsed(snapshot.elapsedMs ?? snapshot.observedAt - snapshot.startedAt)}`);
49
+ c.put(2, 0, `session ${snapshot.session} · ${at.slice(11, 19)} UTC`, 'slate');
50
+ if (!narrow)
51
+ for (let y = 3; y < h - 3; y++)
52
+ c.put(y, split + 1, '│', 'slate');
53
+ const leftWidth = split, first = h >= 30 ? 4 : 3, second = h >= 30 ? 11 : 8, third = h >= 30 ? 18 : 13;
54
+ c.put(first, 0, 'SPAWNS', 'slate').digits(first + 1, 0, snapshot.spawns === null ? '-' : snapshot.spawns, color);
55
+ c.put(first + 4, 0, `${snapshot.windowActiveMinutes ?? '?'} active min · ceiling ${snapshot.spawnCeiling ?? '?'}`.slice(0, leftWidth), 'slate');
56
+ c.put(second, 0, 'TOKENS IN WINDOW', 'slate');
57
+ const tokens = compact(snapshot.tokens);
58
+ c.digits(second + 1, 0, tokens.digits, 'mint');
59
+ c.put(second + 4, 0, `${tokens.unit} · ${snapshot.windowActiveMinutes ?? '?'} active min`.slice(0, leftWidth), 'slate');
60
+ c.put(third, 0, 'REPLAY SHARE %', 'slate').digits(third + 1, 0, snapshot.replayShare === null ? '-' : Math.round(snapshot.replayShare * 100), 'mint');
61
+ c.put(third + 4, 0, 'session cache-read / tokens'.slice(0, leftWidth), 'slate');
62
+ const barRow = h - 6;
63
+ c.put(barRow, 0, `SPAWN RATE ${snapshot.spawns ?? '?'} / ${snapshot.spawnCeiling ?? '?'}`.slice(0, leftWidth), color);
64
+ c.bar(barRow + 1, 0, leftWidth, snapshot.spawns === null || snapshot.spawnCeiling === null ? 0 : snapshot.spawns / snapshot.spawnCeiling, color);
65
+ if (!narrow)
66
+ c.put(first, right, 'LAST 8 DECISIONS', 'slate');
67
+ (narrow ? [] : snapshot.decisions.slice(-8)).forEach((d, index) => {
68
+ const y = first + 1 + index * 2, chip = d.verdict === 'OK' ? 'clean' : d.verdict, tint = d.verdict === 'STOP' ? 'red' : d.verdict === 'WARN' ? 'amber' : 'mint';
69
+ c.put(y, right, `${new Date(d.at).toISOString().slice(11, 19)} ${d.tool}`.slice(0, Math.max(0, room - 8)), 'white');
70
+ c.put(y, Math.max(right, w - 7), `[${chip}]`, tint);
71
+ c.put(y + 1, right, d.reason.slice(0, room), 'slate');
72
+ });
73
+ if (!narrow && !snapshot.decisions.length)
74
+ c.put(6, right, 'No recorded decisions yet.', 'slate');
75
+ if (snapshot.stop) {
76
+ const y = h >= 30 ? h - 11 : 16;
77
+ for (let i = 0; i < 5; i++)
78
+ c.put(y + i, right, ' '.repeat(room), 'none');
79
+ c.put(y, right, 'STOP · ' + (snapshot.stop.blocked === true ? 'blocked' : snapshot.stop.blocked === false ? 'shadow / allowed' : 'outcome unknown'), 'red');
80
+ c.put(y + 1, right, snapshot.stop.reason.slice(0, room), 'red');
81
+ c.put(y + 2, right, snapshot.stopHold === false ? 'Latest STOP reason.' : 'Reason held for 2 seconds.', 'slate');
82
+ }
83
+ c.put(h - 3, 0, snapshot.note.slice(0, w), 'slate');
84
+ return c;
85
+ }
86
+ /** STOP report adaptation uses only already-computed metadata, never local I/O. */
87
+ function panelFromReport(report, at, outcome, subject) {
88
+ const rate = report.findings.find(f => f.detector === 'spawn_rate');
89
+ const decision = { id: digest(`${report.sessionId}:${at}`), at, tool: subject === 'call' ? 'model_call' : 'spawn', verdict: report.verdict,
90
+ reason: report.findings.map(f => `${f.detector}:${f.verdict} ${f.observed}/${f.threshold}`).join(' + ') || 'Reason not recorded.', blocked: outcome === undefined ? null : outcome === 'blocked' };
91
+ const note = outcome === 'blocked' ? (subject === 'call' ? 'model call blocked' : 'agent spawn blocked')
92
+ : outcome === 'shadow' ? 'shadow: would have blocked, call allowed' : outcome === 'overridden' ? 'override: call allowed'
93
+ : outcome === 'allowed' ? 'call allowed' : 'STOP boundary · enforcement outcome not recorded';
94
+ return { session: (/^[A-Za-z0-9_-]+$/.test(report.sessionId) ? report.sessionId : digest(report.sessionId)).slice(0, 8), startedAt: at, observedAt: at, elapsedMs: null, controls: false, stopHold: false,
95
+ spawns: rate?.observed ?? null, tokens: null, replayShare: Number.isFinite(report.cacheReadRatio) ? report.cacheReadRatio : null,
96
+ windowActiveMinutes: null, spawnCeiling: rate?.verdict === 'STOP' ? rate.threshold : null,
97
+ decisions: [decision], stop: decision, note: `${note}. Unknown window metrics remain unknown.` };
98
+ }
99
+ /** Display-only hold. Intake continues and the latest observation follows the card. */
100
+ class StopHold {
101
+ held;
102
+ until = 0;
103
+ seen = new Set();
104
+ latest;
105
+ update(snapshot, now) {
106
+ if (this.latest && (this.latest.sessionDigest ?? this.latest.session) !== (snapshot.sessionDigest ?? snapshot.session)) {
107
+ this.held = undefined;
108
+ this.until = 0;
109
+ this.seen.clear();
110
+ }
111
+ this.latest = snapshot;
112
+ // A historical STOP in the right column must not borrow a later decision's counters.
113
+ const latestDecision = snapshot.decisions.at(-1);
114
+ const stop = latestDecision?.verdict === 'STOP' && !this.seen.has(latestDecision.id) ? latestDecision : undefined;
115
+ for (const decision of snapshot.decisions)
116
+ this.seen.add(decision.id);
117
+ if (stop) {
118
+ this.held = { ...snapshot, stop };
119
+ this.until = now + 2000;
120
+ }
121
+ if (this.held && now < this.until)
122
+ return this.held;
123
+ this.held = undefined;
124
+ return this.latest;
125
+ }
126
+ }
127
+ exports.StopHold = StopHold;
128
+ async function runLivePanel(home, args) {
129
+ const flag = (key) => {
130
+ const i = args.indexOf(key);
131
+ if (i < 0)
132
+ return undefined;
133
+ if (!args[i + 1] || args[i + 1].startsWith('--'))
134
+ throw new Error(`Use ${key} with a value.`);
135
+ return args[i + 1];
136
+ };
137
+ const replayFile = flag('--replay'), selected = flag('--session');
138
+ const once = args.includes('--once') || !process.stdout.isTTY;
139
+ const colour = !args.includes('--no-color') && process.env.NO_COLOR === undefined && Boolean(process.stdout.isTTY);
140
+ const viewport = { width: Math.min(104, process.stdout.columns || 104), height: Math.min(35, process.stdout.rows || 35) };
141
+ let paused = false, stopped = false;
142
+ const key = (bytes) => { const text = String(bytes); if (text.includes('q') || text.includes('\x03'))
143
+ stopped = true; if (text.includes('p'))
144
+ paused = !paused; };
145
+ const stop = () => { stopped = true; };
146
+ const display = (snapshot, recorded, at) => {
147
+ (0, recording_1.recordFrame)({ kind: 'live', snapshot }, at, viewport);
148
+ process.stdout.write((process.stdout.isTTY ? '\x1b[H\x1b[2J' : '') + renderLivePanel(snapshot, { ...viewport, at, recorded, colour }).render() + '\n');
149
+ };
150
+ const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
151
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
152
+ if (interactive) {
153
+ process.stdin.setRawMode(true);
154
+ process.stdin.resume();
155
+ process.stdin.on('data', key);
156
+ }
157
+ process.on('SIGINT', stop);
158
+ try {
159
+ if (replayFile) {
160
+ if (!(0, node_fs_1.existsSync)(replayFile))
161
+ throw new Error('Local recording is unavailable.');
162
+ const frames = (0, recording_1.readRecording)(replayFile).filter(frame => frame.data.kind === 'live');
163
+ if (!frames.length)
164
+ throw new Error('Recording has no live instrument frames.');
165
+ for (let i = 0; i < frames.length && !stopped; i++) {
166
+ const frame = frames[i];
167
+ if (frame.data.kind !== 'live')
168
+ continue;
169
+ display(frame.data.snapshot, true, frame.at);
170
+ if (!once) {
171
+ const delay = i + 1 < frames.length ? Date.parse(frames[i + 1].at) - Date.parse(frame.at) : 2000;
172
+ let remaining = delay;
173
+ while (remaining > 0 && !stopped) {
174
+ const step = Math.min(100, remaining);
175
+ await wait(step);
176
+ if (!paused)
177
+ remaining -= step;
178
+ }
179
+ }
180
+ }
181
+ }
182
+ else {
183
+ const hold = new StopHold(), follow = new SessionFollow();
184
+ do {
185
+ if (!paused) {
186
+ const now = Date.now(), snapshot = follow.update(hold.update(collectLivePanel(home, selected, now), now), now);
187
+ display(snapshot, false, new Date(now).toISOString());
188
+ }
189
+ if (!once)
190
+ await wait(500);
191
+ } while (!once && !stopped);
192
+ }
193
+ }
194
+ finally {
195
+ process.off('SIGINT', stop);
196
+ if (interactive) {
197
+ process.stdin.off('data', key);
198
+ process.stdin.setRawMode(false);
199
+ process.stdin.pause();
200
+ }
201
+ }
202
+ return 0;
203
+ }
@@ -1,4 +1,6 @@
1
1
  import type { Policy } from './types';
2
2
  /** Normalize before evaluating or hashing so receipts bind the actual policy. */
3
3
  export declare function normalizePolicy(policy: Policy): Policy;
4
- export declare function loadPolicy(home: string): Policy;
4
+ export declare function loadPolicy(home: string, options?: {
5
+ notice?: boolean;
6
+ }): Policy;
@@ -82,7 +82,7 @@ function noticeOnce(home, fields) {
82
82
  }
83
83
  process.stderr.write(`AgentGuard loaded missing policy fields from defaults: ${fields.join(', ')}; existing overrides are unchanged.\n`);
84
84
  }
85
- function loadPolicy(home) {
85
+ function loadPolicy(home, options = {}) {
86
86
  try {
87
87
  const parsed = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(home, 'burn-policy.json'), 'utf8'));
88
88
  if (parsed && (parsed.mode === 'shadow' || parsed.mode === 'enforce') && parsed.thresholds) {
@@ -91,7 +91,7 @@ function loadPolicy(home) {
91
91
  missing.push('fanout.windowActiveMinutes=120');
92
92
  if (parsed.thresholds.spawnRate?.enforce === undefined)
93
93
  missing.push('spawnRate.enforce=true');
94
- if (missing.length)
94
+ if (missing.length && options.notice !== false)
95
95
  noticeOnce(home, missing);
96
96
  return normalizePolicy(withTeamThresholds(home, parsed));
97
97
  }
@@ -0,0 +1,30 @@
1
+ import { type LedgerDecision } from './ledger-panel';
2
+ import type { RecordedFrame } from './recording';
3
+ export interface RecordLedgerOptions {
4
+ session: string;
5
+ from?: string;
6
+ to?: string;
7
+ out: string;
8
+ }
9
+ export interface RecordLedgerResult {
10
+ output: string;
11
+ session: string;
12
+ from: string;
13
+ to: string;
14
+ frameCount: number;
15
+ decisionCount: number;
16
+ clippedStopHold: boolean;
17
+ }
18
+ /** Require an explicit timezone and reject dates Date.parse would normalize. */
19
+ export declare function ledgerTimestamp(value: string): number;
20
+ export declare function validateCompanionRecording(home: string, output: string, recording: string): void;
21
+ export declare function recordLedgerArguments(args: string[]): RecordLedgerOptions;
22
+ export declare function reconstructLedgerFrames(rows: LedgerDecision[], options: Omit<RecordLedgerOptions, 'out'>): {
23
+ frames: RecordedFrame[];
24
+ session: string;
25
+ from: number;
26
+ to: number;
27
+ decisionCount: number;
28
+ clippedStopHold: boolean;
29
+ };
30
+ export declare function recordLedger(home: string, options: RecordLedgerOptions): RecordLedgerResult;
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ledgerTimestamp = ledgerTimestamp;
4
+ exports.validateCompanionRecording = validateCompanionRecording;
5
+ exports.recordLedgerArguments = recordLedgerArguments;
6
+ exports.reconstructLedgerFrames = reconstructLedgerFrames;
7
+ exports.recordLedger = recordLedger;
8
+ /** Reconstruct local instrument-panel observations from immutable ledger history. */
9
+ const node_fs_1 = require("node:fs");
10
+ const node_path_1 = require("node:path");
11
+ const ledger_panel_1 = require("./ledger-panel");
12
+ const live_panel_1 = require("./live-panel");
13
+ const USAGE = 'Usage: agentguard-burn record --session <id> [--from <iso>] [--to <iso>] --out <run.jsonl>';
14
+ const MAX_FRAMES = 10000;
15
+ /** Require an explicit timezone and reject dates Date.parse would normalize. */
16
+ function ledgerTimestamp(value) {
17
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?(Z|[+-]\d{2}:\d{2})$/.exec(value);
18
+ if (!match)
19
+ throw new Error('Record timestamps must be ISO dates with a timezone and at most millisecond precision.');
20
+ const year = Number(match[1]), month = Number(match[2]), day = Number(match[3]);
21
+ const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
22
+ const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
23
+ const offset = match[8];
24
+ const at = Date.parse(value);
25
+ if (month < 1 || month > 12 || day < 1 || day > days[month - 1] || Number(match[4]) > 23
26
+ || Number(match[5]) > 59 || Number(match[6]) > 59 || !Number.isFinite(at) || at < 0
27
+ || offset !== 'Z' && (Number(offset.slice(1, 3)) > 23 || Number(offset.slice(4)) > 59)) {
28
+ throw new Error('Record timestamps must contain a valid date, time and timezone.');
29
+ }
30
+ return at;
31
+ }
32
+ /** Compare aliases before writing, including a new file below a symlinked directory. */
33
+ function samePath(left, right) {
34
+ try {
35
+ const a = (0, node_fs_1.statSync)(left), b = (0, node_fs_1.statSync)(right);
36
+ if (a.dev === b.dev && a.ino === b.ino)
37
+ return true;
38
+ }
39
+ catch { /* A fresh output has no inode yet. */ }
40
+ const physical = (file) => {
41
+ let path = (0, node_path_1.resolve)(file);
42
+ const suffix = [];
43
+ for (;;) {
44
+ try {
45
+ return (0, node_path_1.resolve)((0, node_fs_1.realpathSync)(path), ...suffix);
46
+ }
47
+ catch {
48
+ const parent = (0, node_path_1.dirname)(path);
49
+ if (parent === path)
50
+ return (0, node_path_1.resolve)(file);
51
+ suffix.unshift((0, node_path_1.basename)(path));
52
+ path = parent;
53
+ }
54
+ }
55
+ };
56
+ return physical(left) === physical(right);
57
+ }
58
+ function validateCompanionRecording(home, output, recording) {
59
+ if (samePath(output, recording))
60
+ throw new Error('Use different paths for --out and --record.');
61
+ if (['decisions.ndjson', 'receipts.ndjson'].some(name => samePath(recording, (0, node_path_1.join)(home, name)))) {
62
+ throw new Error('Recording must not append to a source ledger.');
63
+ }
64
+ }
65
+ function recordLedgerArguments(args) {
66
+ const fields = {};
67
+ for (let i = 0; i < args.length; i++) {
68
+ const flag = args[i];
69
+ if (!['--session', '--from', '--to', '--out'].includes(flag))
70
+ throw new Error(USAGE);
71
+ const key = flag.slice(2), value = args[++i];
72
+ if (!value || value.startsWith('--') || fields[key] !== undefined)
73
+ throw new Error(USAGE);
74
+ fields[key] = value;
75
+ }
76
+ if (!fields.session || !fields.out)
77
+ throw new Error(USAGE);
78
+ return fields;
79
+ }
80
+ function reconstructLedgerFrames(rows, options) {
81
+ if (!options.session || /[\x00-\x20\x7f]/.test(options.session))
82
+ throw new Error('Record requires a session id or an unambiguous prefix.');
83
+ const selected = (0, ledger_panel_1.resolveLedgerSession)(rows, options.session);
84
+ if (!selected)
85
+ throw new Error(`No ledger decisions match session ${options.session}.`);
86
+ const history = rows.filter(row => row.sessionDigest === selected.sessionDigest);
87
+ if (!history.length)
88
+ throw new Error(`No ledger decisions match session ${options.session}.`);
89
+ const first = history[0], last = history.at(-1);
90
+ const from = options.from === undefined ? first.at : ledgerTimestamp(options.from);
91
+ const to = options.to === undefined ? last.at : ledgerTimestamp(options.to);
92
+ if (from > to)
93
+ throw new Error('Record --from must be at or before --to.');
94
+ if ((to - from) / 1000 + 1 > MAX_FRAMES)
95
+ throw new Error('Recording exceeds the 10,000 frame render limit; select a shorter time range.');
96
+ const within = history.filter(row => row.at >= from && row.at <= to);
97
+ if (!within.length)
98
+ throw new Error('No ledger decisions fall inside the requested time range.');
99
+ const session = selected.sessionId?.slice(0, 8) ?? selected.sessionDigest.slice(0, 8);
100
+ const project = (index, at) => {
101
+ const snapshot = (0, ledger_panel_1.projectLedgerPanel)(history.slice(0, index + 1), selected.sessionDigest, at);
102
+ return { ...snapshot, session, sessionDigest: selected.sessionDigest, startedAt: first.at, observedAt: at, elapsedMs: Math.max(0, at - first.at), controls: false };
103
+ };
104
+ // Seed the hold with prior history without treating an old STOP as a new event.
105
+ const hold = new live_panel_1.StopHold();
106
+ let priorIndex = -1;
107
+ while (priorIndex + 1 < history.length && history[priorIndex + 1].at < from - 2000)
108
+ priorIndex++;
109
+ if (priorIndex >= 0)
110
+ hold.update(project(priorIndex, from - 2000), from - 2000);
111
+ while (priorIndex + 1 < history.length && history[priorIndex + 1].at < from) {
112
+ priorIndex++;
113
+ hold.update(project(priorIndex, history[priorIndex].at), history[priorIndex].at);
114
+ }
115
+ const events = [];
116
+ const decisionTimes = new Set();
117
+ history.forEach((row, index) => {
118
+ if (row.at >= from && row.at <= to) {
119
+ events.push({ at: row.at, index });
120
+ decisionTimes.add(row.at);
121
+ }
122
+ });
123
+ for (let at = from; at <= to; at += 1000)
124
+ if (!decisionTimes.has(at))
125
+ events.push({ at });
126
+ if ((to - from) % 1000 !== 0 && !decisionTimes.has(to))
127
+ events.push({ at: to });
128
+ events.sort((a, b) => a.at - b.at || (a.index ?? -1) - (b.index ?? -1));
129
+ if (events.length > MAX_FRAMES)
130
+ throw new Error('Recording exceeds the 10,000 frame render limit; select a shorter time range.');
131
+ const frames = events.map(event => {
132
+ if (event.index !== undefined)
133
+ priorIndex = event.index;
134
+ else
135
+ while (priorIndex + 1 < history.length && history[priorIndex + 1].at <= event.at)
136
+ priorIndex++;
137
+ const snapshot = hold.update(project(priorIndex, event.at), event.at);
138
+ return { version: 1, at: new Date(event.at).toISOString(), data: { kind: 'live', snapshot }, viewport: { width: 104, height: 35 } };
139
+ });
140
+ const lastStop = history.filter(row => row.at <= to && row.verdict === 'STOP').at(-1);
141
+ return { frames, session, from, to, decisionCount: within.length, clippedStopHold: Boolean(lastStop && lastStop.at + 2000 > to) };
142
+ }
143
+ function recordLedger(home, options) {
144
+ if (!options.out || /[\x00-\x1f\x7f]/.test(options.out))
145
+ throw new Error('Recording output must be a local path without control characters.');
146
+ const output = (0, node_path_1.resolve)(options.out);
147
+ if (['decisions.ndjson', 'receipts.ndjson'].some(name => output === (0, node_path_1.resolve)((0, node_path_1.join)(home, name)))) {
148
+ throw new Error('Recording output must not replace a source ledger.');
149
+ }
150
+ const result = reconstructLedgerFrames((0, ledger_panel_1.readPanelLedger)(home), options);
151
+ const bytes = result.frames.map(frame => JSON.stringify(frame)).join('\n') + '\n';
152
+ if (Buffer.byteLength(bytes) > 128 * 1024 * 1024)
153
+ throw new Error('Recording exceeds the 128 MB render limit; select a shorter time range.');
154
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(output), { recursive: true, mode: 0o700 });
155
+ try {
156
+ (0, node_fs_1.writeFileSync)(output, bytes, { flag: 'wx', mode: 0o600 });
157
+ }
158
+ catch (error) {
159
+ if (error.code === 'EEXIST')
160
+ throw new Error(`Refusing to overwrite existing output: ${output}`);
161
+ throw error;
162
+ }
163
+ return { output, session: result.session, from: new Date(result.from).toISOString(), to: new Date(result.to).toISOString(),
164
+ frameCount: result.frames.length, decisionCount: result.decisionCount, clippedStopHold: result.clippedStopHold };
165
+ }
@@ -42,12 +42,37 @@ function readRecording(file) {
42
42
  catch {
43
43
  throw new Error(`Invalid recording JSON at line ${index + 1}.`);
44
44
  }
45
- if (row?.version !== 1 || typeof row.at !== 'string' || !Number.isFinite(Date.parse(row.at)) || !row.data || !['audit', 'report', 'replay', 'command'].includes(row.data.kind))
45
+ if (row?.version !== 1 || typeof row.at !== 'string' || !Number.isFinite(Date.parse(row.at)) || !row.data || !['audit', 'report', 'replay', 'command', 'live'].includes(row.data.kind))
46
46
  throw new Error(`Invalid frame at line ${index + 1}.`);
47
47
  if (row.viewport && (!Number.isInteger(row.viewport.width) || row.viewport.width < 1 || row.viewport.width > 104 || !Number.isInteger(row.viewport.height) || row.viewport.height < 1 || row.viewport.height > 35))
48
48
  throw new Error(`Invalid viewport at line ${index + 1}.`);
49
49
  if (result.length && Date.parse(row.at) < Date.parse(result.at(-1).at))
50
50
  throw new Error(`Recording timestamps go backwards at line ${index + 1}.`);
51
+ if (row.data.kind === 'live') {
52
+ const snapshot = row.data.snapshot;
53
+ const count = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
54
+ const timestamp = (value) => count(value) && Number(value) <= 8.64e15;
55
+ const decision = (value) => {
56
+ if (!value || typeof value !== 'object')
57
+ return false;
58
+ const d = value;
59
+ return typeof d.id === 'string' && timestamp(d.at) && typeof d.tool === 'string' && typeof d.reason === 'string'
60
+ && ['OK', 'WARN', 'STOP'].includes(String(d.verdict)) && (typeof d.blocked === 'boolean' || d.blocked === null);
61
+ };
62
+ const sessionSwitch = snapshot?.sessionSwitch;
63
+ if (!snapshot || typeof snapshot.session !== 'string'
64
+ || !(snapshot.sessionDigest === undefined || typeof snapshot.sessionDigest === 'string' && /^[a-f0-9]{64}$/.test(snapshot.sessionDigest))
65
+ || !(sessionSwitch === undefined || sessionSwitch && typeof sessionSwitch.from === 'string' && typeof sessionSwitch.to === 'string' && timestamp(sessionSwitch.at)) || typeof snapshot.note !== 'string'
66
+ || !timestamp(snapshot.observedAt) || !timestamp(snapshot.startedAt)
67
+ || !(snapshot.windowActiveMinutes === null || count(snapshot.windowActiveMinutes) && snapshot.windowActiveMinutes > 0)
68
+ || !(snapshot.spawnCeiling === null || count(snapshot.spawnCeiling) && snapshot.spawnCeiling > 0)
69
+ || !(snapshot.elapsedMs === undefined || snapshot.elapsedMs === null || count(snapshot.elapsedMs))
70
+ || ![snapshot.spawns, snapshot.tokens].every(value => value === null || count(value))
71
+ || !(snapshot.replayShare === null || count(snapshot.replayShare) && snapshot.replayShare <= 1)
72
+ || !Array.isArray(snapshot.decisions) || snapshot.decisions.length > 8 || !snapshot.decisions.every(decision)
73
+ || snapshot.stop !== undefined && !decision(snapshot.stop))
74
+ throw new Error(`Invalid live frame at line ${index + 1}.`);
75
+ }
51
76
  if (row.data.kind === 'audit' && (!Array.isArray(row.data.report?.rows) || !row.data.report?.totals))
52
77
  throw new Error(`Invalid audit frame at line ${index + 1}.`);
53
78
  if (row.data.kind === 'command' && (!row.data.values || typeof row.data.values !== 'object'))
@@ -1,3 +1,4 @@
1
+ import { type CanvasSnapshot } from './canvas';
1
2
  export interface RenderRecordingResult {
2
3
  framesDir: string;
3
4
  provenancePath: string;
@@ -6,6 +7,7 @@ export interface RenderRecordingResult {
6
7
  ffmpegCommand: string;
7
8
  frameCount: number;
8
9
  }
10
+ export declare function renderCanvasPng(snapshot: CanvasSnapshot, width?: number, height?: number): Promise<Buffer>;
9
11
  export declare function renderRecording(source: string, options: {
10
12
  mp4: string;
11
13
  gif?: string;
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderCanvasPng = renderCanvasPng;
3
4
  exports.renderRecording = renderRecording;
4
5
  /** Offline PNG and video rendering from validated, recorded Canvas data. */
5
6
  const node_fs_1 = require("node:fs");
@@ -67,14 +68,19 @@ function timestampMicros(at) {
67
68
  const fraction = /\.(\d+)(?:Z|[+-]\d{2}:?\d{2})$/i.exec(at)?.[1] ?? '';
68
69
  return BigInt(milliseconds) * 1000n + BigInt(fraction.slice(3, 6).padEnd(3, '0'));
69
70
  }
70
- async function png(snapshot) {
71
- const canvas = (0, canvas_1.createCanvas)(1920, 1080);
72
- const paddingX = Math.floor((canvas.width - snapshot.width * CELL_WIDTH) / 2);
73
- const paddingY = Math.floor((canvas.height - snapshot.height * CELL_HEIGHT) / 2);
71
+ async function renderCanvasPng(snapshot, width = 1920, height = 1080) {
72
+ await font();
73
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 64 || height < 64 || width > 4096 || height > 4096)
74
+ throw new Error('Canvas raster dimensions must be integers between 64 and 4096.');
75
+ const canvas = (0, canvas_1.createCanvas)(width, height);
76
+ const cellWidth = Math.min(CELL_WIDTH, Math.max(1, Math.floor((width - 32) / snapshot.width)));
77
+ const cellHeight = Math.min(CELL_HEIGHT, Math.max(1, Math.floor((height - 32) / snapshot.height)), Math.round(cellWidth * CELL_HEIGHT / CELL_WIDTH));
78
+ const paddingX = Math.floor((canvas.width - snapshot.width * cellWidth) / 2);
79
+ const paddingY = Math.floor((canvas.height - snapshot.height * cellHeight) / 2);
74
80
  const ctx = canvas.getContext('2d');
75
81
  ctx.fillStyle = '#0B1117';
76
82
  ctx.fillRect(0, 0, canvas.width, canvas.height);
77
- ctx.font = `26px "${FONT_ALIAS}"`;
83
+ ctx.font = `${26 * cellWidth / CELL_WIDTH}px "${FONT_ALIAS}"`;
78
84
  ctx.textBaseline = 'alphabetic';
79
85
  for (let row = 0; row < snapshot.height; row++)
80
86
  for (let col = 0; col < snapshot.width; col++) {
@@ -82,17 +88,17 @@ async function png(snapshot) {
82
88
  if (cell.char === ' ')
83
89
  continue;
84
90
  ctx.fillStyle = canvas_2.CANVAS_PALETTE[cell.color === 'none' ? 'white' : cell.color].hex;
85
- const x = paddingX + col * CELL_WIDTH, y = paddingY + row * CELL_HEIGHT;
91
+ const x = paddingX + col * cellWidth, y = paddingY + row * cellHeight;
86
92
  if (cell.char === '█')
87
- ctx.fillRect(x, y, CELL_WIDTH, CELL_HEIGHT);
93
+ ctx.fillRect(x, y, cellWidth, cellHeight);
88
94
  else if (cell.char === '▀')
89
- ctx.fillRect(x, y, CELL_WIDTH, CELL_HEIGHT / 2);
95
+ ctx.fillRect(x, y, cellWidth, cellHeight / 2);
90
96
  else if (cell.char === '▄')
91
- ctx.fillRect(x, y + CELL_HEIGHT / 2, CELL_WIDTH, CELL_HEIGHT / 2);
97
+ ctx.fillRect(x, y + cellHeight / 2, cellWidth, cellHeight / 2);
92
98
  else if (cell.char === '─')
93
- ctx.fillRect(x, y + CELL_HEIGHT / 2, CELL_WIDTH, 1);
99
+ ctx.fillRect(x, y + cellHeight / 2, cellWidth, 1);
94
100
  else
95
- ctx.fillText(cell.char, x + 1, y + 23, CELL_WIDTH - 2);
101
+ ctx.fillText(cell.char, x + 1, y + 23 * cellHeight / CELL_HEIGHT, Math.max(1, cellWidth - 2));
96
102
  }
97
103
  return canvas.encode('png');
98
104
  }
@@ -140,7 +146,7 @@ async function renderRecording(source, options) {
140
146
  const frame = frames[index];
141
147
  const name = `frame-${String(index).padStart(6, '0')}.png`;
142
148
  const image = (0, frames_1.renderFrame)(frame.data, { recorded: true, at: frame.at, width: frame.viewport?.width ?? 104, height: frame.viewport?.height ?? 35, colour: true });
143
- await (0, promises_1.writeFile)((0, node_path_1.join)(framesDir, name), await png(image.toJSON()), { flag: 'wx', mode: 0o600 });
149
+ await (0, promises_1.writeFile)((0, node_path_1.join)(framesDir, name), await renderCanvasPng(image.toJSON()), { flag: 'wx', mode: 0o600 });
144
150
  }
145
151
  for (const [position, index] of videoIndices.entries()) {
146
152
  const next = videoIndices[position + 1];
@@ -155,7 +161,7 @@ async function renderRecording(source, options) {
155
161
  // B-frame reordering can collapse the MP4 track duration to rapid DTS steps even
156
162
  // when the final presentation timestamp correctly includes the one-second hold.
157
163
  const mp4Args = [...common, '-fps_mode', 'vfr', '-c:v', 'libx264', '-bf', '0', '-enc_time_base', '1:1000000', '-video_track_timescale', '1000000', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', mp4Path];
158
- const gifArgs = [...common, '-fps_mode', 'vfr', '-vf', 'split[a][b];[a]palettegen[p];[b][p]paletteuse', '-loop', '0', ...(gifPath ? [gifPath] : [])];
164
+ const gifArgs = [...common, '-fps_mode', 'vfr', '-frames:v', String(videoIndices.length), '-final_delay', '100', '-vf', 'split[a][b];[a]palettegen[p];[b][p]paletteuse', '-loop', '0', ...(gifPath ? [gifPath] : [])];
159
165
  const command = [ffmpeg || 'ffmpeg', ...mp4Args].map(shellWord).join(' ')
160
166
  + (gifPath ? `\n${[ffmpeg || 'ffmpeg', ...gifArgs].map(shellWord).join(' ')}` : '');
161
167
  const provenance = {
@@ -169,6 +175,7 @@ async function renderRecording(source, options) {
169
175
  video_frame_indices: videoIndices,
170
176
  video_timestamp_precision: 'microseconds; equal timestamps retain the last frame in video and every PNG',
171
177
  final_hold_seconds: 1,
178
+ gif_timestamp_precision: 'centiseconds; terminal concat sentinel excluded; final displayed frame holds one second',
172
179
  ffmpeg_command: command,
173
180
  format: 'Recorded viewport (legacy 104x35), centered 18x28 pixel cells, 1920x1080 PNG',
174
181
  video_status: ffmpeg ? 'rendering' : 'ffmpeg_unavailable_png_frames_written',
@@ -1,10 +1,22 @@
1
+ import type { HostCapabilities } from '../events';
1
2
  import type { BurnReport, SessionState, Thresholds } from '../types';
2
3
  import type { ReserveResult, Transaction } from './reservations';
4
+ /** The exact decision-time measurements retained for local live and replay views. */
5
+ export interface DecisionWindow {
6
+ activeMinutes: number;
7
+ windowActiveMinutes: number;
8
+ spawns: number | null;
9
+ tokens: number | null;
10
+ spawnCeiling: number;
11
+ replayShare: number | null;
12
+ }
13
+ export declare function decisionWindow(state: SessionState, thresholds: Thresholds, proposedSpawn?: boolean, coverage?: Pick<HostCapabilities, 'spawns' | 'usage'>): DecisionWindow;
3
14
  /** Evaluate and reserve together. Pending forks must not bypass either window. */
4
15
  export declare function evaluateSpawnReservation(tx: Transaction, state: SessionState, thresholds: Thresholds, proposedDepth: number, toolUseId: string, now: number, account?: {
5
16
  sessions: SessionState[];
6
17
  now: number;
7
- }): {
18
+ }, coverage?: Pick<HostCapabilities, 'spawns' | 'usage'>): {
8
19
  report: BurnReport;
9
20
  reservation: ReserveResult;
21
+ window: DecisionWindow;
10
22
  };
@@ -1,11 +1,25 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.decisionWindow = decisionWindow;
3
4
  exports.evaluateSpawnReservation = evaluateSpawnReservation;
4
5
  const evaluate_1 = require("../detectors/evaluate");
5
6
  const defaults_1 = require("../defaults");
6
7
  const session_1 = require("./session");
8
+ function decisionWindow(state, thresholds, proposedSpawn = false, coverage) {
9
+ const windowActiveMinutes = thresholds.spawnRate.windowActiveMinutes;
10
+ const spawnKnown = !coverage || coverage.spawns !== 'missing';
11
+ const usageKnown = !coverage || coverage.usage !== 'missing';
12
+ return {
13
+ activeMinutes: state.activeMinutes,
14
+ windowActiveMinutes,
15
+ spawns: spawnKnown ? (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, windowActiveMinutes) + (proposedSpawn ? 1 : 0) : null,
16
+ tokens: usageKnown ? (0, session_1.windowSum)(state.tokensByActiveMinute, state.activeMinutes, windowActiveMinutes) : null,
17
+ spawnCeiling: thresholds.spawnRate.stop,
18
+ replayShare: usageKnown && state.totalTokens > 0 ? Math.min(1, state.totalCacheRead / state.totalTokens) : null,
19
+ };
20
+ }
7
21
  /** Evaluate and reserve together. Pending forks must not bypass either window. */
8
- function evaluateSpawnReservation(tx, state, thresholds, proposedDepth, toolUseId, now, account) {
22
+ function evaluateSpawnReservation(tx, state, thresholds, proposedDepth, toolUseId, now, account, coverage) {
9
23
  const pending = tx.pendingSpawns(state.sessionId, toolUseId, now);
10
24
  const minute = Math.floor(state.activeMinutes);
11
25
  const spawns = new Map(state.spawnsByActiveMinute);
@@ -13,13 +27,14 @@ function evaluateSpawnReservation(tx, state, thresholds, proposedDepth, toolUseI
13
27
  spawns.set(minute, (spawns.get(minute) ?? 0) + pending);
14
28
  const candidateState = { ...state, spawnCount: state.spawnCount + pending, spawnsByActiveMinute: spawns };
15
29
  const report = (0, evaluate_1.evaluate)(candidateState, thresholds, proposedDepth, account);
30
+ const window = decisionWindow(candidateState, thresholds, true, coverage);
16
31
  // Receipts retain the lifetime ordinal, while admission uses the active window.
17
32
  const effectiveSpawns = state.spawnCount + pending + 1;
18
33
  if (report.verdict === 'STOP') {
19
34
  // A refused proposal never consumes a reservation. Its detector explains why.
20
- return { report, reservation: { allowed: true, effectiveSpawns, pending } };
35
+ return { report, reservation: { allowed: true, effectiveSpawns, pending }, window };
21
36
  }
22
37
  const recent = (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, thresholds.fanout.windowActiveMinutes ?? defaults_1.DEFAULT_THRESHOLDS.fanout.windowActiveMinutes);
23
38
  const reservation = tx.reserve({ sessionId: state.sessionId, toolUseId, observedSpawns: recent, ceiling: thresholds.fanout.stop, now });
24
- return { report, reservation: { ...reservation, effectiveSpawns } };
39
+ return { report, reservation: { ...reservation, effectiveSpawns }, window };
25
40
  }