@agentguard-run/burn 0.3.1 → 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 +7 -0
  2. package/README.md +25 -6
  3. package/dist/src/cli.d.ts +1 -0
  4. package/dist/src/cli.js +15 -1
  5. package/dist/src/gateway.js +5 -3
  6. package/dist/src/hook/pre-tool-use.js +2 -1
  7. package/dist/src/ledger-panel.d.ts +40 -0
  8. package/dist/src/ledger-panel.js +174 -0
  9. package/dist/src/live-panel.d.ts +12 -0
  10. package/dist/src/live-panel.js +27 -107
  11. package/dist/src/record-ledger.d.ts +30 -0
  12. package/dist/src/record-ledger.js +165 -0
  13. package/dist/src/recording.js +4 -1
  14. package/dist/src/state/spawn-window.d.ts +13 -1
  15. package/dist/src/state/spawn-window.js +18 -3
  16. package/docs/LIVE_PANEL_2026_09.md +52 -28
  17. package/docs/assets/burn-real-stop-sep20-stop-1920.png +0 -0
  18. package/docs/assets/burn-real-stop-sep20-stop-390.png +0 -0
  19. package/docs/assets/burn-real-stop-sep20.evidence.json +67 -0
  20. package/docs/assets/burn-real-stop-sep20.gif +0 -0
  21. package/docs/assets/burn-real-stop-sep20.jsonl +39 -0
  22. package/docs/assets/burn-real-stop-sep20.mp4 +0 -0
  23. package/docs/assets/{burn-live-sep19.mp4.provenance.json → burn-real-stop-sep20.mp4.provenance.json} +45 -81
  24. package/docs/assets/burn-real-stop-sep20.policy.json +47 -0
  25. package/docs/assets/burn-real-stop-sep20.receipts.ndjson +18 -0
  26. package/docs/burn-render.md +12 -2
  27. package/fixtures/live-sep20-stop-104x35.txt +35 -0
  28. package/package.json +1 -1
  29. package/docs/assets/burn-live-sep19-stop-1920.png +0 -0
  30. package/docs/assets/burn-live-sep19-stop-390.png +0 -0
  31. package/docs/assets/burn-live-sep19.evidence.json +0 -288
  32. package/docs/assets/burn-live-sep19.gif +0 -0
  33. package/docs/assets/burn-live-sep19.jsonl +0 -45
  34. package/docs/assets/burn-live-sep19.mp4 +0 -0
  35. package/docs/assets/burn-live-sep19.observations.jsonl +0 -45
  36. package/docs/assets/burn-live-sep19.receipt.json +0 -38
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.2 (2026-09-21)
4
+
5
+ - Follow the newest ledger decision on every live refresh, unless a session is pinned. Show the raw session ID prefix and record identity switches. A switch immediately releases a previous session's STOP hold.
6
+ - Read panel counters from the hook's decision window, including pending spawn reservations. Record the exact active window, token count and ceiling for future decisions. Elapsed starts at the first ledger decision, not the transcript start. Historical measurements that were never recorded remain unknown.
7
+ - Add `record --session ID --from ISO --to ISO --out run.jsonl` to reconstruct local ledger decisions at their original timestamps with one-second display ticks. No transcript contents or live state changes are needed.
8
+ - Replace the current showcase with the verified September 20 spawn-rate block: 16 proposals against 16, with a full red bar, the STOP reason and original timing. The older hashed display ID referred to this same session; the defect was stale transcript counters versus pending reservations.
9
+
3
10
  ## 0.3.1 (2026-09-20)
4
11
 
5
12
  - Add the live session screen: block-digit counters for spawns, tokens in window and replay share, a spawn-rate bar against the policy ceiling, the last decisions with verdict chips, and a two-second STOP hold, all through the canvas. The same screen renders recorded runs.
package/README.md CHANGED
@@ -387,9 +387,28 @@ No telemetry. No provider-quota guesses. Account forecasts use only observed hos
387
387
 
388
388
  ### Local live panel
389
389
 
390
- `agentguard-burn live` displays spawn and token counts within the active window,
391
- session cache-read share, the policy rate bar and the last eight local decisions.
392
- Use `--session ID`, `--once`, `--record run.jsonl`, or `--replay run.jsonl`.
393
- STOP reasons hold for two seconds while observations continue. The display is
394
- local and does not affect admission. Missing observations remain unknown.
395
- See [the live panel and recorded September 19 evidence](docs/LIVE_PANEL_2026_09.md).
390
+ `agentguard-burn live` follows the session with the newest local ledger decision
391
+ on every refresh. `--session ID` pins a full ID, unique ID prefix or digest.
392
+ The header shows the raw session ID prefix when the decision ledger supplies it.
393
+ Spawn and token counters, the rate ceiling and the last eight decisions come
394
+ from that session's decision ledger. Pending spawn proposals are included,
395
+ exactly as the hook measured them. Elapsed starts at its first ledger decision.
396
+ A change of session is captured as `sessionSwitch` in `--record run.jsonl`.
397
+
398
+ Use `--once`, `--record run.jsonl`, or `--replay run.jsonl`. STOP reasons hold
399
+ for two seconds while observations continue, and a session switch releases
400
+ that hold immediately. The display never changes admission. Historical token
401
+ windows and cache-read share remain unknown when the ledger did not record them.
402
+
403
+ Reconstruct an event after it happened, entirely from local ledgers:
404
+
405
+ ```sh
406
+ agentguard-burn record --session 0b0c2202 --from 2026-09-20T20:20:32Z --to 2026-09-20T20:20:52Z --out run.jsonl
407
+ agentguard-burn render run.jsonl --mp4 run.mp4 --gif run.gif
408
+ ```
409
+
410
+ `record` writes each decision at its real timestamp, with one frame per second
411
+ between decisions. Bounds are optional and otherwise use the session's first
412
+ and last decisions. Earlier decisions still supply window context. The output
413
+ must be new. It contains identifiers and measured metadata, never tool input,
414
+ output or transcript content. See [the live panel and September 20 evidence](docs/LIVE_PANEL_2026_09.md).
package/dist/src/cli.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * ps local agent processes, held memory and orphan workspace disk
6
6
  * reap interactively SIGTERM selected safe processes, never delete
