@vgai/live 0.5.13 → 0.5.15

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/dist/index.d.ts CHANGED
@@ -55,13 +55,27 @@ export type { DocumentGestureOptions, DocumentKeyOptions, DocumentPasteOptions,
55
55
  export { LiveEditorDocument } from './editor-document.js';
56
56
  export { createGameClient, createLiveGame, type LiveGame } from './game.js';
57
57
  export * from './game-client/index.js';
58
+ export type { GameplayRecordingResult } from './recording.js';
59
+ export { LiveGameplayRecording } from './recording.js';
58
60
  export type { ProjectSessionHint, ResolvedSession, SessionListingTransport, SessionResolutionDeps, } from './session.js';
59
61
  export { findProjectRootFrom, resolveSession } from './session.js';
60
62
  export type { LazySession } from './singleton.js';
61
63
  export { createLazySession } from './singleton.js';
62
64
  export { LiveTools } from './tools.js';
63
- /** A `game.page(step)`-shaped call — see this module's doc comment for the closure-capture limitation. */
64
- export type PageStep = GameClient['page'];
65
+ /**
66
+ * The `page` binding: a `game.page(step)`-shaped CALL (see this module's doc
67
+ * comment for the closure-capture limitation) that also carries the one page
68
+ * verb no step can express — `page.reload()`.
69
+ *
70
+ * `reload` is a property rather than a step because a step that navigates
71
+ * destroys the channel its own result would return on: `page(p =>
72
+ * p.evaluate(() => location.reload()))` was the only spelling available, and
73
+ * it resolves on a send while failing outright outside play mode. See
74
+ * `GameClient.reloadPage`.
75
+ */
76
+ export type PageStep = GameClient['page'] & {
77
+ reload: GameClient['reloadPage'];
78
+ };
65
79
  export interface LiveSession extends LiveBindings {
66
80
  /** The resolved session this is bound to — useful for logging/debugging which port/project a script attached to. */
67
81
  session: ResolvedSession;
@@ -73,7 +87,9 @@ export interface LiveBindings {
73
87
  * several mounted instances (multiplayer authoring). A bare `game` call
74
88
  * targets the sole instance and refuses when several are live. */
75
89
  game: LiveGame;
76
- /** `GameClient.page` bound to `game` — see this module's own doc comment for the wire limitation. */
90
+ /** `GameClient.page` bound to `game`, plus `page.reload()` — see this
91
+ * module's own doc comment for the wire limitation, and {@link PageStep}
92
+ * for why reload is a property rather than a step. */
77
93
  page: PageStep;
78
94
  /** Registered project callables: enumerate, inspect, and invoke. */
79
95
  tools: LiveTools;
package/dist/index.js CHANGED
@@ -55,6 +55,7 @@ export { inferAssetKind, LiveEditor } from './editor.js';
55
55
  export { LiveEditorDocument } from './editor-document.js';
56
56
  export { createGameClient, createLiveGame } from './game.js';
57
57
  export * from './game-client/index.js';
58
+ export { LiveGameplayRecording } from './recording.js';
58
59
  export { findProjectRootFrom, resolveSession } from './session.js';
59
60
  export { createLazySession } from './singleton.js';
60
61
  export { LiveTools } from './tools.js';
@@ -67,7 +68,8 @@ function bindTo(port, projectRoot) {
67
68
  const client = new EditorClient({ url: `http://127.0.0.1:${port}` });
68
69
  const editor = new LiveEditor(client);
69
70
  const game = createLiveGame(port, join(projectRoot, '.vgai', 'last-run'));
70
- const page = (step) => game.page(step);
71
+ const step = (fn) => game.page(fn);
72
+ const page = Object.assign(step, { reload: () => game.reloadPage() });
71
73
  const tools = new LiveTools(client);
72
74
  return { editor, game, page, tools };
73
75
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `editor.recording` — real-time gameplay evidence through the existing live
3
+ * editor session. The browser owns MediaRecorder and the game pixels/audio;
4
+ * the project server streams the standard WebM to disk while this namespace
5
+ * exposes the start/stop controls and optional final copy destination.
6
+ *
7
+ * Recording deliberately is not a CLI workflow of its own. Agents start and
8
+ * stop it through the one general door (`vgai eval`) and use ordinary ffmpeg,
9
+ * ffprobe, and filesystem tools to review or trim the result.
10
+ */
11
+ import type { EditorClient, GameplayRecordingOptions, GameplayRecordingStarted } from '@vgai/editor-sdk';
12
+ export interface GameplayRecordingResult extends GameplayRecordingStarted {
13
+ /** Absolute path to the standard WebM written on stop. */
14
+ path: string;
15
+ durationMs: number;
16
+ droppedFrames: number;
17
+ frameErrors: number;
18
+ }
19
+ export declare class LiveGameplayRecording {
20
+ #private;
21
+ constructor(client: EditorClient);
22
+ /** Start one clean game recording. The page owns it until `stop()`, even
23
+ * when start and stop are issued by separate `vgai eval` processes. */
24
+ start(options?: GameplayRecordingOptions): Promise<GameplayRecordingStarted>;
25
+ /** Finalize the active recording, write it to disk, and return honest media
26
+ * metadata without echoing the large base64 transport payload to stdout. */
27
+ stop(destination?: string): Promise<GameplayRecordingResult>;
28
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * `editor.recording` — real-time gameplay evidence through the existing live
3
+ * editor session. The browser owns MediaRecorder and the game pixels/audio;
4
+ * the project server streams the standard WebM to disk while this namespace
5
+ * exposes the start/stop controls and optional final copy destination.
6
+ *
7
+ * Recording deliberately is not a CLI workflow of its own. Agents start and
8
+ * stop it through the one general door (`vgai eval`) and use ordinary ffmpeg,
9
+ * ffprobe, and filesystem tools to review or trim the result.
10
+ */
11
+ import { copyFile, mkdir } from 'node:fs/promises';
12
+ import { dirname, extname, isAbsolute, resolve } from 'node:path';
13
+ function recordingPath(destination) {
14
+ if (destination.trim() === '')
15
+ throw new Error('recording destination must not be empty');
16
+ const withExtension = extname(destination) === '' ? `${destination}.webm` : destination;
17
+ return isAbsolute(withExtension) ? withExtension : resolve(process.cwd(), withExtension);
18
+ }
19
+ export class LiveGameplayRecording {
20
+ #client;
21
+ constructor(client) {
22
+ this.#client = client;
23
+ }
24
+ /** Start one clean game recording. The page owns it until `stop()`, even
25
+ * when start and stop are issued by separate `vgai eval` processes. */
26
+ async start(options = {}) {
27
+ return this.#client.startGameplayRecording(options);
28
+ }
29
+ /** Finalize the active recording, write it to disk, and return honest media
30
+ * metadata without echoing the large base64 transport payload to stdout. */
31
+ async stop(destination) {
32
+ const capture = await this.#client.stopGameplayRecording();
33
+ const path = destination === undefined ? capture.path : recordingPath(destination);
34
+ if (path !== capture.path) {
35
+ await mkdir(dirname(path), { recursive: true });
36
+ await copyFile(capture.path, path);
37
+ }
38
+ return {
39
+ path,
40
+ startedAt: capture.startedAt,
41
+ mimeType: capture.mimeType,
42
+ width: capture.width,
43
+ height: capture.height,
44
+ fps: capture.fps,
45
+ audio: capture.audio,
46
+ durationMs: capture.durationMs,
47
+ droppedFrames: capture.droppedFrames,
48
+ frameErrors: capture.frameErrors,
49
+ layers: capture.layers,
50
+ };
51
+ }
52
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@vgai/live",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.5.13",
5
+ "version": "0.5.15",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
@@ -31,8 +31,8 @@
31
31
  "dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput"
32
32
  },
33
33
  "dependencies": {
34
- "@vgai/editor-sdk": "0.5.13",
35
- "@vgai/sdk": "0.5.13"
34
+ "@vgai/editor-sdk": "0.5.15",
35
+ "@vgai/sdk": "0.5.15"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@playwright/test": ">=1.58.2 <2"
package/src/editor.ts CHANGED
@@ -13,6 +13,7 @@
13
13
 
14
14
  import type {
15
15
  ActiveDocumentCapture,
16
+ AnimationCaptureAction,
16
17
  AssetKind,
17
18
  AssetPreviewCapture,
18
19
  AssetPreviewOptions,
@@ -21,6 +22,7 @@ import type {
21
22
  EditorState,
22
23
  EditorView,
23
24
  HistoryStep,
25
+ InspectedFieldWrite,
24
26
  InspectedHierarchy,
25
27
  InspectedInspection,
26
28
  PresentedEditorView,
@@ -29,6 +31,7 @@ import type {
29
31
  ViewportCapture,
30
32
  } from '@vgai/editor-sdk';
31
33
  import { LiveEditorDocument } from './editor-document.js';
34
+ import { LiveGameplayRecording } from './recording.js';
32
35
 
33
36
  /** `vgai show <...>`'s four sub-verbs folded into one action name — see `showPanel`. */
34
37
  export type PanelName = 'viewport-edit' | 'viewport-play' | 'inspector' | 'console' | 'build';
@@ -86,9 +89,14 @@ export class LiveEditor {
86
89
  */
87
90
  readonly document: LiveEditorDocument;
88
91
 
92
+ /** Clean real-time game capture: start/stop through `vgai eval`, standard
93
+ * WebM on disk. This is evidence capture, not an animation authoring tool. */
94
+ readonly recording: LiveGameplayRecording;
95
+
89
96
  constructor(client: EditorClient) {
90
97
  this.#client = client;
91
98
  this.document = new LiveEditorDocument(client);
99
+ this.recording = new LiveGameplayRecording(client);
92
100
  }
93
101
 
94
102
  /**
@@ -271,21 +279,11 @@ export class LiveEditor {
271
279
  }
272
280
 
273
281
  /**
274
- * CONSENT to edits being written into the game's own source files, for this
275
- * session the Game document's "Persist to game source" checkbox, reachable
276
- * from a script.
277
- *
278
- * Off by default every session, on purpose: it is a statement about what you
279
- * are doing right now, never a property of the game. With it off, an edit
280
- * lives on the running object and says so; with it on, an edit that can be
281
- * honestly anchored to the line that CREATED the object is written there,
282
- * and one that cannot still says so. Answers with the server's own phrase for
283
- * who records the resulting diff — your version control, or a vendored
284
- * game's own lock — and refuses, with the reason, where the checkbox is
285
- * disabled.
282
+ * Drive the recorder of the active native animation document. The document
283
+ * decides the native destination and refuses when it owns no safe writer.
286
284
  */
287
- async persistToGameSource(on: boolean): Promise<{ enabled: boolean; recorder: string | null }> {
288
- return this.#client.setSourcePersistConsent(on);
285
+ async recordAnimation(action: AnimationCaptureAction): Promise<void> {
286
+ await this.#client.recordAnimation(action);
289
287
  }
290
288
 
291
289
  /**
@@ -353,8 +351,15 @@ export class LiveEditor {
353
351
  /**
354
352
  * Write one editable field from `inspect()` by its stable path, through the
355
353
  * same Inspector IO and persistence boundary the human control uses.
354
+ *
355
+ * The answer is `{ subject, write }`, and `write` is the half worth reading
356
+ * first: a write with no persistence route open still succeeds — it lands on
357
+ * the live object and journals live-only — so `write.persisted` is how you
358
+ * tell a saved edit from one that will not survive the session, without
359
+ * diffing the tree. `write.destination` is the adapter's own words for where
360
+ * it went ("live-only (not saved)" is a destination, never silence).
356
361
  */
357
- async setField(path: string, value: unknown): Promise<InspectedInspection> {
362
+ async setField(path: string, value: unknown): Promise<InspectedFieldWrite> {
358
363
  return this.#client.setInspectionField(path, value);
359
364
  }
360
365
 
@@ -75,4 +75,23 @@ export interface BridgeTransport {
75
75
  * serialized — inline every value the step needs.
76
76
  */
77
77
  runPageScript(src: string, step: (page: unknown) => unknown): Promise<BridgeCallOutcome>;
78
+ /**
79
+ * P20 — reload the document showing the game, resolving only once the page
80
+ * is BACK and taking commands again.
81
+ *
82
+ * Deliberately not expressible as a `runPageScript` step, which is why it
83
+ * is on this interface at all: a step that calls `location.reload()` kills
84
+ * the channel its own acknowledgement would travel on, so the caller's
85
+ * promise resolves on a thrown/timed-out send and says nothing about
86
+ * whether a page came back. Each transport therefore owns its own honest
87
+ * completion signal — Playwright's real `page.reload()` on the page
88
+ * transport, a new document load plus a working command round trip on the
89
+ * relay.
90
+ *
91
+ * This exists because it was MISSING: P20's recovery for stale asset bytes
92
+ * is a full document reload, and the only way to spell it through
93
+ * `vgai eval` was `page(p => p.evaluate(() => location.reload()))` — which
94
+ * both fails outside play mode and resolves on a send, not on a reload.
95
+ */
96
+ reloadPage(): Promise<void>;
78
97
  }
@@ -4,9 +4,10 @@
4
4
  *
5
5
  * A PNG is silent about the conditions it was taken under. Two of those
6
6
  * conditions change what the frame is worth as evidence, and both are already
7
- * measured elsewhere in the stack: the editor page reports `hiddenFrame` when
8
- * the surface was hidden and the runtime had to render one deterministic tick
9
- * on demand (`command-listener.ts`'s `handleBridgeScreenshot`), and it reports
7
+ * measured elsewhere in the stack: the editor page reports
8
+ * `loopRecoveryFrame` when the host loop was starved and the runtime had to
9
+ * render one deterministic tick on demand (`command-listener.ts`'s
10
+ * `handleBridgeScreenshot`), and it reports
10
11
  * a `flatness.warning` sentence when the frame is nine-tenths one flat surface
11
12
  * (`composite-screenshot.ts`'s `measureFlatness`). Until now both stopped at a
12
13
  * `console.warn` inside the relay transport — visible to a human watching a
@@ -23,9 +24,9 @@
23
24
  /** The conditions a capture was taken under, as facts. Both fields are
24
25
  * optional and absent means "not so": an ordinary frame carries no notes. */
25
26
  export interface CaptureNotes {
26
- /** The surface showing the game was hidden, so the frame exists only because
27
- * the runtime was asked for one deterministic tick. */
28
- readonly hiddenFrame?: boolean;
27
+ /** The host loop was starved, so the frame exists only because the runtime
28
+ * was asked for one deterministic tick. */
29
+ readonly loopRecoveryFrame?: boolean;
29
30
  /** The page's own near-blank-frame sentence, verbatim (it owns the wording;
30
31
  * see `composite-screenshot.ts`'s `CaptureFlatness.warning`). */
31
32
  readonly flatnessWarning?: string;
@@ -49,24 +50,24 @@ export interface CaptureRecord {
49
50
  * capture with where the run was standing, say) can. */
50
51
  export type CaptureListener = (capture: CaptureRecord) => void | Promise<void>;
51
52
 
52
- /** The hidden-surface sentence. Spelled once because it is said in two places
53
+ /** The loop-recovery sentence. Spelled once because it is said in two places
53
54
  * (a live console warning and a persisted record) and a second copy is a
54
55
  * second wording. */
55
- const HIDDEN_FRAME_CAVEAT =
56
- 'HIDDEN FRAME — the surface showing the game was hidden, so the runtime rendered one ' +
57
- 'deterministic tick on demand. It is current, not stale; it is not a frame anyone was watching.';
56
+ const LOOP_RECOVERY_FRAME_CAVEAT =
57
+ 'LOOP-RECOVERY FRAME — the host loop was starved, so the runtime rendered one ' +
58
+ 'deterministic tick on demand. It is current, not stale; it was not produced by ordinary presentation.';
58
59
 
59
60
  /**
60
61
  * What a reader must be told about this frame, or `null` when there is nothing
61
62
  * to tell.
62
63
  *
63
- * Both notes can be true at once (a hidden tab's on-demand tick that also came
64
+ * Both notes can be true at once (an on-demand recovery tick that also came
64
65
  * out near-blank), and both are said — a capture that is degraded twice over
65
66
  * must not report only the first reason.
66
67
  */
67
68
  export function describeCaptureCaveat(notes: CaptureNotes): string | null {
68
69
  const parts: string[] = [];
69
- if (notes.hiddenFrame === true) parts.push(HIDDEN_FRAME_CAVEAT);
70
+ if (notes.loopRecoveryFrame === true) parts.push(LOOP_RECOVERY_FRAME_CAVEAT);
70
71
  if (typeof notes.flatnessWarning === 'string' && notes.flatnessWarning !== '') {
71
72
  parts.push(notes.flatnessWarning);
72
73
  }
@@ -157,6 +157,13 @@ export class PageTransport implements BridgeTransport {
157
157
  };
158
158
  }
159
159
  }
160
+
161
+ /** Playwright's own reload already waits for the new document's `load`
162
+ * event, which is exactly the completion signal this method's contract
163
+ * asks for — nothing to reconstruct on this leg. */
164
+ async reloadPage(): Promise<void> {
165
+ await this.page.reload();
166
+ }
160
167
  }
161
168
 
162
169
  export interface GameClientOptions {
@@ -268,7 +275,7 @@ export class GameInput {
268
275
  * instead of the old set → `waitSimTime`'s 150ms-interval snapshot poll
269
276
  * loop → clear (15+ transport round trips over the editor relay for a
270
277
  * multi-second hold). The wait uses ordinary host-loop ticks while visible.
271
- * If the browser has hidden-paused that loop, the bridge drives the held
278
+ * If the browser has loop-starved that loop, the bridge drives the held
272
279
  * action through the same game phases with deterministic ticks; this
273
280
  * collapses transport cost without letting a background tab deadlock it.
274
281
  *
@@ -797,6 +804,20 @@ export class GameClient {
797
804
  return this.unwrap<T>(outcome);
798
805
  }
799
806
 
807
+ /**
808
+ * Reload the document showing the game, resolving only once it is back and
809
+ * answering commands (see `bridge-transport.ts`'s `reloadPage`).
810
+ *
811
+ * Bound as `page.reload()` on `@vgai/live`'s `page` binding. It is the one
812
+ * recovery for bytes that changed on disk after the running document
813
+ * loaded: `vgai restart` remounts every root from fresh SOURCE, but
814
+ * module-scope loaders and the page-lifetime asset caches (Pixi `Assets`,
815
+ * three's loader caches) survive a remount and keep serving the old bytes.
816
+ */
817
+ async reloadPage(): Promise<void> {
818
+ await this.#transport.reloadPage();
819
+ }
820
+
800
821
  private async toSessionFailure(err: WaitForTimeoutError): Promise<SessionFailure> {
801
822
  const providers = await this.providers();
802
823
  const screenshotPath = await this.screenshot('waitfor-timeout').catch(() => null);
@@ -820,7 +841,7 @@ export class GameClient {
820
841
  // as a generic "loop is stalled" no matter WHY the clock was frozen.
821
842
  // `HiddenRecoveryDriver` already tried ONE `bringToFront()` recovery
822
843
  // before this failure fires (see this class's constructor) — if the
823
- // loop is STILL hidden-paused here, recovery couldn't reach the tab
844
+ // loop is STILL loop-starved here, recovery couldn't reach the tab
824
845
  // (headless run, no-op bringToFront), and the failure block must say
825
846
  // so explicitly rather than leaving an agent to guess "stalled" for a
826
847
  // tab that is simply backgrounded.
@@ -42,7 +42,7 @@ export interface FailureBlockContext {
42
42
  /**
43
43
  * Issue #175 — the REAL loop liveness at the moment of failure (the
44
44
  * timed-out/failed snapshot's `time.loopLiveness`). When this is
45
- * `'hidden-paused'`, `ratioLine` below replaces its generic "if ~0x, the
45
+ * `'loop-starved'`, `ratioLine` below replaces its generic "if ~0x, the
46
46
  * loop is stalled" hint with an explicit "the tab is hidden" diagnosis —
47
47
  * `HiddenRecoveryDriver` already tried ONE `bringToFront()` recovery
48
48
  * before this failure was ever thrown (see `client.ts`/`hidden-recovery.ts`);
@@ -52,7 +52,7 @@ export interface FailureBlockContext {
52
52
  * itself crashed. Optional/`undefined` when the bridge build predates
53
53
  * `loopLiveness` — falls back to the old generic hint, same as before.
54
54
  */
55
- loopLiveness?: 'running' | 'hidden-paused' | 'stopped' | null | undefined;
55
+ loopLiveness?: 'running' | 'loop-starved' | 'stopped' | null | undefined;
56
56
  }
57
57
 
58
58
  export interface SessionFailureData {
@@ -75,7 +75,7 @@ export interface SessionFailureData {
75
75
  * Machine-readable twin of `elapsed.line`'s hidden-tab hint, for a JSON
76
76
  * reporter that doesn't want to parse prose to tell a frozen tab from a
77
77
  * stalled game. `undefined`/`null` exactly mirrors the context field. */
78
- loopLiveness?: 'running' | 'hidden-paused' | 'stopped' | null | undefined;
78
+ loopLiveness?: 'running' | 'loop-starved' | 'stopped' | null | undefined;
79
79
  }
80
80
 
81
81
  export interface AssembledFailureBlock {
@@ -103,19 +103,28 @@ export function annotateStateTiers(
103
103
  function ratioLine(
104
104
  simElapsedSeconds: number,
105
105
  wallElapsedMs: number,
106
- loopLiveness?: 'running' | 'hidden-paused' | 'stopped' | null,
106
+ loopLiveness?: 'running' | 'loop-starved' | 'stopped' | null,
107
107
  ): { ratio: number; line: string } {
108
108
  const wallElapsedS = wallElapsedMs / 1000;
109
109
  const ratio = wallElapsedS > 0 ? simElapsedSeconds / wallElapsedS : 0;
110
- // Issue #175: a hidden tab hard-stops the engine loop (T2.1's deliberate
111
- // idle throttle) — the same ~0x reading a genuinely stalled/crashed game
112
- // produces. Without this branch an agent sees "loop is stalled" and has no
113
- // way to tell a frozen tab from a dead game; say so explicitly instead.
110
+ // Issue #175: an rAF chain can make no progress while UI play state still
111
+ // says running — the same ~0x reading a genuinely crashed game produces.
112
+ // The loop's own measurement distinguishes that condition without guessing
113
+ // what is happening to the browser window.
114
+ //
115
+ // P21: state the READING, not a conclusion about the human's screen. This
116
+ // hint used to read "the EDITOR/BROWSER TAB IS HIDDEN", and that sentence
117
+ // reached the owner as an assertion about a tab they were looking at.
118
+ // `loop-starved` is what the engine's own loop reports when no recent host
119
+ // rAF callback was observed; repeating that is honest, narrating the window
120
+ // is not.
114
121
  const hint =
115
- loopLiveness === 'hidden-paused'
116
- ? 'the EDITOR/BROWSER TAB IS HIDDENthe engine loop deliberately ' +
117
- 'stops ticking while backgrounded (not a crash); bring the tab to ' +
118
- 'the foreground and retry'
122
+ loopLiveness === 'loop-starved'
123
+ ? "the loop reported liveness 'loop-starved'no recent host rAF " +
124
+ 'callback was observed (not, by itself, a crash or a visibility ' +
125
+ 'verdict). Sim time still advances through game.waitSimTime/waitFor, ' +
126
+ 'which drive deterministic ticks over the relay; `vgai status` prints ' +
127
+ 'the visibility readings with their ages if you need to know why'
119
128
  : 'if ~0x, the loop is stalled; if <1x under CI, the budget may just ' +
120
129
  'be too small for SwiftShader';
121
130
  const line =
@@ -75,6 +75,18 @@ const PAGE_SCRIPT_TIMEOUT_MS = 60_000;
75
75
  * count. */
76
76
  const HIDDEN_SAMPLE_TTL_MS = 500;
77
77
 
78
+ /** How long `reloadPage` waits for a new document AND a working command
79
+ * round trip before it reports what it did and did not observe. A cold Vite
80
+ * re-optimize on a first reload is the slow case this budget is sized for. */
81
+ const RELOAD_READY_TIMEOUT_MS = 60_000;
82
+ const RELOAD_POLL_MS = 250;
83
+
84
+ /** Seconds since `since`, one decimal — every `reloadPage` failure names how
85
+ * long it actually waited rather than quoting the budget. */
86
+ function elapsedSeconds(since: number): string {
87
+ return ((Date.now() - since) / 1000).toFixed(1);
88
+ }
89
+
78
90
  export class RelayTransport implements BridgeTransport {
79
91
  private readonly baseUrl: string;
80
92
  private readonly timeoutMs: number;
@@ -174,10 +186,14 @@ export class RelayTransport implements BridgeTransport {
174
186
  const hidden = this.forceHiddenDrive || (await this.isHiddenCached());
175
187
  if (hidden && !this.hiddenDriveAnnounced) {
176
188
  this.hiddenDriveAnnounced = true;
189
+ // P21: what was measured, and what this run is doing about it — not a
190
+ // claim about where the tab is. The reading is a `presence` snapshot
191
+ // (`isHidden` below), which ages between the tab's own reports.
177
192
  process.stdout.write(
178
- 'vgai: the editor tab is HIDDEN the engine hidden-pauses its loop while the tab ' +
179
- 'is backgrounded, so this run drives deterministic runTicks through the session relay. ' +
180
- 'Bring the editor tab to the foreground for real-time play.\n',
193
+ 'vgai: the editor page last REPORTED document.visibilityState "hidden" the engine ' +
194
+ 'stops its loop while the page reports itself hidden, so this run drives ' +
195
+ 'deterministic runTicks through the session relay instead of wall clock. Sim time ' +
196
+ 'advances either way; `vgai status` prints that reading with its age.\n',
181
197
  );
182
198
  }
183
199
  return hidden;
@@ -249,13 +265,13 @@ export class RelayTransport implements BridgeTransport {
249
265
 
250
266
  async screenshot(path: string): Promise<CaptureNotes> {
251
267
  // Same preflight as every other leg — a hidden tab's canvas is a provably
252
- // stale frame, and `refreshHiddenFrame` is what asks the relay for a
268
+ // stale frame, and `refreshStarvedFrame` is what asks the relay for a
253
269
  // deterministic one-tick refresh instead of a `BRIDGE_SCREENSHOT_STALE`
254
270
  // refusal (`command-listener.ts`'s `handleBridgeScreenshot`).
255
271
  const body = await this.postCommand(
256
272
  {
257
273
  type: 'bridge-screenshot',
258
- refreshHiddenFrame: await this.preflightHidden(),
274
+ refreshStarvedFrame: await this.preflightHidden(),
259
275
  // Address THIS transport's instance so a per-seat `game.instance(id)`
260
276
  // screenshot captures that seat's game stack, not always the primary.
261
277
  ...(this.instance !== undefined ? { instance: this.instance } : {}),
@@ -277,7 +293,7 @@ export class RelayTransport implements BridgeTransport {
277
293
  // human watching this terminal is looking.
278
294
  const warning = (body['flatness'] as { warning?: string } | undefined)?.warning;
279
295
  const notes: CaptureNotes = {
280
- ...(body['hiddenFrame'] === true ? { hiddenFrame: true } : {}),
296
+ ...(body['loopRecoveryFrame'] === true ? { loopRecoveryFrame: true } : {}),
281
297
  ...(typeof warning === 'string' ? { flatnessWarning: warning } : {}),
282
298
  };
283
299
  const caveat = describeCaptureCaveat(notes);
@@ -310,4 +326,112 @@ export class RelayTransport implements BridgeTransport {
310
326
  };
311
327
  }
312
328
  }
329
+
330
+ /**
331
+ * P20 — order the tab to reload, then wait for EVIDENCE that it came back.
332
+ *
333
+ * Two witnesses, in order, because either one alone lies in a way that
334
+ * matters:
335
+ *
336
+ * 1. The tab table's EPOCH count rising. A tab's epoch id is minted per
337
+ * page-load and carried on its heartbeat, so the server increments
338
+ * this when it observes a new document — not when the tab claims one.
339
+ * Without it, a `page-reload` that the page never acted on (a
340
+ * `beforeunload` blocker, a listener that had already died) would look
341
+ * identical to a completed reload.
342
+ *
343
+ * `lastIndexRequestAt` was the first choice here and is measurably the
344
+ * WRONG one: a reload served from the browser's own cache leaves it
345
+ * untouched (observed live 2026-08-15 — `epochCount` went 2 → 3 while
346
+ * the stamp did not move), so it reported "no new document" for a
347
+ * reload that had plainly happened.
348
+ * 2. A `list-instances` command completing. That is the whole relay path
349
+ * — server → SSE channel → the NEW document's command listener — so it
350
+ * is the difference between "a document loaded" and "the session can
351
+ * be driven again". It is the one command with no play gate and no
352
+ * side effects (`command-listener.ts`), and it is held-and-retried by
353
+ * the relay while a tab is mid-boot, so a refusal here means the page
354
+ * genuinely is not answering yet.
355
+ *
356
+ * Neither witness is inferred from the send. A reload that never completes
357
+ * REJECTS with what was and was not observed, rather than resolving into a
358
+ * caller's belief that the page is fresh.
359
+ */
360
+ async reloadPage(): Promise<void> {
361
+ const before = await this.readTabEpochs();
362
+ const orderedAt = Date.now();
363
+ const ordered = await this.postCommand({ type: 'page-reload' }, this.timeoutMs);
364
+ if (!ordered.ok) {
365
+ throw new Error(
366
+ `vgai: the editor session refused the reload — ${ordered.error ?? 'no reason given'}`,
367
+ );
368
+ }
369
+ // The stale visibility sample belongs to a document that no longer
370
+ // exists; the next leg must measure the new one.
371
+ this.hiddenCache = null;
372
+ const deadline = orderedAt + RELOAD_READY_TIMEOUT_MS;
373
+ const loaded = await this.pollUntil(deadline, async () => {
374
+ const now = await this.readTabEpochs();
375
+ // Any present tab whose epoch count has RISEN, or a tab that was not
376
+ // in the table before (the reload arrived as a fresh row). An unknown
377
+ // table — an unreachable server, an older one — never witnesses a
378
+ // reload by default; `undefined` compares false here on purpose.
379
+ return [...now].some(([tabId, epochs]) => epochs > (before.get(tabId) ?? 0));
380
+ });
381
+ if (!loaded) {
382
+ throw new Error(
383
+ `vgai: reload ordered ${elapsedSeconds(orderedAt)}s ago and no tab of the session at ` +
384
+ `${this.baseUrl} has reported a new page load. The tab may be gone; ` +
385
+ '`vgai edit` reopens it.',
386
+ );
387
+ }
388
+ const answering = await this.pollUntil(deadline, async () => {
389
+ const ready = await this.postCommand({ type: 'list-instances' }, this.timeoutMs).catch(
390
+ () => ({ ok: false }) as RelayCommandBody,
391
+ );
392
+ return ready.ok;
393
+ });
394
+ if (!answering) {
395
+ throw new Error(
396
+ `vgai: reload ordered ${elapsedSeconds(orderedAt)}s ago — a new document loaded, but ` +
397
+ 'its command listener has not answered yet. Check `vgai status` for page errors ' +
398
+ 'from that load.',
399
+ );
400
+ }
401
+ }
402
+
403
+ /** Poll `check` every {@link RELOAD_POLL_MS} until it holds or `deadline`
404
+ * passes; `false` means the deadline won, and the CALLER names what that
405
+ * means — a shared "timed out" sentence would be exactly the kind of
406
+ * message that says nothing about which witness was missing. */
407
+ private async pollUntil(deadline: number, check: () => Promise<boolean>): Promise<boolean> {
408
+ for (;;) {
409
+ await new Promise((resolve) => setTimeout(resolve, RELOAD_POLL_MS));
410
+ if (await check()) return true;
411
+ if (Date.now() >= deadline) return false;
412
+ }
413
+ }
414
+
415
+ /** Page-loads per present tab, from `/__editor/state`'s tab table — the
416
+ * server's own count of the documents it has seen a tab run. Empty when
417
+ * the server cannot answer, which witnesses nothing. */
418
+ private async readTabEpochs(): Promise<Map<string, number>> {
419
+ try {
420
+ const res = await fetch(`${this.baseUrl}/__editor/state`, {
421
+ signal: AbortSignal.timeout(this.timeoutMs),
422
+ });
423
+ const state = (await res.json()) as {
424
+ tabs?: { tabId8?: string; epochCount?: number }[];
425
+ };
426
+ const epochs = new Map<string, number>();
427
+ for (const tab of state.tabs ?? []) {
428
+ if (typeof tab.tabId8 === 'string' && typeof tab.epochCount === 'number') {
429
+ epochs.set(tab.tabId8, tab.epochCount);
430
+ }
431
+ }
432
+ return epochs;
433
+ } catch {
434
+ return new Map();
435
+ }
436
+ }
313
437
  }
@@ -86,15 +86,15 @@ export interface DebugSnapshot {
86
86
  * Issue #175 — the REAL engine `GameLoop.liveness` behind this session
87
87
  * (mirrors `@vgai/engine`'s `GameLoopLiveness`; this package never
88
88
  * imports the engine — see the module doc above — so the union is
89
- * declared here from the same contract). `'hidden-paused'` means the
90
- * T2.1 idle throttle has stopped the loop because the tab is
91
- * backgrounded: `simSeconds`/`tick` above are frozen, but this is NOT a
92
- * crashed/wedged game it resumes the instant the tab is foregrounded.
89
+ * declared here from the same contract). `'loop-starved'` means no recent
90
+ * host rAF callback was observed. It may reflect the current visibility
91
+ * gate or an armed callback the browser has starved; it is NOT itself a
92
+ * visibility verdict or proof that the game crashed.
93
93
  * `undefined` against an older bridge build that predates this field;
94
94
  * `null` when the live bridge has no loop wired at all (should not
95
95
  * happen against a real `Game`, but never fabricated either way).
96
96
  */
97
- loopLiveness?: 'running' | 'hidden-paused' | 'stopped' | null;
97
+ loopLiveness?: 'running' | 'loop-starved' | 'stopped' | null;
98
98
  };
99
99
  state: Record<string, unknown>;
100
100
  events: TickStampedEvent[];
@@ -121,7 +121,7 @@ export interface VgaiBridgeHandle {
121
121
  runTicks(n: number, opts?: RunTicksOptions): void;
122
122
  /** Collapses `input.setVirtualAction(action, true)` → wait `simSeconds` of
123
123
  * sim time (host-loop ticks while visible, deterministic same-phase ticks
124
- * while hidden-paused) → `input.clearVirtualActions()` into one call — see
124
+ * while loop-starved) → `input.clearVirtualActions()` into one call — see
125
125
  * `runtime/debug-bridge.ts`'s
126
126
  * `holdFor` doc comment for the full contract (gated-immediately /
127
127
  * play-stopped-mid-wait shapes). */