@vgai/live 0.5.13 → 0.5.14

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/editor.d.ts CHANGED
@@ -10,8 +10,9 @@
10
10
  * hand-rolls a `fetch` to `/__editor/command` itself. (WO-8 removed the one
11
11
  * exception, `applyDiff`, which was FILE mode rather than a live wire command.)
12
12
  */
13
- import type { ActiveDocumentCapture, AssetKind, AssetPreviewCapture, AssetPreviewOptions, AssetPreviewSource, EditorClient, EditorState, EditorView, HistoryStep, InspectedHierarchy, InspectedInspection, PresentedEditorView, ShadingMode, ViewPreset, ViewportCapture } from '@vgai/editor-sdk';
13
+ import type { ActiveDocumentCapture, AnimationCaptureAction, AssetKind, AssetPreviewCapture, AssetPreviewOptions, AssetPreviewSource, EditorClient, EditorState, EditorView, HistoryStep, InspectedFieldWrite, InspectedHierarchy, InspectedInspection, PresentedEditorView, ShadingMode, ViewPreset, ViewportCapture } from '@vgai/editor-sdk';
14
14
  import { LiveEditorDocument } from './editor-document.js';
15
+ import { LiveGameplayRecording } from './recording.js';
15
16
  /** `vgai show <...>`'s four sub-verbs folded into one action name — see `showPanel`. */
16
17
  export type PanelName = 'viewport-edit' | 'viewport-play' | 'inspector' | 'console' | 'build';
17
18
  /**
@@ -33,6 +34,9 @@ export declare class LiveEditor {
33
34
  * {@link LiveEditor.captureActiveDocument}, not a fifth verb here.
34
35
  */
35
36
  readonly document: LiveEditorDocument;
37
+ /** Clean real-time game capture: start/stop through `vgai eval`, standard
38
+ * WebM on disk. This is evidence capture, not an animation authoring tool. */
39
+ readonly recording: LiveGameplayRecording;
36
40
  constructor(client: EditorClient);
37
41
  /**
38
42
  * The active authoring adapter's persistence destination — where a save would
@@ -109,23 +113,10 @@ export declare class LiveEditor {
109
113
  stats(on: boolean): Promise<void>;
110
114
  shading(mode: ShadingMode): Promise<void>;
111
115
  /**
112
- * CONSENT to edits being written into the game's own source files, for this
113
- * session the Game document's "Persist to game source" checkbox, reachable
114
- * from a script.
115
- *
116
- * Off by default every session, on purpose: it is a statement about what you
117
- * are doing right now, never a property of the game. With it off, an edit
118
- * lives on the running object and says so; with it on, an edit that can be
119
- * honestly anchored to the line that CREATED the object is written there,
120
- * and one that cannot still says so. Answers with the server's own phrase for
121
- * who records the resulting diff — your version control, or a vendored
122
- * game's own lock — and refuses, with the reason, where the checkbox is
123
- * disabled.
116
+ * Drive the recorder of the active native animation document. The document
117
+ * decides the native destination and refuses when it owns no safe writer.
124
118
  */
125
- persistToGameSource(on: boolean): Promise<{
126
- enabled: boolean;
127
- recorder: string | null;
128
- }>;
119
+ recordAnimation(action: AnimationCaptureAction): Promise<void>;
129
120
  /**
130
121
  * READ the inspector, as data — the serialized inspection subject
131
122
  * (`editor.inspect()`; design: `docs/ARCHITECTURE-CORE.md` §Editor chrome,
@@ -185,8 +176,15 @@ export declare class LiveEditor {
185
176
  /**
186
177
  * Write one editable field from `inspect()` by its stable path, through the
187
178
  * same Inspector IO and persistence boundary the human control uses.
179
+ *
180
+ * The answer is `{ subject, write }`, and `write` is the half worth reading
181
+ * first: a write with no persistence route open still succeeds — it lands on
182
+ * the live object and journals live-only — so `write.persisted` is how you
183
+ * tell a saved edit from one that will not survive the session, without
184
+ * diffing the tree. `write.destination` is the adapter's own words for where
185
+ * it went ("live-only (not saved)" is a destination, never silence).
188
186
  */