7
+ * record reconstruct a session recording from its ledger decisions
7
8
  * replay what enforcement would have stopped, on your history
8
9
  * calibrate fit thresholds to your own usage, write shadow policy
9
10
  * status mode, shadow decisions, eligibility, every host's sessions
package/dist/src/cli.js CHANGED
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * ps local agent processes, held memory and orphan workspace disk
7
7
  * reap interactively SIGTERM selected safe processes, never delete
8
+ * record reconstruct a session recording from its ledger decisions
8
9
  * replay what enforcement would have stopped, on your history
9
10
  * calibrate fit thresholds to your own usage, write shadow policy
10
11
  * status mode, shadow decisions, eligibility, every host's sessions
@@ -130,6 +131,13 @@ async function main(argv) {
130
131
  const { runLivePanel } = await Promise.resolve().then(() => __importStar(require('./live-panel')));
131
132
  return runLivePanel(HOME, rawRest);
132
133
  }
134
+ case 'record': {
135
+ const { recordLedger, recordLedgerArguments } = await Promise.resolve().then(() => __importStar(require('./record-ledger')));
136
+ const result = recordLedger(HOME, recordLedgerArguments(rest));
137
+ show({ kind: 'command', command, values: { ...result } }, `Recording: ${result.output}\nSession: ${result.session}\nRange: ${result.from} to ${result.to}\n${result.frameCount} frames from ${result.decisionCount} ledger decisions.\n`
138
+ + (result.clippedStopHold ? 'The requested end clips the final STOP hold. No decision timestamps were extended.\n' : ''));
139
+ return 0;
140
+ }
133
141
  case 'ps':
134
142
  case 'reap':
135
143
  return (0, cli_1.runIdleCommand)(command, rawRest, HOME);
@@ -374,9 +382,10 @@ async function main(argv) {
374
382
  return 0;
375
383
  }
376
384
  default:
377
- show({ kind: 'command', command: 'help', values: { commands: ['ps', 'reap', 'live', 'render', 'blocks', 'why', 'rewrites', 'pace', 'statusline', 'pricing', 'replay', 'calibrate', 'status', 'init', 'enforce', 'shadow', 'resume', 'proxy', 'conformance'] } }, 'agentguard-burn <ps|reap|live|render|blocks|why|rewrites|pace|statusline|pricing|replay|calibrate|status|init|enforce|shadow|resume|proxy|conformance>\n' +
385
+ show({ kind: 'command', command: 'help', values: { commands: ['ps', 'reap', 'live', 'record', 'render', 'blocks', 'why', 'rewrites', 'pace', 'statusline', 'pricing', 'replay', 'calibrate', 'status', 'init', 'enforce', 'shadow', 'resume', 'proxy', 'conformance'] } }, 'agentguard-burn <ps|reap|live|record|render|blocks|why|rewrites|pace|statusline|pricing|replay|calibrate|status|init|enforce|shadow|resume|proxy|conformance>\n' +
378
386
  ' ps [--json] local agent processes, held memory and workspace disk\n' +
379
387
  ' render <run.jsonl> --mp4 <out.mp4> [--gif <out.gif>] local recorded-run export\n' +
388
+ ' record --session <id> [--from <iso>] [--to <iso>] --out <run.jsonl> reconstruct local ledger frames\n' +
380
389
  ' live [--session ID] [--once] [--replay run.jsonl] local instrument panel\n' +
381
390
  ' --record <file.jsonl> append structured frames for any command; --no-color keeps plain output\n' +
382
391
  ' reap numbered interactive SIGTERM, never delete or force-kill\n' +
@@ -401,6 +410,11 @@ async function main(argv) {
401
410
  }
402
411
  async function run() {
403
412
  const parsed = (0, recording_1.recordingArguments)(process.argv.slice(2));
413
+ if (parsed.args[0] === 'record' && parsed.file) {
414
+ const { recordLedgerArguments, validateCompanionRecording } = await Promise.resolve().then(() => __importStar(require('./record-ledger')));
415
+ const options = recordLedgerArguments(parsed.args.slice(1).filter(arg => arg !== '--no-color'));
416
+ validateCompanionRecording(HOME, options.out, parsed.file);
417
+ }
404
418
  (0, recording_1.configureRecording)(parsed.file);
405
419
  try {
406
420
  const code = await main(parsed.args);
@@ -219,13 +219,13 @@ class Gateway {
219
219
  }
220
220
  const live = new Map(meta.liveSpawns);
221
221
  const proposedDepth = event.proposedDepth ?? (event.issuerId !== undefined && live.has(event.issuerId) ? live.get(event.issuerId) + 1 : 1);
222
- 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 });
222
+ const { report, reservation, window } = (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 }, meta.capabilities);
223
223
  const wouldBlock = report.verdict === 'STOP' || !reservation.allowed;
224
224
  const verdict = wouldBlock ? 'STOP' : report.verdict;
225
225
  // Session-scope STOPs need a session we trust. Fan-out is session scope.
226
226
  const due = wouldBlock && policy.mode === 'enforce' && event.attribution === 'high';
227
227
  const override = due ? (0, override_1.consumeOverride)(this.home, event.at) : null;
228
- const decision = this.finish({ action: 'spawn', host: event.host, sessionId: event.sessionId, at: event.at, proposedDepth, verdict, wouldBlock, blocked: due && !override, override, report, effectiveSpawns: reservation.effectiveSpawns, compute: null }, policy, meta, state);
228
+ const decision = this.finish({ action: 'spawn', host: event.host, sessionId: event.sessionId, at: event.at, proposedDepth, verdict, wouldBlock, blocked: due && !override, override, report, effectiveSpawns: reservation.effectiveSpawns, compute: null, window }, policy, meta, state);
229
229
  this.save(meta, state);
230
230
  return decision;
231
231
  });
