@agentguard-run/burn 0.3.0 → 0.3.1
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 +5 -0
- package/README.md +9 -0
- package/dist/src/cli.js +6 -1
- package/dist/src/frames.d.ts +4 -0
- package/dist/src/frames.js +10 -0
- package/dist/src/live-panel.d.ts +42 -0
- package/dist/src/live-panel.js +283 -0
- package/dist/src/policy.d.ts +3 -1
- package/dist/src/policy.js +2 -2
- package/dist/src/recording.js +23 -1
- package/dist/src/render-recording.d.ts +2 -0
- package/dist/src/render-recording.js +20 -13
- package/docs/LIVE_PANEL_2026_09.md +55 -0
- 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 +288 -0
- package/docs/assets/burn-live-sep19.gif +0 -0
- package/docs/assets/burn-live-sep19.jsonl +45 -0
- package/docs/assets/burn-live-sep19.mp4 +0 -0
- package/docs/assets/burn-live-sep19.mp4.provenance.json +292 -0
- package/docs/assets/burn-live-sep19.observations.jsonl +45 -0
- package/docs/assets/burn-live-sep19.receipt.json +38 -0
- package/fixtures/live-panel-104x35.txt +35 -0
- package/fixtures/live-panel-80x24.txt +24 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.1 (2026-09-20)
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- Ship the September 19 recorded run as docs/assets/burn-live-sep19 with an mp4, gif, evidence and provenance files.
|
|
7
|
+
|
|
3
8
|
## 0.3.0 (2026-09-20)
|
|
4
9
|
|
|
5
10
|
- Add local ps and JSON audits for Claude Code and Codex processes, leftover plugin daemons, automation browsers, workspace disk and current swap. Report held memory and cost exposure with explicit unknown evidence.
|
package/README.md
CHANGED
|
@@ -384,3 +384,12 @@ A provenance JSON beside the requested MP4 records the source recording's SHA256
|
|
|
384
384
|
|
|
385
385
|
No prompts, responses, file contents, or tool inputs are persisted or rendered.
|
|
386
386
|
No telemetry. No provider-quota guesses. Account forecasts use only observed host percentages, never an invented allowance or cache weighting.
|
|
387
|
+
|
|
388
|
+
### Local live panel
|
|
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).
|
package/dist/src/cli.js
CHANGED
|
@@ -126,6 +126,10 @@ async function main(argv) {
|
|
|
126
126
|
};
|
|
127
127
|
const has = (name) => rawRest.includes(name);
|
|
128
128
|
switch (command) {
|
|
129
|
+
case 'live': {
|
|
130
|
+
const { runLivePanel } = await Promise.resolve().then(() => __importStar(require('./live-panel')));
|
|
131
|
+
return runLivePanel(HOME, rawRest);
|
|
132
|
+
}
|
|
129
133
|
case 'ps':
|
|
130
134
|
case 'reap':
|
|
131
135
|
return (0, cli_1.runIdleCommand)(command, rawRest, HOME);
|
|
@@ -370,9 +374,10 @@ async function main(argv) {
|
|
|
370
374
|
return 0;
|
|
371
375
|
}
|
|
372
376
|
default:
|
|
373
|
-
show({ kind: 'command', command: 'help', values: { commands: ['ps', 'reap', 'render', 'blocks', 'why', 'rewrites', 'pace', 'statusline', 'pricing', 'replay', 'calibrate', 'status', 'init', 'enforce', 'shadow', 'resume', 'proxy', 'conformance'] } }, 'agentguard-burn <ps|reap|render|blocks|why|rewrites|pace|statusline|pricing|replay|calibrate|status|init|enforce|shadow|resume|proxy|conformance>\n' +
|
|
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' +
|
|
374
378
|
' ps [--json] local agent processes, held memory and workspace disk\n' +
|
|
375
379
|
' render <run.jsonl> --mp4 <out.mp4> [--gif <out.gif>] local recorded-run export\n' +
|
|
380
|
+
' live [--session ID] [--once] [--replay run.jsonl] local instrument panel\n' +
|
|
376
381
|
' --record <file.jsonl> append structured frames for any command; --no-color keeps plain output\n' +
|
|
377
382
|
' reap numbered interactive SIGTERM, never delete or force-kill\n' +
|
|
378
383
|
' blocks [session|all] stored WARN/STOP receipts and that session\'s child usage\n' +
|
package/dist/src/frames.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** Recorded frames carry observations, not ANSI, screen text or process arguments. */
|
|
2
2
|
import { Canvas } from './canvas';
|
|
3
|
+
import { type LivePanelSnapshot } from './live-panel';
|
|
3
4
|
import type { AuditReport, AuditRow } from './idle/types';
|
|
4
5
|
import type { BurnReport } from './types';
|
|
5
6
|
import type { ReplaySummary } from './replay/simulate';
|
|
@@ -7,6 +8,9 @@ export type AuditFrameReport = Omit<AuditReport, 'processes' | 'rows'> & {
|
|
|
7
8
|
rows: Array<Omit<AuditRow, 'processes'>>;
|
|
8
9
|
};
|
|
9
10
|
export type BurnFrame = {
|
|
11
|
+
kind: 'live';
|
|
12
|
+
snapshot: LivePanelSnapshot;
|
|
13
|
+
} | {
|
|
10
14
|
kind: 'audit';
|
|
11
15
|
command: 'ps' | 'reap';
|
|
12
16
|
report: AuditFrameReport;
|
package/dist/src/frames.js
CHANGED
|
@@ -5,6 +5,7 @@ exports.framePageCount = framePageCount;
|
|
|
5
5
|
exports.renderFrame = renderFrame;
|
|
6
6
|
/** Recorded frames carry observations, not ANSI, screen text or process arguments. */
|
|
7
7
|
const canvas_1 = require("./canvas");
|
|
8
|
+
const live_panel_1 = require("./live-panel");
|
|
8
9
|
function auditFrame(command, report) {
|
|
9
10
|
const { processes: ignored, rows, ...metadata } = report;
|
|
10
11
|
return { kind: 'audit', command, report: { ...metadata, rows: rows.map(({ processes: omitted, ...row }) => row) } };
|
|
@@ -32,6 +33,15 @@ function valueRows(values, prefix = '', depth = 0) {
|
|
|
32
33
|
});
|
|
33
34
|
}
|
|
34
35
|
function renderFrame(data, options = {}) {
|
|
36
|
+
if (data.kind === 'live')
|
|
37
|
+
return (0, live_panel_1.renderLivePanel)(data.snapshot, options);
|
|
38
|
+
if (data.kind === 'report' && data.report.verdict === 'STOP') {
|
|
39
|
+
const at = options.at ?? new Date().toISOString();
|
|
40
|
+
const snapshot = (0, live_panel_1.panelFromReport)(data.report, Date.parse(at), data.outcome, data.subject), panel = (0, live_panel_1.renderLivePanel)(snapshot, options);
|
|
41
|
+
panel.put(2, 0, `AGENTGUARD STOP · session ${snapshot.session} · ${at.slice(11, 19)} UTC`.padEnd(panel.width), 'red');
|
|
42
|
+
panel.put(panel.height - 4, 0, 'override once: agentguard-burn resume --once --reason "..."', 'slate');
|
|
43
|
+
return panel;
|
|
44
|
+
}
|
|
35
45
|
const canvas = new canvas_1.Canvas(options), w = canvas.width, h = canvas.height;
|
|
36
46
|
const replay = options.recorded || data.kind === 'replay';
|
|
37
47
|
const at = options.at ?? new Date().toISOString();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Canvas, type CanvasColor } from './canvas';
|
|
2
|
+
import type { FrameOptions } from './frames';
|
|
3
|
+
import type { BurnReport, Verdict } from './types';
|
|
4
|
+
export interface PanelDecision {
|
|
5
|
+
id: string;
|
|
6
|
+
at: number;
|
|
7
|
+
tool: string;
|
|
8
|
+
verdict: Verdict;
|
|
9
|
+
reason: string;
|
|
10
|
+
blocked: boolean | null;
|
|
11
|
+
}
|
|
12
|
+
export interface LivePanelSnapshot {
|
|
13
|
+
session: string;
|
|
14
|
+
startedAt: number;
|
|
15
|
+
observedAt: number;
|
|
16
|
+
spawns: number | null;
|
|
17
|
+
tokens: number | null;
|
|
18
|
+
replayShare: number | null;
|
|
19
|
+
windowActiveMinutes: number | null;
|
|
20
|
+
spawnCeiling: number | null;
|
|
21
|
+
decisions: PanelDecision[];
|
|
22
|
+
elapsedMs?: number | null;
|
|
23
|
+
controls?: boolean;
|
|
24
|
+
stopHold?: boolean;
|
|
25
|
+
note: string;
|
|
26
|
+
stop?: PanelDecision;
|
|
27
|
+
}
|
|
28
|
+
export declare function panelDecisions(home: string, sessionId: string, at?: number): PanelDecision[];
|
|
29
|
+
export declare function collectLivePanel(home: string, session?: string, now?: number): LivePanelSnapshot;
|
|
30
|
+
export declare function spawnBarColor(spawns: number | null, ceiling: number | null): CanvasColor;
|
|
31
|
+
export declare function renderLivePanel(snapshot: LivePanelSnapshot, options?: FrameOptions): Canvas;
|
|
32
|
+
/** STOP report adaptation uses only already-computed metadata, never local I/O. */
|
|
33
|
+
export declare function panelFromReport(report: BurnReport, at: number, outcome?: 'blocked' | 'shadow' | 'overridden' | 'allowed', subject?: 'spawn' | 'call'): LivePanelSnapshot;
|
|
34
|
+
/** Display-only hold. Intake continues and the latest observation follows the card. */
|
|
35
|
+
export declare class StopHold {
|
|
36
|
+
private held?;
|
|
37
|
+
private until;
|
|
38
|
+
private seen;
|
|
39
|
+
private latest?;
|
|
40
|
+
update(snapshot: LivePanelSnapshot, now: number): LivePanelSnapshot;
|
|
41
|
+
}
|
|
42
|
+
export declare function runLivePanel(home: string, args: string[]): Promise<number>;
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StopHold = 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 node_path_1 = require("node:path");
|
|
14
|
+
const canvas_1 = require("./canvas");
|
|
15
|
+
const policy_1 = require("./policy");
|
|
16
|
+
const recording_1 = require("./recording");
|
|
17
|
+
const session_1 = require("./state/session");
|
|
18
|
+
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
|
+
}
|
|
51
|
+
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);
|
|
80
|
+
}
|
|
81
|
+
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;
|
|
93
|
+
}
|
|
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
|
+
}
|
|
123
|
+
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
|
+
function spawnBarColor(spawns, ceiling) {
|
|
125
|
+
return spawns === null || ceiling === null ? 'slate' : spawns >= ceiling ? 'red' : spawns >= ceiling * .8 ? 'amber' : 'mint';
|
|
126
|
+
}
|
|
127
|
+
function elapsed(ms) {
|
|
128
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
129
|
+
return `${String(Math.floor(seconds / 3600)).padStart(2, '0')}:${String(Math.floor(seconds / 60) % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
|
130
|
+
}
|
|
131
|
+
function renderLivePanel(snapshot, options = {}) {
|
|
132
|
+
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;
|
|
133
|
+
const at = options.at ?? new Date(snapshot.observedAt).toISOString(), color = spawnBarColor(snapshot.spawns, snapshot.spawnCeiling);
|
|
134
|
+
c.header('AGENTGUARD / BURN', options.recorded ? 'RECORDED RUN · 1x' : 'LIVE');
|
|
135
|
+
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)}`);
|
|
136
|
+
c.put(2, 0, `session ${snapshot.session} · ${at.slice(11, 19)} UTC`, 'slate');
|
|
137
|
+
if (!narrow)
|
|
138
|
+
for (let y = 3; y < h - 3; y++)
|
|
139
|
+
c.put(y, split + 1, '│', 'slate');
|
|
140
|
+
const leftWidth = split, first = h >= 30 ? 4 : 3, second = h >= 30 ? 11 : 8, third = h >= 30 ? 18 : 13;
|
|
141
|
+
c.put(first, 0, 'SPAWNS', 'slate').digits(first + 1, 0, snapshot.spawns === null ? '-' : snapshot.spawns, color);
|
|
142
|
+
c.put(first + 4, 0, `${snapshot.windowActiveMinutes ?? '?'} active min · ceiling ${snapshot.spawnCeiling ?? '?'}`.slice(0, leftWidth), 'slate');
|
|
143
|
+
c.put(second, 0, 'TOKENS IN WINDOW', 'slate');
|
|
144
|
+
const tokens = compact(snapshot.tokens);
|
|
145
|
+
c.digits(second + 1, 0, tokens.digits, 'mint');
|
|
146
|
+
c.put(second + 4, 0, `${tokens.unit} · ${snapshot.windowActiveMinutes ?? '?'} active min`.slice(0, leftWidth), 'slate');
|
|
147
|
+
c.put(third, 0, 'REPLAY SHARE %', 'slate').digits(third + 1, 0, snapshot.replayShare === null ? '-' : Math.round(snapshot.replayShare * 100), 'mint');
|
|
148
|
+
c.put(third + 4, 0, 'session cache-read / tokens'.slice(0, leftWidth), 'slate');
|
|
149
|
+
const barRow = h - 6;
|
|
150
|
+
c.put(barRow, 0, `SPAWN RATE ${snapshot.spawns ?? '?'} / ${snapshot.spawnCeiling ?? '?'}`.slice(0, leftWidth), color);
|
|
151
|
+
c.bar(barRow + 1, 0, leftWidth, snapshot.spawns === null || snapshot.spawnCeiling === null ? 0 : snapshot.spawns / snapshot.spawnCeiling, color);
|
|
152
|
+
if (!narrow)
|
|
153
|
+
c.put(first, right, 'LAST 8 DECISIONS', 'slate');
|
|
154
|
+
(narrow ? [] : snapshot.decisions.slice(-8)).forEach((d, index) => {
|
|
155
|
+
const y = first + 1 + index * 2, chip = d.verdict === 'OK' ? 'clean' : d.verdict, tint = d.verdict === 'STOP' ? 'red' : d.verdict === 'WARN' ? 'amber' : 'mint';
|
|
156
|
+
c.put(y, right, `${new Date(d.at).toISOString().slice(11, 19)} ${d.tool}`.slice(0, Math.max(0, room - 8)), 'white');
|
|
157
|
+
c.put(y, Math.max(right, w - 7), `[${chip}]`, tint);
|
|
158
|
+
c.put(y + 1, right, d.reason.slice(0, room), 'slate');
|
|
159
|
+
});
|
|
160
|
+
if (!narrow && !snapshot.decisions.length)
|
|
161
|
+
c.put(6, right, 'No recorded decisions yet.', 'slate');
|
|
162
|
+
if (snapshot.stop) {
|
|
163
|
+
const y = h >= 30 ? h - 11 : 16;
|
|
164
|
+
for (let i = 0; i < 5; i++)
|
|
165
|
+
c.put(y + i, right, ' '.repeat(room), 'none');
|
|
166
|
+
c.put(y, right, 'STOP · ' + (snapshot.stop.blocked === true ? 'blocked' : snapshot.stop.blocked === false ? 'shadow / allowed' : 'outcome unknown'), 'red');
|
|
167
|
+
c.put(y + 1, right, snapshot.stop.reason.slice(0, room), 'red');
|
|
168
|
+
c.put(y + 2, right, snapshot.stopHold === false ? 'Latest STOP reason.' : 'Reason held for 2 seconds.', 'slate');
|
|
169
|
+
}
|
|
170
|
+
c.put(h - 3, 0, snapshot.note.slice(0, w), 'slate');
|
|
171
|
+
return c;
|
|
172
|
+
}
|
|
173
|
+
/** STOP report adaptation uses only already-computed metadata, never local I/O. */
|
|
174
|
+
function panelFromReport(report, at, outcome, subject) {
|
|
175
|
+
const rate = report.findings.find(f => f.detector === 'spawn_rate');
|
|
176
|
+
const decision = { id: digest(`${report.sessionId}:${at}`), at, tool: subject === 'call' ? 'model_call' : 'spawn', verdict: report.verdict,
|
|
177
|
+
reason: report.findings.map(f => `${f.detector}:${f.verdict} ${f.observed}/${f.threshold}`).join(' + ') || 'Reason not recorded.', blocked: outcome === undefined ? null : outcome === 'blocked' };
|
|
178
|
+
const note = outcome === 'blocked' ? (subject === 'call' ? 'model call blocked' : 'agent spawn blocked')
|
|
179
|
+
: outcome === 'shadow' ? 'shadow: would have blocked, call allowed' : outcome === 'overridden' ? 'override: call allowed'
|
|
180
|
+
: 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,
|
|
182
|
+
spawns: rate?.observed ?? null, tokens: null, replayShare: Number.isFinite(report.cacheReadRatio) ? report.cacheReadRatio : null,
|
|
183
|
+
windowActiveMinutes: null, spawnCeiling: rate?.verdict === 'STOP' ? rate.threshold : null,
|
|
184
|
+
decisions: [decision], stop: decision, note: `${note}. Unknown window metrics remain unknown.` };
|
|
185
|
+
}
|
|
186
|
+
/** Display-only hold. Intake continues and the latest observation follows the card. */
|
|
187
|
+
class StopHold {
|
|
188
|
+
held;
|
|
189
|
+
until = 0;
|
|
190
|
+
seen = new Set();
|
|
191
|
+
latest;
|
|
192
|
+
update(snapshot, now) {
|
|
193
|
+
this.latest = snapshot;
|
|
194
|
+
const stop = snapshot.decisions.filter(d => d.verdict === 'STOP' && !this.seen.has(d.id)).at(-1);
|
|
195
|
+
for (const decision of snapshot.decisions)
|
|
196
|
+
this.seen.add(decision.id);
|
|
197
|
+
if (stop) {
|
|
198
|
+
this.held = { ...snapshot, stop };
|
|
199
|
+
this.until = now + 2000;
|
|
200
|
+
}
|
|
201
|
+
if (this.held && now < this.until)
|
|
202
|
+
return this.held;
|
|
203
|
+
this.held = undefined;
|
|
204
|
+
return this.latest;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
exports.StopHold = StopHold;
|
|
208
|
+
async function runLivePanel(home, args) {
|
|
209
|
+
const flag = (key) => {
|
|
210
|
+
const i = args.indexOf(key);
|
|
211
|
+
if (i < 0)
|
|
212
|
+
return undefined;
|
|
213
|
+
if (!args[i + 1] || args[i + 1].startsWith('--'))
|
|
214
|
+
throw new Error(`Use ${key} with a value.`);
|
|
215
|
+
return args[i + 1];
|
|
216
|
+
};
|
|
217
|
+
const replayFile = flag('--replay'), selected = flag('--session');
|
|
218
|
+
const once = args.includes('--once') || !process.stdout.isTTY;
|
|
219
|
+
const colour = !args.includes('--no-color') && process.env.NO_COLOR === undefined && Boolean(process.stdout.isTTY);
|
|
220
|
+
const viewport = { width: Math.min(104, process.stdout.columns || 104), height: Math.min(35, process.stdout.rows || 35) };
|
|
221
|
+
let paused = false, stopped = false;
|
|
222
|
+
const key = (bytes) => { const text = String(bytes); if (text.includes('q') || text.includes('\x03'))
|
|
223
|
+
stopped = true; if (text.includes('p'))
|
|
224
|
+
paused = !paused; };
|
|
225
|
+
const stop = () => { stopped = true; };
|
|
226
|
+
const display = (snapshot, recorded, at) => {
|
|
227
|
+
(0, recording_1.recordFrame)({ kind: 'live', snapshot }, at, viewport);
|
|
228
|
+
process.stdout.write((process.stdout.isTTY ? '\x1b[H\x1b[2J' : '') + renderLivePanel(snapshot, { ...viewport, at, recorded, colour }).render() + '\n');
|
|
229
|
+
};
|
|
230
|
+
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
231
|
+
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
232
|
+
if (interactive) {
|
|
233
|
+
process.stdin.setRawMode(true);
|
|
234
|
+
process.stdin.resume();
|
|
235
|
+
process.stdin.on('data', key);
|
|
236
|
+
}
|
|
237
|
+
process.on('SIGINT', stop);
|
|
238
|
+
try {
|
|
239
|
+
if (replayFile) {
|
|
240
|
+
if (!(0, node_fs_1.existsSync)(replayFile))
|
|
241
|
+
throw new Error('Local recording is unavailable.');
|
|
242
|
+
const frames = (0, recording_1.readRecording)(replayFile).filter(frame => frame.data.kind === 'live');
|
|
243
|
+
if (!frames.length)
|
|
244
|
+
throw new Error('Recording has no live instrument frames.');
|
|
245
|
+
for (let i = 0; i < frames.length && !stopped; i++) {
|
|
246
|
+
const frame = frames[i];
|
|
247
|
+
if (frame.data.kind !== 'live')
|
|
248
|
+
continue;
|
|
249
|
+
display(frame.data.snapshot, true, frame.at);
|
|
250
|
+
if (!once) {
|
|
251
|
+
const delay = i + 1 < frames.length ? Date.parse(frames[i + 1].at) - Date.parse(frame.at) : 2000;
|
|
252
|
+
let remaining = delay;
|
|
253
|
+
while (remaining > 0 && !stopped) {
|
|
254
|
+
const step = Math.min(100, remaining);
|
|
255
|
+
await wait(step);
|
|
256
|
+
if (!paused)
|
|
257
|
+
remaining -= step;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
const hold = new StopHold();
|
|
264
|
+
do {
|
|
265
|
+
if (!paused) {
|
|
266
|
+
const now = Date.now(), snapshot = hold.update(collectLivePanel(home, selected, now), now);
|
|
267
|
+
display(snapshot, false, new Date(now).toISOString());
|
|
268
|
+
}
|
|
269
|
+
if (!once)
|
|
270
|
+
await wait(500);
|
|
271
|
+
} while (!once && !stopped);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
process.off('SIGINT', stop);
|
|
276
|
+
if (interactive) {
|
|
277
|
+
process.stdin.off('data', key);
|
|
278
|
+
process.stdin.setRawMode(false);
|
|
279
|
+
process.stdin.pause();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
package/dist/src/policy.d.ts
CHANGED
|
@@ -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
|
|
4
|
+
export declare function loadPolicy(home: string, options?: {
|
|
5
|
+
notice?: boolean;
|
|
6
|
+
}): Policy;
|
package/dist/src/policy.js
CHANGED
|
@@ -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
|
}
|
package/dist/src/recording.js
CHANGED
|
@@ -42,12 +42,34 @@ 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
|
+
if (!snapshot || typeof snapshot.session !== 'string' || typeof snapshot.note !== 'string'
|
|
63
|
+
|| !timestamp(snapshot.observedAt) || !timestamp(snapshot.startedAt)
|
|
64
|
+
|| !(snapshot.windowActiveMinutes === null || count(snapshot.windowActiveMinutes) && snapshot.windowActiveMinutes > 0)
|
|
65
|
+
|| !(snapshot.spawnCeiling === null || count(snapshot.spawnCeiling) && snapshot.spawnCeiling > 0)
|
|
66
|
+
|| !(snapshot.elapsedMs === undefined || snapshot.elapsedMs === null || count(snapshot.elapsedMs))
|
|
67
|
+
|| ![snapshot.spawns, snapshot.tokens].every(value => value === null || count(value))
|
|
68
|
+
|| !(snapshot.replayShare === null || count(snapshot.replayShare) && snapshot.replayShare <= 1)
|
|
69
|
+
|| !Array.isArray(snapshot.decisions) || snapshot.decisions.length > 8 || !snapshot.decisions.every(decision)
|
|
70
|
+
|| snapshot.stop !== undefined && !decision(snapshot.stop))
|
|
71
|
+
throw new Error(`Invalid live frame at line ${index + 1}.`);
|
|
72
|
+
}
|
|
51
73
|
if (row.data.kind === 'audit' && (!Array.isArray(row.data.report?.rows) || !row.data.report?.totals))
|
|
52
74
|
throw new Error(`Invalid audit frame at line ${index + 1}.`);
|
|
53
75
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 =
|
|
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 *
|
|
91
|
+
const x = paddingX + col * cellWidth, y = paddingY + row * cellHeight;
|
|
86
92
|
if (cell.char === '█')
|
|
87
|
-
ctx.fillRect(x, y,
|
|
93
|
+
ctx.fillRect(x, y, cellWidth, cellHeight);
|
|
88
94
|
else if (cell.char === '▀')
|
|
89
|
-
ctx.fillRect(x, y,
|
|
95
|
+
ctx.fillRect(x, y, cellWidth, cellHeight / 2);
|
|
90
96
|
else if (cell.char === '▄')
|
|
91
|
-
ctx.fillRect(x, y +
|
|
97
|
+
ctx.fillRect(x, y + cellHeight / 2, cellWidth, cellHeight / 2);
|
|
92
98
|
else if (cell.char === '─')
|
|
93
|
-
ctx.fillRect(x, y +
|
|
99
|
+
ctx.fillRect(x, y + cellHeight / 2, cellWidth, 1);
|
|
94
100
|
else
|
|
95
|
-
ctx.fillText(cell.char, x + 1, y + 23,
|
|
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
|
|
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',
|