189
- setField(path: string, value: unknown): Promise<InspectedInspection>;
187
+ setField(path: string, value: unknown): Promise<InspectedFieldWrite>;
190
188
  /** Undo / redo one project transaction, through the session's own history
191
189
  * queue — the same one the keyboard shortcut drives. */
192
190
  undo(): Promise<HistoryStep>;
package/dist/editor.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * exception, `applyDiff`, which was FILE mode rather than a live wire command.)
12
12
  */
13
13
  import { LiveEditorDocument } from './editor-document.js';
14
+ import { LiveGameplayRecording } from './recording.js';
14
15
  const EXTENSION_KIND = {
15
16
  '.glb': 'model',
16
17
  '.gltf': 'model',
@@ -61,9 +62,13 @@ export class LiveEditor {
61
62
  * {@link LiveEditor.captureActiveDocument}, not a fifth verb here.
62
63
  */
63
64
  document;
65
+ /** Clean real-time game capture: start/stop through `vgai eval`, standard
66
+ * WebM on disk. This is evidence capture, not an animation authoring tool. */
67
+ recording;
64
68
  constructor(client) {
65
69
  this.#client = client;
66
70
  this.document = new LiveEditorDocument(client);
71
+ this.recording = new LiveGameplayRecording(client);
67
72
  }
68
73
  /**
69
74
  * The active authoring adapter's persistence destination — where a save would
@@ -216,21 +221,11 @@ export class LiveEditor {
216
221
  await this.#client.setShadingMode(mode);
217
222
  }
218
223
  /**
219
- * CONSENT to edits being written into the game's own source files, for this
220
- * session the Game document's "Persist to game source" checkbox, reachable
221
- * from a script.
222
- *
223
- * Off by default every session, on purpose: it is a statement about what you
224
- * are doing right now, never a property of the game. With it off, an edit
225
- * lives on the running object and says so; with it on, an edit that can be
226
- * honestly anchored to the line that CREATED the object is written there,
227
- * and one that cannot still says so. Answers with the server's own phrase for
228
- * who records the resulting diff — your version control, or a vendored
229
- * game's own lock — and refuses, with the reason, where the checkbox is
230
- * disabled.
224
+ * Drive the recorder of the active native animation document. The document
225
+ * decides the native destination and refuses when it owns no safe writer.
231
226
  */
232
- async persistToGameSource(on) {
233
- return this.#client.setSourcePersistConsent(on);
227
+ async recordAnimation(action) {
228
+ await this.#client.recordAnimation(action);
234
229
  }
235
230
  /**
236
231
  * READ the inspector, as data — the serialized inspection subject
@@ -295,6 +290,13 @@ export class LiveEditor {
295
290
  /**
296
291
  * Write one editable field from `inspect()` by its stable path, through the
297
292
  * same Inspector IO and persistence boundary the human control uses.
293
+ *
294
+ * The answer is `{ subject, write }`, and `write` is the half worth reading
295
+ * first: a write with no persistence route open still succeeds — it lands on
296
+ * the live object and journals live-only — so `write.persisted` is how you
297
+ * tell a saved edit from one that will not survive the session, without
298
+ * diffing the tree. `write.destination` is the adapter's own words for where
299
+ * it went ("live-only (not saved)" is a destination, never silence).
298
300
  */
299
301
  async setField(path, value) {
300
302
  return this.#client.setInspectionField(path, value);
@@ -76,4 +76,23 @@ export interface BridgeTransport {
76
76
  * serialized — inline every value the step needs.
77
77
  */
78
78
  runPageScript(src: string, step: (page: unknown) => unknown): Promise<BridgeCallOutcome>;
79
+ /**
80
+ * P20 — reload the document showing the game, resolving only once the page
81
+ * is BACK and taking commands again.
82
+ *
83
+ * Deliberately not expressible as a `runPageScript` step, which is why it
84
+ * is on this interface at all: a step that calls `location.reload()` kills
85
+ * the channel its own acknowledgement would travel on, so the caller's
86
+ * promise resolves on a thrown/timed-out send and says nothing about
87
+ * whether a page came back. Each transport therefore owns its own honest
88
+ * completion signal — Playwright's real `page.reload()` on the page
89
+ * transport, a new document load plus a working command round trip on the
90
+ * relay.
91
+ *
92
+ * This exists because it was MISSING: P20's recovery for stale asset bytes
93
+ * is a full document reload, and the only way to spell it through
94
+ * `vgai eval` was `page(p => p.evaluate(() => location.reload()))` — which
95
+ * both fails outside play mode and resolves on a send, not on a reload.
96
+ */
97
+ reloadPage(): Promise<void>;
79
98
  }
@@ -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
@@ -22,9 +23,9 @@
22
23
  /** The conditions a capture was taken under, as facts. Both fields are
23
24
  * optional and absent means "not so": an ordinary frame carries no notes. */
24
25
  export interface CaptureNotes {
25
- /** The surface showing the game was hidden, so the frame exists only because
26
- * the runtime was asked for one deterministic tick. */
27
- readonly hiddenFrame?: boolean;
26
+ /** The host loop was starved, so the frame exists only because the runtime
27
+ * was asked for one deterministic tick. */
28
+ readonly loopRecoveryFrame?: boolean;
28
29
  /** The page's own near-blank-frame sentence, verbatim (it owns the wording;
29
30
  * see `composite-screenshot.ts`'s `CaptureFlatness.warning`). */
30
31
  readonly flatnessWarning?: string;
@@ -49,7 +50,7 @@ export type CaptureListener = (capture: CaptureRecord) => void | Promise<void>;
49
50
  * What a reader must be told about this frame, or `null` when there is nothing
50
51
  * to tell.
51
52
  *
52
- * Both notes can be true at once (a hidden tab's on-demand tick that also came
53
+ * Both notes can be true at once (an on-demand recovery tick that also came
53
54
  * out near-blank), and both are said — a capture that is degraded twice over
54
55
  * must not report only the first reason.
55
56
  */
@@ -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
@@ -19,23 +20,23 @@
19
20
  * listener — so the words a human reads in the terminal and the words a run
20
21
  * record carries beside the frame cannot drift apart.
21
22
  */
22
- /** The hidden-surface sentence. Spelled once because it is said in two places
23
+ /** The loop-recovery sentence. Spelled once because it is said in two places
23
24
  * (a live console warning and a persisted record) and a second copy is a
24
25
  * second wording. */
25
- const HIDDEN_FRAME_CAVEAT = 'HIDDEN FRAME — the surface showing the game was hidden, so the runtime rendered one ' +
26
- 'deterministic tick on demand. It is current, not stale; it is not a frame anyone was watching.';
26
+ const LOOP_RECOVERY_FRAME_CAVEAT = 'LOOP-RECOVERY FRAME — the host loop was starved, so the runtime rendered one ' +
27
+ 'deterministic tick on demand. It is current, not stale; it was not produced by ordinary presentation.';
27
28
  /**
28
29
  * What a reader must be told about this frame, or `null` when there is nothing
29
30
  * to tell.
30
31
  *
31
- * Both notes can be true at once (a hidden tab's on-demand tick that also came
32
+ * Both notes can be true at once (an on-demand recovery tick that also came
32
33
  * out near-blank), and both are said — a capture that is degraded twice over
33
34
  * must not report only the first reason.
34
35
  */
35
36
  export function describeCaptureCaveat(notes) {
36
37
  const parts = [];
37
- if (notes.hiddenFrame === true)
38
- parts.push(HIDDEN_FRAME_CAVEAT);
38
+ if (notes.loopRecoveryFrame === true)
39
+ parts.push(LOOP_RECOVERY_FRAME_CAVEAT);
39
40
  if (typeof notes.flatnessWarning === 'string' && notes.flatnessWarning !== '') {
40
41
  parts.push(notes.flatnessWarning);
41
42
  }
@@ -39,6 +39,10 @@ export declare class PageTransport implements BridgeTransport {
39
39
  * full honesty-boundary contract; `src` is unused on this leg, kept only
40
40
  * to satisfy the shared interface). */
41
41
  runPageScript(_src: string, step: (page: unknown) => unknown): Promise<BridgeCallOutcome>;
42
+ /** Playwright's own reload already waits for the new document's `load`
43
+ * event, which is exactly the completion signal this method's contract
44
+ * asks for — nothing to reconstruct on this leg. */
45
+ reloadPage(): Promise<void>;
42
46
  }
43
47
  export interface GameClientOptions {
44
48
  /** The bridge transport — `new PageTransport(page)` for a standalone page
@@ -111,7 +115,7 @@ export declare class GameInput {
111
115
  * instead of the old set → `waitSimTime`'s 150ms-interval snapshot poll
112
116
  * loop → clear (15+ transport round trips over the editor relay for a
113
117
  * multi-second hold). The wait uses ordinary host-loop ticks while visible.
114
- * If the browser has hidden-paused that loop, the bridge drives the held
118
+ * If the browser has loop-starved that loop, the bridge drives the held
115
119
  * action through the same game phases with deterministic ticks; this
116
120
  * collapses transport cost without letting a background tab deadlock it.
117
121
  *
@@ -294,6 +298,17 @@ export declare class GameClient {
294
298
  * `runPageScript` doc comment for the full contract this method wraps.
295
299
  */
296
300
  page<T = unknown>(step: (page: Page) => T | Promise<T>): Promise<T>;
301
+ /**
302
+ * Reload the document showing the game, resolving only once it is back and
303
+ * answering commands (see `bridge-transport.ts`'s `reloadPage`).
304
+ *
305
+ * Bound as `page.reload()` on `@vgai/live`'s `page` binding. It is the one
306
+ * recovery for bytes that changed on disk after the running document
307
+ * loaded: `vgai restart` remounts every root from fresh SOURCE, but
308
+ * module-scope loaders and the page-lifetime asset caches (Pixi `Assets`,
309
+ * three's loader caches) survive a remount and keep serving the old bytes.
310
+ */
311
+ reloadPage(): Promise<void>;
297
312
  private toSessionFailure;
298
313
  /** run-3 friction #2 — called at the top of every bridge dispatch, before
299
314
  * the await, so a burst of short calls (e.g. many `hold()`s) keeps
@@ -128,6 +128,12 @@ export class PageTransport {
128
128
  };
129
129
  }
130
130
  }
131
+ /** Playwright's own reload already waits for the new document's `load`
132
+ * event, which is exactly the completion signal this method's contract
133
+ * asks for — nothing to reconstruct on this leg. */
134
+ async reloadPage() {
135
+ await this.page.reload();
136
+ }
131
137
  }
132
138
  /**
133
139
  * A relay-transport call that overran its async-invoke timeout budget
@@ -181,7 +187,7 @@ export class GameInput {
181
187
  * instead of the old set → `waitSimTime`'s 150ms-interval snapshot poll
182
188
  * loop → clear (15+ transport round trips over the editor relay for a
183
189
  * multi-second hold). The wait uses ordinary host-loop ticks while visible.
184
- * If the browser has hidden-paused that loop, the bridge drives the held
190
+ * If the browser has loop-starved that loop, the bridge drives the held
185
191
  * action through the same game phases with deterministic ticks; this
186
192
  * collapses transport cost without letting a background tab deadlock it.
187
193
  *
@@ -672,6 +678,19 @@ export class GameClient {
672
678
  const outcome = await this.#transport.runPageScript(step.toString(), erased);
673
679
  return this.unwrap(outcome);
674
680
  }
681
+ /**
682
+ * Reload the document showing the game, resolving only once it is back and
683
+ * answering commands (see `bridge-transport.ts`'s `reloadPage`).
684
+ *
685
+ * Bound as `page.reload()` on `@vgai/live`'s `page` binding. It is the one
686
+ * recovery for bytes that changed on disk after the running document
687
+ * loaded: `vgai restart` remounts every root from fresh SOURCE, but
688
+ * module-scope loaders and the page-lifetime asset caches (Pixi `Assets`,
689
+ * three's loader caches) survive a remount and keep serving the old bytes.
690
+ */
691
+ async reloadPage() {
692
+ await this.#transport.reloadPage();
693
+ }
675
694
  async toSessionFailure(err) {
676
695
  const providers = await this.providers();
677
696
  const screenshotPath = await this.screenshot('waitfor-timeout').catch(() => null);
@@ -694,7 +713,7 @@ export class GameClient {
694
713
  // as a generic "loop is stalled" no matter WHY the clock was frozen.
695
714
  // `HiddenRecoveryDriver` already tried ONE `bringToFront()` recovery
696
715
  // before this failure fires (see this class's constructor) — if the
697
- // loop is STILL hidden-paused here, recovery couldn't reach the tab
716
+ // loop is STILL loop-starved here, recovery couldn't reach the tab
698
717
  // (headless run, no-op bringToFront), and the failure block must say
699
718
  // so explicitly rather than leaving an agent to guess "stalled" for a
700
719
  // tab that is simply backgrounded.
@@ -39,7 +39,7 @@ export interface FailureBlockContext {
39
39
  /**
40
40
  * Issue #175 — the REAL loop liveness at the moment of failure (the
41
41
  * timed-out/failed snapshot's `time.loopLiveness`). When this is
42
- * `'hidden-paused'`, `ratioLine` below replaces its generic "if ~0x, the
42
+ * `'loop-starved'`, `ratioLine` below replaces its generic "if ~0x, the
43
43
  * loop is stalled" hint with an explicit "the tab is hidden" diagnosis —
44
44
  * `HiddenRecoveryDriver` already tried ONE `bringToFront()` recovery
45
45
  * before this failure was ever thrown (see `client.ts`/`hidden-recovery.ts`);
@@ -49,7 +49,7 @@ export interface FailureBlockContext {
49
49
  * itself crashed. Optional/`undefined` when the bridge build predates
50
50
  * `loopLiveness` — falls back to the old generic hint, same as before.
51
51
  */
52
- loopLiveness?: 'running' | 'hidden-paused' | 'stopped' | null | undefined;
52
+ loopLiveness?: 'running' | 'loop-starved' | 'stopped' | null | undefined;
53
53
  }
54
54
  export interface SessionFailureData {
55
55
  headline: string;
@@ -79,7 +79,7 @@ export interface SessionFailureData {
79
79
  * Machine-readable twin of `elapsed.line`'s hidden-tab hint, for a JSON
80
80
  * reporter that doesn't want to parse prose to tell a frozen tab from a
81
81
  * stalled game. `undefined`/`null` exactly mirrors the context field. */
82
- loopLiveness?: 'running' | 'hidden-paused' | 'stopped' | null | undefined;
82
+ loopLiveness?: 'running' | 'loop-starved' | 'stopped' | null | undefined;
83
83
  }
84
84
  export interface AssembledFailureBlock {
85
85
  message: string;
@@ -26,14 +26,23 @@ export function annotateStateTiers(state, providers) {
26
26
  function ratioLine(simElapsedSeconds, wallElapsedMs, loopLiveness) {
27
27
  const wallElapsedS = wallElapsedMs / 1000;
28
28
  const ratio = wallElapsedS > 0 ? simElapsedSeconds / wallElapsedS : 0;
29
- // Issue #175: a hidden tab hard-stops the engine loop (T2.1's deliberate
30
- // idle throttle) — the same ~0x reading a genuinely stalled/crashed game
31
- // produces. Without this branch an agent sees "loop is stalled" and has no
32
- // way to tell a frozen tab from a dead game; say so explicitly instead.
33
- const hint = loopLiveness === 'hidden-paused'
34
- ? 'the EDITOR/BROWSER TAB IS HIDDEN the engine loop deliberately ' +
35
- 'stops ticking while backgrounded (not a crash); bring the tab to ' +
36
- 'the foreground and retry'
29
+ // Issue #175: an rAF chain can make no progress while UI play state still
30
+ // says running — the same ~0x reading a genuinely crashed game produces.
31
+ // The loop's own measurement distinguishes that condition without guessing
32
+ // what is happening to the browser window.
33
+ //
34
+ // P21: state the READING, not a conclusion about the human's screen. This
35
+ // hint used to read "the EDITOR/BROWSER TAB IS HIDDEN", and that sentence
36
+ // reached the owner as an assertion about a tab they were looking at.
37
+ // `loop-starved` is what the engine's own loop reports when no recent host
38
+ // rAF callback was observed; repeating that is honest, narrating the window
39
+ // is not.
40
+ const hint = loopLiveness === 'loop-starved'
41
+ ? "the loop reported liveness 'loop-starved' — no recent host rAF " +
42
+ 'callback was observed (not, by itself, a crash or a visibility ' +
43
+ 'verdict). Sim time still advances through game.waitSimTime/waitFor, ' +
44
+ 'which drive deterministic ticks over the relay; `vgai status` prints ' +
45
+ 'the visibility readings with their ages if you need to know why'
37
46
  : 'if ~0x, the loop is stalled; if <1x under CI, the budget may just ' +
38
47
  'be too small for SwiftShader';
39
48
  const line = `sim-time elapsed: ${simElapsedSeconds.toFixed(2)}s over ${wallElapsedS.toFixed(1)}s wall ` +
@@ -98,4 +98,44 @@ export declare class RelayTransport implements BridgeTransport {
98
98
  * `PageTransport` (which DOES call it directly) also implements.
99
99
  */
100
100
  runPageScript(src: string, _step: (page: unknown) => unknown): Promise<BridgeCallOutcome>;
101
+ /**
102
+ * P20 — order the tab to reload, then wait for EVIDENCE that it came back.
103
+ *
104
+ * Two witnesses, in order, because either one alone lies in a way that
105
+ * matters:
106
+ *
107
+ * 1. The tab table's EPOCH count rising. A tab's epoch id is minted per
108
+ * page-load and carried on its heartbeat, so the server increments
109
+ * this when it observes a new document — not when the tab claims one.
110
+ * Without it, a `page-reload` that the page never acted on (a
111
+ * `beforeunload` blocker, a listener that had already died) would look
112
+ * identical to a completed reload.
113
+ *
114
+ * `lastIndexRequestAt` was the first choice here and is measurably the
115
+ * WRONG one: a reload served from the browser's own cache leaves it
116
+ * untouched (observed live 2026-08-15 — `epochCount` went 2 → 3 while
117
+ * the stamp did not move), so it reported "no new document" for a
118
+ * reload that had plainly happened.
119
+ * 2. A `list-instances` command completing. That is the whole relay path
120
+ * — server → SSE channel → the NEW document's command listener — so it
121
+ * is the difference between "a document loaded" and "the session can
122
+ * be driven again". It is the one command with no play gate and no
123
+ * side effects (`command-listener.ts`), and it is held-and-retried by
124
+ * the relay while a tab is mid-boot, so a refusal here means the page
125
+ * genuinely is not answering yet.
126
+ *
127
+ * Neither witness is inferred from the send. A reload that never completes
128
+ * REJECTS with what was and was not observed, rather than resolving into a
129
+ * caller's belief that the page is fresh.
130
+ */
131
+ reloadPage(): Promise<void>;
132
+ /** Poll `check` every {@link RELOAD_POLL_MS} until it holds or `deadline`
133
+ * passes; `false` means the deadline won, and the CALLER names what that
134
+ * means — a shared "timed out" sentence would be exactly the kind of
135
+ * message that says nothing about which witness was missing. */
136
+ private pollUntil;
137
+ /** Page-loads per present tab, from `/__editor/state`'s tab table — the
138
+ * server's own count of the documents it has seen a tab run. Empty when
139
+ * the server cannot answer, which witnesses nothing. */
140
+ private readTabEpochs;
101
141
  }
@@ -34,6 +34,16 @@ const PAGE_SCRIPT_TIMEOUT_MS = 60_000;
34
34
  * long enough that the per-leg preflight doesn't double a bot's request
35
35
  * count. */
36
36
  const HIDDEN_SAMPLE_TTL_MS = 500;
37
+ /** How long `reloadPage` waits for a new document AND a working command
38
+ * round trip before it reports what it did and did not observe. A cold Vite
39
+ * re-optimize on a first reload is the slow case this budget is sized for. */
40
+ const RELOAD_READY_TIMEOUT_MS = 60_000;
41
+ const RELOAD_POLL_MS = 250;
42
+ /** Seconds since `since`, one decimal — every `reloadPage` failure names how
43
+ * long it actually waited rather than quoting the budget. */
44
+ function elapsedSeconds(since) {
45
+ return ((Date.now() - since) / 1000).toFixed(1);
46
+ }
37
47
  export class RelayTransport {
38
48
  baseUrl;
39
49
  timeoutMs;
@@ -119,9 +129,13 @@ export class RelayTransport {
119
129
  const hidden = this.forceHiddenDrive || (await this.isHiddenCached());
120
130
  if (hidden && !this.hiddenDriveAnnounced) {
121
131
  this.hiddenDriveAnnounced = true;
122
- process.stdout.write('vgai: the editor tab is HIDDEN the engine hidden-pauses its loop while the tab ' +
123
- 'is backgrounded, so this run drives deterministic runTicks through the session relay. ' +
124
- 'Bring the editor tab to the foreground for real-time play.\n');
132
+ // P21: what was measured, and what this run is doing about it not a
133
+ // claim about where the tab is. The reading is a `presence` snapshot
134
+ // (`isHidden` below), which ages between the tab's own reports.
135
+ process.stdout.write('vgai: the editor page last REPORTED document.visibilityState "hidden" — the engine ' +
136
+ 'stops its loop while the page reports itself hidden, so this run drives ' +
137
+ 'deterministic runTicks through the session relay instead of wall clock. Sim time ' +
138
+ 'advances either way; `vgai status` prints that reading with its age.\n');
125
139
  }
126
140
  return hidden;
127
141
  }
@@ -182,12 +196,12 @@ export class RelayTransport {
182
196
  }
183
197
  async screenshot(path) {
184
198
  // Same preflight as every other leg — a hidden tab's canvas is a provably
185
- // stale frame, and `refreshHiddenFrame` is what asks the relay for a
199
+ // stale frame, and `refreshStarvedFrame` is what asks the relay for a
186
200
  // deterministic one-tick refresh instead of a `BRIDGE_SCREENSHOT_STALE`
187
201
  // refusal (`command-listener.ts`'s `handleBridgeScreenshot`).
188
202
  const body = await this.postCommand({
189
203
  type: 'bridge-screenshot',
190
- refreshHiddenFrame: await this.preflightHidden(),
204
+ refreshStarvedFrame: await this.preflightHidden(),
191
205
  // Address THIS transport's instance so a per-seat `game.instance(id)`
192
206
  // screenshot captures that seat's game stack, not always the primary.
193
207
  ...(this.instance !== undefined ? { instance: this.instance } : {}),
@@ -205,7 +219,7 @@ export class RelayTransport {
205
219
  // human watching this terminal is looking.
206
220
  const warning = body['flatness']?.warning;
207
221
  const notes = {
208
- ...(body['hiddenFrame'] === true ? { hiddenFrame: true } : {}),
222
+ ...(body['loopRecoveryFrame'] === true ? { loopRecoveryFrame: true } : {}),
209
223
  ...(typeof warning === 'string' ? { flatnessWarning: warning } : {}),
210
224
  };
211
225
  const caveat = describeCaptureCaveat(notes);
@@ -238,4 +252,102 @@ export class RelayTransport {
238
252
  };
239
253
  }
240
254
  }
255
+ /**
256
+ * P20 — order the tab to reload, then wait for EVIDENCE that it came back.
257
+ *
258
+ * Two witnesses, in order, because either one alone lies in a way that
259
+ * matters:
260
+ *
261
+ * 1. The tab table's EPOCH count rising. A tab's epoch id is minted per
262
+ * page-load and carried on its heartbeat, so the server increments
263
+ * this when it observes a new document — not when the tab claims one.
264
+ * Without it, a `page-reload` that the page never acted on (a
265
+ * `beforeunload` blocker, a listener that had already died) would look
266
+ * identical to a completed reload.
267
+ *
268
+ * `lastIndexRequestAt` was the first choice here and is measurably the
269
+ * WRONG one: a reload served from the browser's own cache leaves it
270
+ * untouched (observed live 2026-08-15 — `epochCount` went 2 → 3 while
271
+ * the stamp did not move), so it reported "no new document" for a
272
+ * reload that had plainly happened.
273
+ * 2. A `list-instances` command completing. That is the whole relay path
274
+ * — server → SSE channel → the NEW document's command listener — so it
275
+ * is the difference between "a document loaded" and "the session can
276
+ * be driven again". It is the one command with no play gate and no
277
+ * side effects (`command-listener.ts`), and it is held-and-retried by
278
+ * the relay while a tab is mid-boot, so a refusal here means the page
279
+ * genuinely is not answering yet.
280
+ *
281
+ * Neither witness is inferred from the send. A reload that never completes
282
+ * REJECTS with what was and was not observed, rather than resolving into a
283
+ * caller's belief that the page is fresh.
284
+ */
285
+ async reloadPage() {
286
+ const before = await this.readTabEpochs();
287
+ const orderedAt = Date.now();
288
+ const ordered = await this.postCommand({ type: 'page-reload' }, this.timeoutMs);
289
+ if (!ordered.ok) {
290
+ throw new Error(`vgai: the editor session refused the reload — ${ordered.error ?? 'no reason given'}`);
291
+ }
292
+ // The stale visibility sample belongs to a document that no longer
293
+ // exists; the next leg must measure the new one.
294
+ this.hiddenCache = null;
295
+ const deadline = orderedAt + RELOAD_READY_TIMEOUT_MS;
296
+ const loaded = await this.pollUntil(deadline, async () => {
297
+ const now = await this.readTabEpochs();
298
+ // Any present tab whose epoch count has RISEN, or a tab that was not
299
+ // in the table before (the reload arrived as a fresh row). An unknown
300
+ // table — an unreachable server, an older one — never witnesses a
301
+ // reload by default; `undefined` compares false here on purpose.
302
+ return [...now].some(([tabId, epochs]) => epochs > (before.get(tabId) ?? 0));
303
+ });
304
+ if (!loaded) {
305
+ throw new Error(`vgai: reload ordered ${elapsedSeconds(orderedAt)}s ago and no tab of the session at ` +
306
+ `${this.baseUrl} has reported a new page load. The tab may be gone; ` +
307
+ '`vgai edit` reopens it.');
308
+ }
309
+ const answering = await this.pollUntil(deadline, async () => {
310
+ const ready = await this.postCommand({ type: 'list-instances' }, this.timeoutMs).catch(() => ({ ok: false }));
311
+ return ready.ok;
312
+ });
313
+ if (!answering) {
314
+ throw new Error(`vgai: reload ordered ${elapsedSeconds(orderedAt)}s ago — a new document loaded, but ` +
315
+ 'its command listener has not answered yet. Check `vgai status` for page errors ' +
316
+ 'from that load.');
317
+ }
318
+ }
319
+ /** Poll `check` every {@link RELOAD_POLL_MS} until it holds or `deadline`
320
+ * passes; `false` means the deadline won, and the CALLER names what that
321
+ * means — a shared "timed out" sentence would be exactly the kind of
322
+ * message that says nothing about which witness was missing. */
323
+ async pollUntil(deadline, check) {
324
+ for (;;) {
325
+ await new Promise((resolve) => setTimeout(resolve, RELOAD_POLL_MS));
326
+ if (await check())
327
+ return true;
328
+ if (Date.now() >= deadline)
329
+ return false;
330
+ }
331
+ }
332
+ /** Page-loads per present tab, from `/__editor/state`'s tab table — the
333
+ * server's own count of the documents it has seen a tab run. Empty when
334
+ * the server cannot answer, which witnesses nothing. */
335
+ async readTabEpochs() {
336
+ try {
337
+ const res = await fetch(`${this.baseUrl}/__editor/state`, {
338
+ signal: AbortSignal.timeout(this.timeoutMs),
339
+ });
340
+ const state = (await res.json());
341
+ const epochs = new Map();
342
+ for (const tab of state.tabs ?? []) {
343
+ if (typeof tab.tabId8 === 'string' && typeof tab.epochCount === 'number') {
344
+ epochs.set(tab.tabId8, tab.epochCount);
345
+ }
346
+ }
347
+ return epochs;
348
+ }
349
+ catch {
350
+ return new Map();
351
+ }
352
+ }
241
353
  }
@@ -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[];
@@ -120,7 +120,7 @@ export interface VgaiBridgeHandle {
120
120
  runTicks(n: number, opts?: RunTicksOptions): void;
121
121
  /** Collapses `input.setVirtualAction(action, true)` → wait `simSeconds` of
122
122
  * sim time (host-loop ticks while visible, deterministic same-phase ticks
123
- * while hidden-paused) → `input.clearVirtualActions()` into one call — see
123
+ * while loop-starved) → `input.clearVirtualActions()` into one call — see
124
124
  * `runtime/debug-bridge.ts`'s
125
125
  * `holdFor` doc comment for the full contract (gated-immediately /
126
126
  * play-stopped-mid-wait shapes). */