@@ -272,7 +272,8 @@ class Gateway {
272
272
  const due = wouldBlock && policy.mode === 'enforce' && (event.attribution === 'high' || machineStop);
273
273
  const override = due ? (0, override_1.consumeOverride)(this.home, event.at) : null;
274
274
  const blocked = due && !override;
275
- const decision = this.finish({ action: 'model_call', host: event.host, sessionId: event.sessionId, at: event.at, proposedDepth: null, verdict: report.verdict, wouldBlock, blocked, override, report, effectiveSpawns: state.spawnCount, compute }, policy, meta, state);
275
+ const decision = this.finish({ action: 'model_call', host: event.host, sessionId: event.sessionId, at: event.at, proposedDepth: null, verdict: report.verdict, wouldBlock, blocked, override, report, effectiveSpawns: state.spawnCount, compute,
276
+ window: (0, spawn_window_1.decisionWindow)(state, policy.thresholds, false, meta.capabilities) }, policy, meta, state);
276
277
  if (blocked) {
277
278
  // A denied call neither occupies the machine nor spends tokens.
278
279
  tx.finishCall(event.callId, windowMs(policy), event.at);
@@ -379,6 +380,7 @@ class Gateway {
379
380
  findings: d.report.findings.map((f) => ({ detector: f.detector, verdict: f.verdict, observed: f.observed, threshold: f.threshold })),
380
381
  effectiveSpawns: d.effectiveSpawns,
381
382
  totals: d.report.totals,
383
+ window: d.window,
382
384
  compute: d.compute,
383
385
  coverage: meta.capabilities,
384
386
  })}\n`, { mode: 0o600 });
@@ -189,7 +189,7 @@ function handleSpawnPreToolUse(input, home, now) {
189
189
  // Depth of the proposed child: the issuing agent's depth plus one. A hook
190
190
  // fired inside a subagent carries agent_id; treat that as depth 1 issuer.
191
191
  const proposedDepth = input.agent_id ? 2 : 1;
192
- 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 });
192
+ const { report, reservation, window } = (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 });
193
193
  const shouldDeny = report.verdict === 'STOP' || !reservation.allowed;
194
194
  const reason = shouldDeny ? buildDenyReason(report, reservation) : '';
195
195
  // The audited override: only consulted when a block is about to happen.
@@ -223,6 +223,7 @@ function handleSpawnPreToolUse(input, home, now) {
223
223
  findings: report.findings.map((f) => ({ detector: f.detector, verdict: f.verdict, observed: f.observed, threshold: f.threshold })),
224
224
  effectiveSpawns: reservation.effectiveSpawns,
225
225
  totals: report.totals,
226
+ window,
226
227
  });
227
228
  if (shouldDeny && policy.mode === 'enforce') {
228
229
  if (!override) {
@@ -0,0 +1,40 @@
1
+ import type { LivePanelSnapshot, PanelDecision } from './live-panel';
2
+ import type { Verdict } from './types';
3
+ export interface LedgerWindow {
4
+ activeMinutes: number | null;
5
+ windowActiveMinutes: number | null;
6
+ spawns: number | null;
7
+ tokens: number | null;
8
+ spawnCeiling: number | null;
9
+ replayShare: number | null;
10
+ }
11
+ export interface LedgerSession {
12
+ sessionId: string | null;
13
+ sessionDigest: string;
14
+ }
15
+ export interface LedgerDecision extends PanelDecision, LedgerSession {
16
+ /** Unique chronological order after joining matching receipts. */
17
+ sequence: number;
18
+ activeMinutes: number | null;
19
+ effectiveSpawns: number | null;
20
+ totalSpawns: number | null;
21
+ totalTokens: number | null;
22
+ window: LedgerWindow | null;
23
+ rate: {
24
+ observed: number;
25
+ threshold: number | null;
26
+ verdict: Verdict;
27
+ } | null;
28
+ usageKnown: boolean;
29
+ spawnsKnown: boolean;
30
+ /** Loaded only when the signed receipt proves the exact policy digest. */
31
+ legacyPolicy: {
32
+ windowActiveMinutes: number;
33
+ spawnCeiling: number;
34
+ } | null;
35
+ }
36
+ /** Join each receipt once. Signed receipt identifiers do not duplicate decisions. */
37
+ export declare function readPanelLedger(home: string): LedgerDecision[];
38
+ /** Accept full ids or unique raw-id/digest prefixes; never silently pick ambiguity. */
39
+ export declare function resolveLedgerSession(rows: LedgerDecision[], selector?: string): LedgerSession | undefined;
40
+ export declare function projectLedgerPanel(rows: LedgerDecision[], selector?: string, at?: number): LivePanelSnapshot;
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readPanelLedger = readPanelLedger;
4
+ exports.resolveLedgerSession = resolveLedgerSession;
5
+ exports.projectLedgerPanel = projectLedgerPanel;
6
+ /** Content-free panel projection from the decisions the local gate recorded. */
7
+ const node_crypto_1 = require("node:crypto");
8
+ const node_fs_1 = require("node:fs");
9
+ const node_path_1 = require("node:path");
10
+ const node_string_decoder_1 = require("node:string_decoder");
11
+ const defaults_1 = require("./defaults");
12
+ const policy_1 = require("./policy");
13
+ const receipt_1 = require("./receipt");
14
+ const digest = (text) => (0, node_crypto_1.createHash)('sha256').update(text).digest('hex');
15
+ const object = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
16
+ const number = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
17
+ const positive = (value) => number(value) !== null && Number(value) > 0 ? Number(value) : null;
18
+ const verdict = (value) => value === 'OK' || value === 'WARN' || value === 'STOP' ? value : null;
19
+ const safe = (value, fallback) => typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,160}$/.test(value) ? value : fallback;
20
+ const known = (coverage) => coverage === undefined || coverage === 'authoritative' || coverage === 'estimated';
21
+ /** Scan complete history in chunks, including a complete final line without LF. */
22
+ function rows(file) {
23
+ let fd;
24
+ try {
25
+ fd = (0, node_fs_1.openSync)(file, 'r');
26
+ }
27
+ catch {
28
+ return [];
29
+ }
30
+ const out = [], decoder = new node_string_decoder_1.StringDecoder('utf8'), bytes = Buffer.alloc(64 * 1024);
31
+ let pending = '';
32
+ const parse = (line) => { try {
33
+ const value = object(JSON.parse(line));
34
+ if (Object.keys(value).length)
35
+ out.push(value);
36
+ }
37
+ catch { /* Incomplete or malformed rows are not observations. */ } };
38
+ try {
39
+ let length;
40
+ while ((length = (0, node_fs_1.readSync)(fd, bytes, 0, bytes.length, null)) > 0) {
41
+ pending += decoder.write(bytes.subarray(0, length));
42
+ let end;
43
+ while ((end = pending.indexOf('\n')) >= 0) {
44
+ parse(pending.slice(0, end));
45
+ pending = pending.slice(end + 1);
46
+ }
47
+ }
48
+ pending += decoder.end();
49
+ if (pending.trim())
50
+ parse(pending);
51
+ }
52
+ finally {
53
+ (0, node_fs_1.closeSync)(fd);
54
+ }
55
+ return out;
56
+ }
57
+ function parseWindow(value) {
58
+ if (!value || typeof value !== 'object' || Array.isArray(value))
59
+ return null;
60
+ const w = object(value), replay = number(w.replayShare);
61
+ return { activeMinutes: number(w.activeMinutes), windowActiveMinutes: positive(w.windowActiveMinutes),
62
+ spawns: number(w.spawns), tokens: number(w.tokens), spawnCeiling: positive(w.spawnCeiling),
63
+ replayShare: replay !== null && replay <= 1 ? replay : null };
64
+ }
65
+ function decision(raw, sequence) {
66
+ const sessionId = typeof raw.sessionId === 'string' && raw.sessionId.length ? raw.sessionId : null;
67
+ const at = number(raw.at), result = verdict(raw.verdict);
68
+ if (!sessionId || at === null || !Number.isFinite(new Date(at).getTime()) || !result)
69
+ return null;
70
+ const findings = Array.isArray(raw.findings) ? raw.findings.map(object) : [];
71
+ const rate = findings.find(f => f.detector === 'spawn_rate' && number(f.observed) !== null && verdict(f.verdict));
72
+ const totals = object(raw.totals), coverage = object(raw.coverage), window = parseWindow(raw.window);
73
+ return { sessionId, sessionDigest: digest(sessionId), sequence, at, verdict: result,
74
+ id: safe(raw.toolUseId, digest(JSON.stringify(raw)) + ':' + sequence), tool: safe(raw.action ?? raw.tool, 'spawn'),
75
+ blocked: typeof raw.enforced === 'boolean' ? raw.enforced : null,
76
+ reason: findings.map(f => `${safe(f.detector, 'unknown')}:${verdict(f.verdict) ?? 'OK'}`).join(' + ')
77
+ || (raw.failClosed === true ? 'local fail-closed condition' : result === 'OK' ? 'within policy' : 'reason unavailable'),
78
+ activeMinutes: window?.activeMinutes ?? number(totals.activeMinutes), effectiveSpawns: number(raw.effectiveSpawns),
79
+ totalSpawns: number(totals.spawns), totalTokens: number(totals.tokens), window,
80
+ rate: rate ? { observed: Number(rate.observed), threshold: positive(rate.threshold), verdict: verdict(rate.verdict) } : null,
81
+ usageKnown: known(coverage.usage), spawnsKnown: known(coverage.spawns), legacyPolicy: null };
82
+ }
83
+ function receipt(raw, sequence) {
84
+ const p = object(raw.payload), at = number(p.at), result = verdict(p.verdict);
85
+ if (typeof p.sessionDigest !== 'string' || !/^[a-f0-9]{64}$/.test(p.sessionDigest) || at === null || !Number.isFinite(new Date(at).getTime()) || !result)
86
+ return null;
87
+ const measured = object(p.measured), coverage = object(p.coverage);
88
+ return { sessionId: null, sessionDigest: p.sessionDigest, sequence, at, verdict: result,
89
+ id: safe(p.decisionId, digest(JSON.stringify(p))), tool: safe(p.action, 'unknown'),
90
+ blocked: typeof p.blocked === 'boolean' ? p.blocked : null,
91
+ reason: Array.isArray(p.reasons) && p.reasons.length ? [...new Set(p.reasons.map(reason => safe(reason, 'unknown')))].join(' + ') : result === 'OK' ? 'within policy' : 'reason unavailable',
92
+ activeMinutes: null, effectiveSpawns: number(measured.sessionSpawns), totalSpawns: null, totalTokens: number(measured.sessionTokens),
93
+ window: parseWindow(p.window), rate: null, usageKnown: known(coverage.usage), spawnsKnown: known(coverage.spawns), legacyPolicy: null };
94
+ }
95
+ /** Join each receipt once. Signed receipt identifiers do not duplicate decisions. */
96
+ function readPanelLedger(home) {
97
+ const decisions = rows((0, node_path_1.join)(home, 'decisions.ndjson')).flatMap((raw, i) => { const d = decision(raw, i); return d ? [d] : []; });
98
+ const ids = new Map(decisions.map(d => [d.sessionDigest, d.sessionId]));
99
+ const policy = (0, policy_1.loadPolicy)(home, { notice: false }), policyDigest = digest((0, receipt_1.canonical)(policy));
100
+ const matched = new Set();
101
+ for (const [i, raw] of rows((0, node_path_1.join)(home, 'receipts.ndjson')).entries()) {
102
+ const r = receipt(raw, decisions.length + i);
103
+ if (!r)
104
+ continue;
105
+ try {
106
+ if (object(object(raw.payload).policy).digest === policyDigest && (0, receipt_1.verifyReceipt)(raw)) {
107
+ r.legacyPolicy = { windowActiveMinutes: policy.thresholds.spawnRate.windowActiveMinutes, spawnCeiling: policy.thresholds.spawnRate.stop };
108
+ }
109
+ }
110
+ catch { /* Unverified policy metadata cannot define a historical window. */ }
111
+ const index = decisions.findIndex((d, j) => !matched.has(j) && d.sessionDigest === r.sessionDigest && d.at === r.at && d.tool === r.tool && d.verdict === r.verdict && d.blocked === r.blocked);
112
+ if (index >= 0) {
113
+ decisions[index] = { ...decisions[index], id: r.id, reason: r.reason, blocked: r.blocked, legacyPolicy: r.legacyPolicy };
114
+ matched.add(index);
115
+ }
116
+ else {
117
+ matched.add(decisions.length);
118
+ decisions.push({ ...r, sessionId: ids.get(r.sessionDigest) ?? null });
119
+ }
120
+ }
121
+ return decisions.sort((a, b) => a.at - b.at || a.sequence - b.sequence).map((d, sequence) => ({ ...d, sequence }));
122
+ }
123
+ /** Accept full ids or unique raw-id/digest prefixes; never silently pick ambiguity. */
124
+ function resolveLedgerSession(rows, selector) {
125
+ const sessions = new Map();
126
+ for (const r of rows)
127
+ sessions.set(r.sessionDigest, { sessionId: r.sessionId ?? sessions.get(r.sessionDigest)?.sessionId ?? null, sessionDigest: r.sessionDigest });
128
+ if (!selector) {
129
+ const latest = rows.at(-1);
130
+ return latest ? sessions.get(latest.sessionDigest) : undefined;
131
+ }
132
+ const all = [...sessions.values()];
133
+ const exact = all.filter(s => s.sessionId === selector || s.sessionDigest === selector);
134
+ const matching = exact.length ? exact : all.filter(s => s.sessionId?.startsWith(selector) || s.sessionDigest.startsWith(selector));
135
+ if (matching.length > 1)
136
+ throw new Error('Session prefix is ambiguous. Use the full session id or digest.');
137
+ return matching[0];
138
+ }
139
+ /** Older ledgers can prove a whole-lifetime window, but not an arbitrary token window. */
140
+ function legacySpawns(rows, current, windowMinutes) {
141
+ if (!current.spawnsKnown)
142
+ return null;
143
+ if (current.rate)
144
+ return current.rate.observed;
145
+ if (windowMinutes === null || current.activeMinutes === null || current.effectiveSpawns === null || current.tool !== 'spawn')
146
+ return null;
147
+ if (Math.floor(current.activeMinutes) <= windowMinutes)
148
+ return current.effectiveSpawns;
149
+ const first = rows.find(d => d.tool === 'spawn');
150
+ if (!first || first.totalSpawns !== 0 || first.effectiveSpawns !== 1 || first.activeMinutes === null)
151
+ return null;
152
+ const from = Math.max(0, Math.floor(current.activeMinutes) - windowMinutes);
153
+ return Math.floor(first.activeMinutes) >= from ? current.effectiveSpawns : null;
154
+ }
155
+ function projectLedgerPanel(rows, selector, at = Date.now()) {
156
+ const available = rows.filter(d => d.at <= at), session = resolveLedgerSession(available, selector);
157
+ const defaults = defaults_1.DEFAULT_THRESHOLDS.spawnRate;
158
+ const base = { session: 'unavailable', startedAt: at, observedAt: at, elapsedMs: null,
159
+ spawns: null, tokens: null, replayShare: null, windowActiveMinutes: defaults.windowActiveMinutes, spawnCeiling: defaults.stop,
160
+ decisions: [], note: 'No readable ledger decisions. Waiting for local observations.' };
161
+ if (!session)
162
+ return base;
163
+ const selected = available.filter(d => d.sessionDigest === session.sessionDigest), current = selected.at(-1);
164
+ const window = current.window, windowMinutes = window?.windowActiveMinutes ?? current.legacyPolicy?.windowActiveMinutes ?? null;
165
+ const spawns = window ? current.spawnsKnown ? window.spawns : null : legacySpawns(selected, current, windowMinutes);
166
+ const tokens = current.usageKnown ? window ? window.tokens
167
+ : windowMinutes !== null && current.activeMinutes !== null && Math.floor(current.activeMinutes) <= windowMinutes ? current.totalTokens : null : null;
168
+ const decisions = selected.slice(-8).map(({ id, at, tool, verdict, reason, blocked }) => ({ id, at, tool, verdict, reason, blocked }));
169
+ return { ...base, session: (session.sessionId ?? session.sessionDigest).slice(0, 8), sessionDigest: session.sessionDigest, startedAt: selected[0].at, elapsedMs: at - selected[0].at,
170
+ spawns, tokens, replayShare: current.usageKnown ? window?.replayShare ?? null : null,
171
+ windowActiveMinutes: windowMinutes, spawnCeiling: window?.spawnCeiling ?? (current.rate?.verdict === 'STOP' ? current.rate.threshold : null) ?? current.legacyPolicy?.spawnCeiling ?? null,
172
+ decisions, note: window ? 'Ledger decision snapshot. Counters include the proposal evaluated by the hook.'
173
+ : `Ledger decisions. ${tokens === null ? 'Token window and replay share were not recorded.' : 'Replay share was not recorded.'}${spawns === null ? ' Spawn window is unknown.' : ''}${current.legacyPolicy ? ' Policy digest verified.' : ''}` };
174
+ }
@@ -11,8 +11,14 @@ export interface PanelDecision {
11
11
  }
12
12
  export interface LivePanelSnapshot {
13
13
  session: string;
14
+ sessionDigest?: string;
14
15
  startedAt: number;
15
16
  observedAt: number;
17
+ sessionSwitch?: {
18
+ from: string;
19
+ to: string;
20
+ at: number;
21
+ };
16
22
  spawns: number | null;
17
23
  tokens: number | null;
18
24
  replayShare: number | null;
@@ -25,8 +31,14 @@ export interface LivePanelSnapshot {
25
31
  note: string;
26
32
  stop?: PanelDecision;
27
33
  }
34
+ /** The ledger is the single source for decisions, counters and session selection. */
28
35
  export declare function panelDecisions(home: string, sessionId: string, at?: number): PanelDecision[];
29
36
  export declare function collectLivePanel(home: string, session?: string, now?: number): LivePanelSnapshot;
37
+ /** Record changes of identity, including switches between sessions with the same short prefix. */
38
+ export declare class SessionFollow {
39
+ private previous?;
40
+ update(snapshot: LivePanelSnapshot, now: number): LivePanelSnapshot;
41
+ }
30
42
  export declare function spawnBarColor(spawns: number | null, ceiling: number | null): CanvasColor;
31
43
  export declare function renderLivePanel(snapshot: LivePanelSnapshot, options?: FrameOptions): Canvas;
32
44
  /** STOP report adaptation uses only already-computed metadata, never local I/O. */
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.StopHold = void 0;
3
+ exports.StopHold = exports.SessionFollow = void 0;
4
4
  exports.panelDecisions = panelDecisions;
5
5
  exports.collectLivePanel = collectLivePanel;
6
6
  exports.spawnBarColor = spawnBarColor;
@@ -10,116 +10,29 @@ exports.runLivePanel = runLivePanel;
10
10
  /** Local, content-free instrument panel. It observes; it never changes admission. */
11
11
  const node_crypto_1 = require("node:crypto");
12
12
  const node_fs_1 = require("node:fs");
13
- const node_path_1 = require("node:path");
14
13
  const canvas_1 = require("./canvas");
15
- const policy_1 = require("./policy");
14
+ const ledger_panel_1 = require("./ledger-panel");
16
15
  const recording_1 = require("./recording");
17
- const session_1 = require("./state/session");
18
16
  const digest = (text) => (0, node_crypto_1.createHash)('sha256').update(text).digest('hex');
19
- const number = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
20
- const row = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
21
- const safe = (v, fallback) => typeof v === 'string' && /^[A-Za-z0-9_.:-]{1,80}$/.test(v) ? v : fallback;
22
- const verdict = (v) => v === 'OK' || v === 'WARN' || v === 'STOP' ? v : null;
23
- function buckets(value) {
24
- if (!Array.isArray(value) || !value.every(v => Array.isArray(v) && v.length === 2 && number(v[0]) !== null && number(v[1]) !== null))
25
- return null;
26
- return new Map(value);
27
- }
28
- /** Read a bounded tail and discard an incomplete first/last JSON row. */
29
- function ledger(file) {
30
- try {
31
- const size = (0, node_fs_1.statSync)(file).size, offset = Math.max(0, size - 4 * 1024 * 1024), fd = (0, node_fs_1.openSync)(file, 'r');
32
- const bytes = Buffer.alloc(size - offset);
33
- try {
34
- (0, node_fs_1.readSync)(fd, bytes, 0, bytes.length, offset);
35
- }
36
- finally {
37
- (0, node_fs_1.closeSync)(fd);
38
- }
39
- const text = bytes.toString('utf8'), lines = text.slice(offset ? text.indexOf('\n') + 1 : 0, text.lastIndexOf('\n') + 1).split('\n');
40
- return lines.flatMap(line => { try {
41
- return [row(JSON.parse(line))];
42
- }
43
- catch {
44
- return [];
45
- } });
46
- }
47
- catch {
48
- return [];
49
- }
50
- }
17
+ /** The ledger is the single source for decisions, counters and session selection. */
51
18
  function panelDecisions(home, sessionId, at = Date.now()) {
52
- const sessionDigest = digest(sessionId);
53
- const receiptRows = ledger((0, node_path_1.join)(home, 'receipts.ndjson')).flatMap(raw => {
54
- const p = row(raw.payload);
55
- if (p.sessionDigest !== sessionDigest || number(p.at) === null || Number(p.at) > at || !verdict(p.verdict))
56
- return [];
57
- return [{ id: safe(p.decisionId, digest(JSON.stringify(p))), at: Number(p.at), tool: safe(p.action, 'unknown'), verdict: verdict(p.verdict),
58
- reason: Array.isArray(p.reasons) ? [...new Set(p.reasons.map(v => safe(v, 'unknown')))].join(' + ') : 'reason unavailable', blocked: p.blocked === true }];
59
- });
60
- const decisions = ledger((0, node_path_1.join)(home, 'decisions.ndjson')).flatMap(p => {
61
- if (p.sessionId !== sessionId || number(p.at) === null || Number(p.at) > at || !verdict(p.verdict))
62
- return [];
63
- const findings = Array.isArray(p.findings) ? p.findings.map(row) : [];
64
- return [{ id: safe(p.toolUseId, digest(JSON.stringify(p))), at: Number(p.at), tool: safe(p.action ?? p.tool, 'spawn'), verdict: verdict(p.verdict),
65
- reason: findings.map(f => `${safe(f.detector, 'unknown')}:${verdict(f.verdict) ?? 'OK'}`).join(' + ') || (p.failClosed === true ? 'local fail-closed condition' : p.verdict === 'OK' ? 'within policy' : 'reason unavailable'), blocked: p.enforced === true }];
66
- });
67
- const merged = [...decisions], joined = new Set();
68
- for (const d of receiptRows) {
69
- const index = merged.findIndex((candidate, i) => !joined.has(i) && candidate.at === d.at && candidate.tool === d.tool && candidate.verdict === d.verdict && candidate.blocked === d.blocked);
70
- if (index >= 0) {
71
- merged[index] = d;
72
- joined.add(index);
73
- }
74
- else {
75
- joined.add(merged.length);
76
- merged.push(d);
77
- }
78
- }
79
- return merged.sort((a, b) => a.at - b.at).slice(-8);
19
+ return (0, ledger_panel_1.projectLedgerPanel)((0, ledger_panel_1.readPanelLedger)(home), sessionId, at).decisions;
80
20
  }
81
21
  function collectLivePanel(home, session, now = Date.now()) {
82
- const policy = (0, policy_1.loadPolicy)(home, { notice: false });
83
- const configured = policy.thresholds.spawnRate;
84
- const windowActiveMinutes = number(configured?.windowActiveMinutes) || 15, spawnCeiling = number(configured?.stop) || 16;
85
- const base = { session: 'unavailable', startedAt: now, observedAt: now, spawns: null, tokens: null, replayShare: null,
86
- windowActiveMinutes, spawnCeiling, decisions: [], note: 'No readable session state. Waiting for local observations.' };
87
- let names;
88
- try {
89
- names = (0, node_fs_1.readdirSync)((0, node_path_1.join)(home, 'sessions')).filter(name => name.endsWith('.json'));
90
- }
91
- catch {
92
- return base;
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 } : {}) };
93
33
  }
94
- const candidates = names.flatMap(name => {
95
- try {
96
- const path = (0, node_path_1.join)(home, 'sessions', name);
97
- if ((0, node_fs_1.statSync)(path).size > 16 * 1024 * 1024)
98
- return [];
99
- const raw = row(JSON.parse((0, node_fs_1.readFileSync)(path, 'utf8'))), state = row(raw.state);
100
- if (typeof state.sessionId !== 'string' || number(state.lastEventAt) === null || number(state.startedAt) === null)
101
- return [];
102
- if (session && state.sessionId !== session && !state.sessionId.startsWith(session) && !digest(state.sessionId).startsWith(session))
103
- return [];
104
- return [{ raw, state }];
105
- }
106
- catch {
107
- return [];
108
- }
109
- }).sort((a, b) => Number(b.state.lastEventAt) - Number(a.state.lastEventAt));
110
- if (!candidates.length)
111
- return base;
112
- const { raw, state } = candidates[0], active = number(state.activeMinutes), spawned = buckets(state.spawnsByActiveMinute), tokens = buckets(state.tokensByActiveMinute);
113
- const total = number(state.totalTokens), cached = number(state.totalCacheRead), decisions = panelDecisions(home, String(state.sessionId), now);
114
- const coverage = row(raw.capabilities), gateway = Array.isArray(raw.hosts);
115
- const spawnKnown = !gateway || ['authoritative', 'estimated'].includes(String(coverage.spawns));
116
- const usageKnown = !gateway || ['authoritative', 'estimated'].includes(String(coverage.usage));
117
- return { ...base, session: digest(String(state.sessionId)).slice(0, 8), startedAt: Number(state.startedAt),
118
- spawns: spawnKnown && spawned && active !== null ? (0, session_1.windowSum)(spawned, active, windowActiveMinutes) : null,
119
- tokens: usageKnown && tokens && active !== null ? (0, session_1.windowSum)(tokens, active, windowActiveMinutes) : null,
120
- replayShare: usageKnown && total !== null && cached !== null && total > 0 ? Math.min(1, cached / total) : null, decisions,
121
- note: row(raw.cursor).usageVersion === undefined && raw.cursor ? 'Legacy stored usage counts; no recount applied.' : gateway ? `Local observations. Coverage: spawns ${safe(coverage.spawns, 'unknown')}, usage ${safe(coverage.usage, 'unknown')}.` : 'Local observations only. Replay share is session cache-read / tokens.' };
122
34
  }
35
+ exports.SessionFollow = SessionFollow;
123
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' };
124
37
  function spawnBarColor(spawns, ceiling) {
125
38
  return spawns === null || ceiling === null ? 'slate' : spawns >= ceiling ? 'red' : spawns >= ceiling * .8 ? 'amber' : 'mint';
@@ -178,7 +91,7 @@ function panelFromReport(report, at, outcome, subject) {
178
91
  const note = outcome === 'blocked' ? (subject === 'call' ? 'model call blocked' : 'agent spawn blocked')
179
92
  : outcome === 'shadow' ? 'shadow: would have blocked, call allowed' : outcome === 'overridden' ? 'override: call allowed'
180
93
  : outcome === 'allowed' ? 'call allowed' : 'STOP boundary · enforcement outcome not recorded';
181
- return { session: digest(report.sessionId).slice(0, 8), startedAt: at, observedAt: at, elapsedMs: null, controls: false, stopHold: false,
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,
182
95
  spawns: rate?.observed ?? null, tokens: null, replayShare: Number.isFinite(report.cacheReadRatio) ? report.cacheReadRatio : null,
183
96
  windowActiveMinutes: null, spawnCeiling: rate?.verdict === 'STOP' ? rate.threshold : null,
184
97
  decisions: [decision], stop: decision, note: `${note}. Unknown window metrics remain unknown.` };
@@ -190,8 +103,15 @@ class StopHold {
190
103
  seen = new Set();
191
104
  latest;
192
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
+ }
193
111
  this.latest = snapshot;
194
- const stop = snapshot.decisions.filter(d => d.verdict === 'STOP' && !this.seen.has(d.id)).at(-1);
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;
195
115
  for (const decision of snapshot.decisions)
196
116
  this.seen.add(decision.id);
197
117
  if (stop) {
@@ -260,10 +180,10 @@ async function runLivePanel(home, args) {
260
180
  }
261
181
  }
262
182
  else {
263
- const hold = new StopHold();
183
+ const hold = new StopHold(), follow = new SessionFollow();
264
184
  do {
265
185
  if (!paused) {
266
- const now = Date.now(), snapshot = hold.update(collectLivePanel(home, selected, now), now);
186
+ const now = Date.now(), snapshot = follow.update(hold.update(collectLivePanel(home, selected, now), now), now);
267
187
  display(snapshot, false, new Date(now).toISOString());
268
188
  }
269
189
  if (!once)
@@ -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;