@vgai/sdk 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,53 @@
1
+ /**
2
+ * What a run is CALLED — one owner of the bound, and of the fallback a run
3
+ * gets when nobody named it.
4
+ *
5
+ * WHY THIS EXISTS. `vgai play --name <text>` and `npm run playtest --
6
+ * '{"name":"…"}'` both exist so a run is findable by what it was testing, and
7
+ * the measured answer to an optional field is that it stays empty: every play
8
+ * event in the foundry probe's 45-minute session journal reads `"name":null`.
9
+ * A label nobody supplies indexes nothing, so the run that DOES know what it
10
+ * was testing supplies it — a route-targeted playtest is named after its
11
+ * routes, and a full-suite run is named `playtest`. Nothing is taught and no
12
+ * habit is required; the default carries the information.
13
+ *
14
+ * WHAT STAYS UNNAMED, deliberately: interactive `vgai play` with no `--name`.
15
+ * A person pressing play is not testing a named thing, and inventing a label
16
+ * for it would put noise in exactly the directory this makes greppable.
17
+ *
18
+ * Pure — no filesystem, no clock. `editor-server.ts`'s `playRunSlug` is the
19
+ * OTHER half (slugging a name into a filename segment) and reads the same
20
+ * bound from here, so the two can never disagree about how long a run name
21
+ * may be.
22
+ */
23
+
24
+ /** Bound on a run's name — long enough to stay recognisable in a directory
25
+ * listing, short enough that the timestamp beside it is still readable. */
26
+ export const MAX_RUN_NAME = 40;
27
+
28
+ /** The name a full-suite run gets: it targets no route in particular, and
29
+ * "which run was that" is still a question worth being able to answer. */
30
+ export const FULL_SUITE_RUN_NAME = 'playtest';
31
+
32
+ /**
33
+ * The name for one playtest run.
34
+ *
35
+ * An explicit name always wins — it is the caller saying what this run was,
36
+ * and no derivation is better informed than that. Otherwise the routes name
37
+ * it, joined with `+` (a separator that survives being read back as a list,
38
+ * unlike the `-` that route names themselves use), bounded at
39
+ * {@link MAX_RUN_NAME}; and a run that named no route is
40
+ * {@link FULL_SUITE_RUN_NAME}.
41
+ *
42
+ * `null` is impossible by construction — every playtest run gets a name — but
43
+ * an explicit blank/whitespace name is treated as no name at all, the same
44
+ * case a missing one is.
45
+ */
46
+ export function derivePlaytestRunName(
47
+ explicit: string | null | undefined,
48
+ routes: readonly string[],
49
+ ): string {
50
+ if (typeof explicit === 'string' && explicit.trim() !== '') return explicit.trim();
51
+ if (routes.length === 0) return FULL_SUITE_RUN_NAME;
52
+ return routes.join('+').slice(0, MAX_RUN_NAME).replace(/\+$/, '');
53
+ }
@@ -0,0 +1,495 @@
1
+ /**
2
+ * The editor session's console stream, as a STRUCTURED FILE an agent can read
3
+ * at any moment: `<project>/logs/editor-<sessionstamp>.jsonl`.
4
+ *
5
+ * WHY. Agents do not watch terminals; they read files. Every discipline signal
6
+ * the editor server already produces — a save that failed validation, a
7
+ * build-discipline tripwire crossing (`build-discipline.ts`), the session's own
8
+ * lifecycle — was delivered ONLY as a terminal print, which in detach mode goes
9
+ * to a raw capture nobody parses and in foreground scrolls past whoever was not
10
+ * looking. Same measured shape as the tripwires' own origin story: the
11
+ * mechanism was right and the delivery assumption was false.
12
+ *
13
+ * SO THE JOURNAL IS THE RECORD AND THE PRINTS ARE ITS RENDERERS. Every caller
14
+ * emits the event here first and renders second, from one emit point, so no
15
+ * line can reach a terminal without a durable twin on disk. Nothing new is
16
+ * measured for the journal's sake: each event is a fact one of those surfaces
17
+ * was about to print anyway.
18
+ *
19
+ * IT LIVES BESIDE `logs/play-*.jsonl`, in the project, deliberately: that is
20
+ * the established idiom for "durable evidence this session produced", the
21
+ * scaffold already gitignores `logs/`, and `build-discipline.ts` already skips
22
+ * `logs/` when dating source (so a journal line can never read as a source
23
+ * change and make a tripwire cry wolf on its own output).
24
+ *
25
+ * SCHEMA-LIGHT ON PURPOSE. One discriminated union, one writer, no zod: nothing
26
+ * reads a journal back through a validator — an agent reads it, and `JSON.parse`
27
+ * per line is the whole contract. Append-only, one object per line, no levels,
28
+ * no transports, no config.
29
+ *
30
+ * BOUNDS. Same rule the play logs follow (`/__editor/log-session`): prune to the
31
+ * newest `MAX_JOURNAL_FILES` when a new one is opened. Within a session the file
32
+ * grows unbounded, which is what an append-only record means.
33
+ */
34
+
35
+ import { appendFileSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from 'node:fs';
36
+ import { join } from 'node:path';
37
+ import type { TripwireTier } from './build-discipline';
38
+
39
+ /** `editor-` + an ISO instant with `:`/`.` flattened + `.jsonl` — the same
40
+ * lexicographic-order-is-chronological-order shape `play-*.jsonl` uses. */
41
+ const PREFIX = 'editor-';
42
+ const SUFFIX = '.jsonl';
43
+
44
+ /** Journals kept per project. Matches the play logs' own `MAX_LOG_FILES`. */
45
+ const MAX_JOURNAL_FILES = 20;
46
+
47
+ /**
48
+ * One journal line's payload. `at` (ISO) is added by the writer, so a caller
49
+ * only ever describes WHAT happened.
50
+ *
51
+ * The tripwire arm reuses `build-discipline.ts`'s vocabulary verbatim — its
52
+ * `TripwireTier`, its tripwire names, and the same inputs its banners are
53
+ * computed from — so the JSONL and the printed banner can never disagree about
54
+ * what fired or how loud it was.
55
+ */
56
+ export type SessionJournalEvent =
57
+ /** This server began serving this project (the process's own start). */
58
+ | { readonly kind: 'session-started'; readonly project: string; readonly pid: number }
59
+ /** `POST /__editor/open-project` switched the live session's project. */
60
+ | {
61
+ readonly kind: 'project-opened';
62
+ readonly project: string;
63
+ readonly previousProject: string | null;
64
+ }
65
+ /** The session is going down (best-effort: a SIGKILL logs nothing). */
66
+ | { readonly kind: 'session-shutdown' }
67
+ /** A save-validation verdict — the same facts `runFileValidation` reports. */
68
+ | {
69
+ readonly kind: 'validation';
70
+ /** Project-relative, forward-slash. */
71
+ readonly path: string;
72
+ /** The validatable kind the server classified this file as. */
73
+ readonly fileKind: string;
74
+ readonly ok: boolean;
75
+ readonly errors?: readonly string[];
76
+ readonly warnings?: readonly string[];
77
+ }
78
+ /** A build-discipline tripwire CROSSED a step (never a repeat at one tier). */
79
+ | {
80
+ readonly kind: 'tripwire';
81
+ readonly tripwire: 'commit-cadence';
82
+ readonly tier: TripwireTier;
83
+ readonly ageMs: number;
84
+ readonly fileCount: number;
85
+ }
86
+ | {
87
+ readonly kind: 'tripwire';
88
+ readonly tripwire: 'unplayed-session';
89
+ readonly tier: TripwireTier;
90
+ readonly servingForMs: number;
91
+ }
92
+ /**
93
+ * A play-mode log session opened or closed.
94
+ *
95
+ * `name` is the run's OPTIONAL slug (`vgai play --name <text>`), `null` for
96
+ * an unnamed run — the same slug that goes in the log filename, so grepping
97
+ * the journal for a run and listing `logs/` for it are the same question.
98
+ */
99
+ | {
100
+ readonly kind: 'play';
101
+ readonly action: 'start' | 'stop';
102
+ readonly name: string | null;
103
+ readonly logFile: string | null;
104
+ }
105
+ /**
106
+ * THE TRANSPORT ARM. Everything below is one line per transport or
107
+ * presence fact, and it exists because the journal was BLIND to all of
108
+ * them: on 2026-08-09 a session refused five `vgai play` commands against
109
+ * a healthy connected tab and the whole file it left behind was
110
+ * `session-started` plus validation lines. A relay that can refuse has to
111
+ * be able to say who it refused, to which tab, and on what evidence.
112
+ *
113
+ * Ids are truncated to 8 characters (`clientId8`, `tabId8`, `requestId8`)
114
+ * — enough to correlate lines within one session, short enough that a
115
+ * human can scan a column of them. No payload bodies, ever: a state
116
+ * snapshot or a command argument list would turn an append-only record
117
+ * into a memory dump.
118
+ */
119
+ /** A control connection opened (either transport). */
120
+ | {
121
+ readonly kind: 'client-connected';
122
+ readonly clientId8: string;
123
+ readonly transport: 'ws' | 'sse';
124
+ readonly participant: string | null;
125
+ }
126
+ /** A control connection closed, and how many pending commands it settled. */
127
+ | {
128
+ readonly kind: 'client-disconnected';
129
+ readonly clientId8: string;
130
+ readonly transport: 'ws' | 'sse';
131
+ /** WebSocket close code, or null for SSE (which has none). */
132
+ readonly code: number | null;
133
+ readonly reason: string | null;
134
+ readonly commandsSettled: number;
135
+ }
136
+ /** The server granted a socket the duplex control capability. */
137
+ | { readonly kind: 'duplex-granted'; readonly clientId8: string }
138
+ /** A command left the relay for one tab's command channel. */
139
+ | {
140
+ readonly kind: 'command-relayed';
141
+ readonly command: string;
142
+ readonly requestId8: string;
143
+ readonly tabId8: string | null;
144
+ readonly clientId8: string | null;
145
+ }
146
+ /** The tab said its command listener PICKED THE COMMAND UP. */
147
+ | { readonly kind: 'command-receipt'; readonly requestId8: string }
148
+ /** The command settled — by the tab's own answer or by the relay refusing. */
149
+ | {
150
+ readonly kind: 'command-result';
151
+ readonly requestId8: string;
152
+ readonly ok: boolean;
153
+ /** Present only when `ok` is false; the refusal/failure text. */
154
+ readonly error?: string;
155
+ }
156
+ /**
157
+ * The relay HELD a command instead of refusing it: the target tab is
158
+ * present by heartbeat but its command channel is not carrying right now
159
+ * (mid-reload, or a channel that has not answered an echo).
160
+ */
161
+ | {
162
+ readonly kind: 'command-held';
163
+ readonly requestId8: string;
164
+ readonly tabId8: string;
165
+ readonly reason: 'no-channel' | 'unacknowledged';
166
+ }
167
+ /** The main-thread echo probe over a duplex socket. */
168
+ | {
169
+ readonly kind: 'echo-probe';
170
+ readonly clientId8: string;
171
+ readonly answered: boolean;
172
+ readonly waitedMs: number;
173
+ }
174
+ /** The blessed tab changed (or was chosen for the first time). */
175
+ | {
176
+ readonly kind: 'tab-blessed';
177
+ readonly tabId8: string;
178
+ readonly previousTabId8: string | null;
179
+ readonly reason: 'sticky' | 'oldest' | 'claimed';
180
+ }
181
+ /** An extra tab was told to yield. */
182
+ | { readonly kind: 'tab-yielded'; readonly tabId8: string }
183
+ /**
184
+ * PRESENCE, IN TAB VOCABULARY. These five say what the tab table saw, and
185
+ * they deliberately do NOT use socket words: a socket closing is not a tab
186
+ * leaving, and that conflation is what produced "editor tab lost —
187
+ * reopening" against a tab that had never gone anywhere.
188
+ */
189
+ /** A tabId beat for the first time. */
190
+ | {
191
+ readonly kind: 'tab-appeared';
192
+ readonly tabId8: string;
193
+ readonly visibility: 'visible' | 'hidden';
194
+ }
195
+ /** Beats stopped arriving for longer than the notice threshold. */
196
+ | { readonly kind: 'tab-heartbeat-gap'; readonly tabId8: string; readonly sinceMs: number }
197
+ /** A gap closed — the same tab resumed beating. `gapMs` is its full length. */
198
+ | { readonly kind: 'tab-gap-closed'; readonly tabId8: string; readonly gapMs: number }
199
+ /** Same tabId, new epoch: the page reloaded. The tab never left. */
200
+ | { readonly kind: 'tab-reloaded'; readonly tabId8: string; readonly epochCount: number }
201
+ /** Absent past its grace: this tab is gone. */
202
+ | { readonly kind: 'tab-departed'; readonly tabId8: string; readonly absentMs: number }
203
+ /**
204
+ * WHAT THE TAB WAS HOLDING WHEN IT DIED WITHOUT A GOODBYE.
205
+ *
206
+ * Emitted beside `client-disconnected` on an ABNORMAL close (1006 — the
207
+ * socket ended with no close frame, which is what a killed renderer process
208
+ * leaves behind; an ordinary tab close sends one). Measured 2026-08-10: a
209
+ * game tab's renderer was killed repeatedly at Chrome's undocumented
210
+ * per-process ceiling and the whole session record said `1006` and nothing
211
+ * else — the recovery worked perfectly and the CAUSE was unrecorded.
212
+ *
213
+ * `census` is the tab's last resource profile and `censusAgeMs` how stale it
214
+ * was; both null for a tab that never reported one (an older page, a
215
+ * tunnelled tab). Numbers only — this stays a journal line, not a dump.
216
+ */
217
+ | {
218
+ readonly kind: 'tab-death-profile';
219
+ readonly tabId8: string;
220
+ /** The abnormal close code that triggered the line. */
221
+ readonly code: number;
222
+ readonly censusAgeMs: number | null;
223
+ readonly census: {
224
+ readonly heapUsedMB: number | null;
225
+ readonly heapLimitMB: number | null;
226
+ readonly canvases: number;
227
+ readonly canvasMB: number;
228
+ readonly textures?: number;
229
+ readonly geometries?: number;
230
+ readonly programs?: number;
231
+ } | null;
232
+ }
233
+ /** Two epochs beating under one tabId — "Duplicate Tab" copied sessionStorage. */
234
+ | { readonly kind: 'tab-duplicated'; readonly tabId8: string }
235
+ /**
236
+ * The tab is present (its worker is beating) but its PAGE has never opened
237
+ * a command channel this page-load. A heartbeat proves the tab exists, not
238
+ * that the document works — a main thread that died after the inline
239
+ * bootstrap beats forever and can run nothing. Such a tab is passed over
240
+ * for blessing and named in the refusal.
241
+ */
242
+ | {
243
+ readonly kind: 'tab-unresponsive';
244
+ readonly tabId8: string;
245
+ readonly noChannelForMs: number;
246
+ };
247
+
248
+ /** A parsed journal line: the event plus when it was appended. */
249
+ export type SessionJournalLine = SessionJournalEvent & { readonly at: string };
250
+
251
+ /** An open journal. `append` never throws — a journal that breaks a save would
252
+ * be worse than a journal that misses a line. */
253
+ export interface SessionJournal {
254
+ /** Absolute path of the file being appended to. */
255
+ readonly path: string;
256
+ append(event: SessionJournalEvent): void;
257
+ }
258
+
259
+ /** `editor-2026-08-09T12-34-56-789Z.jsonl` for a given instant. */
260
+ export function sessionJournalFilename(startedAt: Date): string {
261
+ return `${PREFIX}${startedAt.toISOString().replace(/[:.]/g, '-')}${SUFFIX}`;
262
+ }
263
+
264
+ /**
265
+ * Open (create) this session's journal under `<projectRoot>/logs/`.
266
+ *
267
+ * `null` when the directory cannot be created or the first write fails —
268
+ * an unwritable project must degrade to "no journal", never to a crashed boot.
269
+ */
270
+ export function openSessionJournal(
271
+ projectRoot: string,
272
+ startedAt: Date = new Date(),
273
+ ): SessionJournal | null {
274
+ const logsDir = join(projectRoot, 'logs');
275
+ try {
276
+ mkdirSync(logsDir, { recursive: true });
277
+ } catch {
278
+ return null;
279
+ }
280
+ pruneJournals(logsDir);
281
+ const path = join(logsDir, sessionJournalFilename(startedAt));
282
+ const journal: SessionJournal = {
283
+ path,
284
+ append(event) {
285
+ const line: SessionJournalLine = { at: new Date().toISOString(), ...event };
286
+ try {
287
+ appendFileSync(path, `${JSON.stringify(line)}\n`, 'utf-8');
288
+ } catch {
289
+ /* a journal that breaks the caller is worse than a missing line */
290
+ }
291
+ },
292
+ };
293
+ try {
294
+ appendFileSync(path, '', 'utf-8');
295
+ } catch {
296
+ return null;
297
+ }
298
+ return journal;
299
+ }
300
+
301
+ /** Drop the oldest journals so opening one more stays at `MAX_JOURNAL_FILES`. */
302
+ function pruneJournals(logsDir: string): void {
303
+ try {
304
+ const files = readdirSync(logsDir)
305
+ .filter((f) => f.startsWith(PREFIX) && f.endsWith(SUFFIX))
306
+ .sort();
307
+ for (const f of files.slice(0, Math.max(0, files.length - MAX_JOURNAL_FILES + 1))) {
308
+ try {
309
+ unlinkSync(join(logsDir, f));
310
+ } catch {
311
+ /* already gone */
312
+ }
313
+ }
314
+ } catch {
315
+ /* no logs dir yet */
316
+ }
317
+ }
318
+
319
+ /**
320
+ * The newest journal in a project, or `null`.
321
+ *
322
+ * File-native on purpose (the same shape as the CLI's newest-play-log reader):
323
+ * the journal path is a fact about the PROJECT DIRECTORY, so `vgai status` and
324
+ * the boot pointer can name it without a server round-trip — and can still name
325
+ * it after the session that wrote it is gone.
326
+ */
327
+ export function newestSessionJournal(projectRoot: string): string | null {
328
+ try {
329
+ const files = readdirSync(join(projectRoot, 'logs'))
330
+ .filter((f) => f.startsWith(PREFIX) && f.endsWith(SUFFIX))
331
+ .sort();
332
+ const newest = files.at(-1);
333
+ return newest ? join(projectRoot, 'logs', newest) : null;
334
+ } catch {
335
+ return null;
336
+ }
337
+ }
338
+
339
+ /**
340
+ * The newest journal's last `limit` lines of the requested kinds, oldest first.
341
+ *
342
+ * WHY A READER EXISTS AT ALL. The journal was written as a file an agent
343
+ * "can read at any time", and the measured answer to that invitation was: it
344
+ * doesn't. The foundry probe's journal held the notice-tier tripwire crossing
345
+ * that predicted its own end-of-run batch commit, and nothing that agent ran
346
+ * ever rendered a line of it. So the record grows a RENDERER on the command
347
+ * agents already poll (`vgai status`) — same principle as the tripwires' own
348
+ * origin: the mechanism was right and the delivery assumption was false.
349
+ *
350
+ * Every failure is the same case — no project, no journal, unreadable file, a
351
+ * line that will not parse — and it is the empty list, never a throw. A status
352
+ * command that crashed on a malformed log line would be a worse outcome than
353
+ * one that says nothing about it.
354
+ *
355
+ * The whole file is read, which is what bounds this: a journal is one editor
356
+ * session's surface-worthy transitions (the happy-path silence rule keeps
357
+ * clean saves out), the pruner caps how many exist, and the alternative — a
358
+ * reverse streaming reader over a file measured in kilobytes — is machinery
359
+ * with nothing to buy.
360
+ */
361
+ export function readRecentJournalEvents(
362
+ projectRoot: string,
363
+ kinds: readonly SessionJournalEvent['kind'][],
364
+ limit: number,
365
+ ): SessionJournalLine[] {
366
+ const path = newestSessionJournal(projectRoot);
367
+ if (path === null || limit <= 0) return [];
368
+ let raw: string;
369
+ try {
370
+ raw = readFileSync(path, 'utf-8');
371
+ } catch {
372
+ return [];
373
+ }
374
+ const wanted = new Set<string>(kinds);
375
+ const matched: SessionJournalLine[] = [];
376
+ for (const line of raw.split('\n')) {
377
+ if (line === '') continue;
378
+ let parsed: SessionJournalLine;
379
+ try {
380
+ parsed = JSON.parse(line) as SessionJournalLine;
381
+ } catch {
382
+ continue; // a torn final line is not a reason to report nothing
383
+ }
384
+ if (parsed && typeof parsed === 'object' && wanted.has(parsed.kind)) matched.push(parsed);
385
+ }
386
+ return matched.slice(-limit);
387
+ }
388
+
389
+ /**
390
+ * One journal line as a scannable terminal line: `HH:MM:SS`, the kind, and
391
+ * the few facts that line is about.
392
+ *
393
+ * ONE OWNER of this rendering, for `sessionJournalPointerLine`'s reason — the
394
+ * events already have exactly one wording as data, and a second prose form per
395
+ * caller is how a reader ends up believing there are two records.
396
+ */
397
+ export function formatJournalLine(line: SessionJournalLine): string {
398
+ const at = line.at.slice(11, 19);
399
+ switch (line.kind) {
400
+ case 'tripwire':
401
+ return line.tripwire === 'commit-cadence'
402
+ ? `journal: ${at} tripwire commit-cadence ${line.tier} (${line.fileCount} files, ${Math.round(line.ageMs / 60_000)}m)`
403
+ : `journal: ${at} tripwire unplayed-session ${line.tier} (${Math.round(line.servingForMs / 60_000)}m)`;
404
+ case 'validation':
405
+ return `journal: ${at} validation ${line.ok ? 'ok' : 'FAILED'} ${line.path}`;
406
+ case 'play':
407
+ return `journal: ${at} play ${line.action}${line.name === null ? '' : ` [${line.name}]`}`;
408
+ case 'session-started':
409
+ return `journal: ${at} session-started pid ${line.pid}`;
410
+ case 'project-opened':
411
+ return `journal: ${at} project-opened ${line.project}`;
412
+ case 'session-shutdown':
413
+ return `journal: ${at} session-shutdown`;
414
+ // The TRANSPORT arm. One short line each, and every one of them names the
415
+ // TAB or the request it is about — a column of ids is how these lines get
416
+ // correlated, and a paragraph per line is how a reader stops reading.
417
+ case 'client-connected':
418
+ return `journal: ${at} client-connected ${line.clientId8} (${line.transport})`;
419
+ case 'client-disconnected':
420
+ return `journal: ${at} client-disconnected ${line.clientId8} code ${line.code ?? '-'} settled ${line.commandsSettled}`;
421
+ case 'duplex-granted':
422
+ return `journal: ${at} duplex-granted ${line.clientId8}`;
423
+ case 'command-relayed':
424
+ return `journal: ${at} command-relayed ${line.command} ${line.requestId8} -> tab ${line.tabId8 ?? 'none'}`;
425
+ case 'command-receipt':
426
+ return `journal: ${at} command-receipt ${line.requestId8}`;
427
+ case 'command-result':
428
+ return `journal: ${at} command-result ${line.requestId8} ${line.ok ? 'ok' : `FAILED ${line.error ?? ''}`}`;
429
+ case 'command-held':
430
+ return `journal: ${at} command-held ${line.requestId8} tab ${line.tabId8} (${line.reason})`;
431
+ case 'echo-probe':
432
+ return `journal: ${at} echo-probe ${line.clientId8} ${line.answered ? 'answered' : 'UNANSWERED'} in ${line.waitedMs}ms`;
433
+ case 'tab-blessed':
434
+ return `journal: ${at} tab-blessed ${line.tabId8} (${line.reason})`;
435
+ case 'tab-yielded':
436
+ return `journal: ${at} tab-yielded ${line.tabId8}`;
437
+ case 'tab-appeared':
438
+ return `journal: ${at} tab-appeared ${line.tabId8} (${line.visibility})`;
439
+ case 'tab-heartbeat-gap':
440
+ return `journal: ${at} tab-heartbeat-gap ${line.tabId8} ${Math.round(line.sinceMs / 100) / 10}s`;
441
+ case 'tab-gap-closed':
442
+ return `journal: ${at} tab-gap-closed ${line.tabId8} after ${Math.round(line.gapMs / 100) / 10}s`;
443
+ case 'tab-reloaded':
444
+ return `journal: ${at} tab-reloaded ${line.tabId8} (page load ${line.epochCount})`;
445
+ case 'tab-departed':
446
+ return `journal: ${at} tab-departed ${line.tabId8} absent ${Math.round(line.absentMs / 100) / 10}s`;
447
+ case 'tab-duplicated':
448
+ return `journal: ${at} tab-duplicated ${line.tabId8}`;
449
+ case 'tab-unresponsive':
450
+ return `journal: ${at} tab-unresponsive ${line.tabId8} no channel for ${Math.round(line.noChannelForMs / 1000)}s`;
451
+ case 'tab-death-profile':
452
+ return `journal: ${at} tab-death-profile ${line.tabId8} code ${line.code} — ${tabDeathProfileBody(line)}`;
453
+ }
454
+ }
455
+
456
+ /** The census half of a `tab-death-profile` line, split out so the switch above
457
+ * stays one expression per case. */
458
+ function tabDeathProfileBody(line: SessionJournalEvent & { kind: 'tab-death-profile' }): string {
459
+ const census = formatTabCensus(line.census);
460
+ if (line.censusAgeMs === null) return census;
461
+ return `${census} (sampled ${Math.round(line.censusAgeMs / 100) / 10}s before)`;
462
+ }
463
+
464
+ /** The resource profile a `tab-death-profile` line and a `vgai status` tab row
465
+ * both quote. ONE wording, for `sessionJournalPointerLine`'s reason: two
466
+ * phrasings of the same numbers is how a reader ends up believing there are
467
+ * two measurements. Renderer counts appear only when the page could read them
468
+ * (no mounted render-debug adapter = the words are absent, never a zero). */
469
+ export function formatTabCensus(
470
+ census: (SessionJournalEvent & { kind: 'tab-death-profile' })['census'],
471
+ ): string {
472
+ if (census === null) return 'no resource census';
473
+ const parts = [
474
+ census.heapUsedMB === null
475
+ ? 'heap n/a'
476
+ : `heap ${census.heapUsedMB}MB${census.heapLimitMB === null ? '' : `/${census.heapLimitMB}MB`}`,
477
+ `canvas ${census.canvasMB}MB in ${census.canvases}`,
478
+ ];
479
+ if (census.textures !== undefined) parts.push(`tex ${census.textures}`);
480
+ if (census.geometries !== undefined) parts.push(`geo ${census.geometries}`);
481
+ if (census.programs !== undefined) parts.push(`prog ${census.programs}`);
482
+ return parts.join(', ');
483
+ }
484
+
485
+ /**
486
+ * The ONE wording for "here is the journal" — printed by the editor server's
487
+ * boot block, by `vgai edit`'s ready/detach lines, and by `vgai status`.
488
+ *
489
+ * One owner of the sentence, for `build-discipline.ts`'s reason: three
490
+ * processes point at this file, and a second phrasing of the same pointer is
491
+ * how a reader ends up believing there are two things.
492
+ */
493
+ export function sessionJournalPointerLine(journalPath: string): string {
494
+ return `Session journal: ${journalPath} — structured JSONL, read it any time`;
495
+ }