@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.
- package/CHANGELOG.md +7 -0
- package/README.md +25 -6
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.js +15 -1
- package/dist/src/gateway.js +5 -3
- package/dist/src/hook/pre-tool-use.js +2 -1
- package/dist/src/ledger-panel.d.ts +40 -0
- package/dist/src/ledger-panel.js +174 -0
- package/dist/src/live-panel.d.ts +12 -0
- package/dist/src/live-panel.js +27 -107
- package/dist/src/record-ledger.d.ts +30 -0
- package/dist/src/record-ledger.js +165 -0
- package/dist/src/recording.js +4 -1
- package/dist/src/state/spawn-window.d.ts +13 -1
- package/dist/src/state/spawn-window.js +18 -3
- package/docs/LIVE_PANEL_2026_09.md +52 -28
- package/docs/assets/burn-real-stop-sep20-stop-1920.png +0 -0
- package/docs/assets/burn-real-stop-sep20-stop-390.png +0 -0
- package/docs/assets/burn-real-stop-sep20.evidence.json +67 -0
- package/docs/assets/burn-real-stop-sep20.gif +0 -0
- package/docs/assets/burn-real-stop-sep20.jsonl +39 -0
- package/docs/assets/burn-real-stop-sep20.mp4 +0 -0
- package/docs/assets/{burn-live-sep19.mp4.provenance.json → burn-real-stop-sep20.mp4.provenance.json} +45 -81
- package/docs/assets/burn-real-stop-sep20.policy.json +47 -0
- package/docs/assets/burn-real-stop-sep20.receipts.ndjson +18 -0
- package/docs/burn-render.md +12 -2
- package/fixtures/live-sep20-stop-104x35.txt +35 -0
- package/package.json +1 -1
- package/docs/assets/burn-live-sep19-stop-1920.png +0 -0
- package/docs/assets/burn-live-sep19-stop-390.png +0 -0
- package/docs/assets/burn-live-sep19.evidence.json +0 -288
- package/docs/assets/burn-live-sep19.gif +0 -0
- package/docs/assets/burn-live-sep19.jsonl +0 -45
- package/docs/assets/burn-live-sep19.mp4 +0 -0
- package/docs/assets/burn-live-sep19.observations.jsonl +0 -45
- 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`
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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);
|
package/dist/src/gateway.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/src/live-panel.d.ts
CHANGED
|
@@ -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. */
|
package/dist/src/live-panel.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return
|
|
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
|
-
|
|
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;
|