@vgai/live 0.5.21 → 0.5.23

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.
Files changed (65) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/{editor.d.ts → vgai-live/src/editor.d.ts} +5 -8
  3. package/dist/{editor.js → vgai-live/src/editor.js} +5 -15
  4. package/dist/{game-client → vgai-live/src/game-client}/bridge-transport.d.ts +1 -1
  5. package/dist/{game-client → vgai-live/src/game-client}/client.d.ts +4 -6
  6. package/dist/{game-client → vgai-live/src/game-client}/client.js +6 -8
  7. package/dist/{game-client → vgai-live/src/game-client}/relay-transport.d.ts +13 -2
  8. package/dist/{game-client → vgai-live/src/game-client}/relay-transport.js +21 -3
  9. package/dist/{game-client → vgai-live/src/game-client}/types.d.ts +5 -5
  10. package/dist/{game-client → vgai-live/src/game-client}/wait-for.js +2 -2
  11. package/dist/{game.d.ts → vgai-live/src/game.d.ts} +1 -1
  12. package/dist/{index.d.ts → vgai-live/src/index.d.ts} +2 -2
  13. package/dist/{index.js → vgai-live/src/index.js} +2 -2
  14. package/dist/{recording.js → vgai-live/src/recording.js} +3 -10
  15. package/dist/{session.d.ts → vgai-live/src/session.d.ts} +0 -7
  16. package/dist/{session.js → vgai-live/src/session.js} +56 -19
  17. package/dist/{tools.d.ts → vgai-live/src/tools.d.ts} +1 -1
  18. package/dist/{tools.js → vgai-live/src/tools.js} +1 -1
  19. package/dist/vgai-sdk/src/editor/session-registry-format.d.ts +81 -0
  20. package/dist/vgai-sdk/src/editor/session-registry-format.js +158 -0
  21. package/package.json +3 -3
  22. package/src/editor.ts +5 -16
  23. package/src/game-client/bridge-transport.ts +1 -1
  24. package/src/game-client/client.ts +6 -8
  25. package/src/game-client/relay-transport.ts +21 -3
  26. package/src/game-client/types.ts +5 -5
  27. package/src/game-client/wait-for.ts +2 -2
  28. package/src/game.ts +1 -1
  29. package/src/index.ts +2 -2
  30. package/src/recording.ts +3 -10
  31. package/src/session.ts +67 -24
  32. package/src/tools.ts +1 -1
  33. /package/dist/{editor-document.d.ts → vgai-live/src/editor-document.d.ts} +0 -0
  34. /package/dist/{editor-document.js → vgai-live/src/editor-document.js} +0 -0
  35. /package/dist/{game-client → vgai-live/src/game-client}/bridge-heartbeat.d.ts +0 -0
  36. /package/dist/{game-client → vgai-live/src/game-client}/bridge-heartbeat.js +0 -0
  37. /package/dist/{game-client → vgai-live/src/game-client}/bridge-transport.js +0 -0
  38. /package/dist/{game-client → vgai-live/src/game-client}/capture-notes.d.ts +0 -0
  39. /package/dist/{game-client → vgai-live/src/game-client}/capture-notes.js +0 -0
  40. /package/dist/{game-client → vgai-live/src/game-client}/errors.d.ts +0 -0
  41. /package/dist/{game-client → vgai-live/src/game-client}/errors.js +0 -0
  42. /package/dist/{game-client → vgai-live/src/game-client}/events-matcher.d.ts +0 -0
  43. /package/dist/{game-client → vgai-live/src/game-client}/events-matcher.js +0 -0
  44. /package/dist/{game-client → vgai-live/src/game-client}/failure-block.d.ts +0 -0
  45. /package/dist/{game-client → vgai-live/src/game-client}/failure-block.js +0 -0
  46. /package/dist/{game-client → vgai-live/src/game-client}/fast-forward.d.ts +0 -0
  47. /package/dist/{game-client → vgai-live/src/game-client}/fast-forward.js +0 -0
  48. /package/dist/{game-client → vgai-live/src/game-client}/hidden-recovery.d.ts +0 -0
  49. /package/dist/{game-client → vgai-live/src/game-client}/hidden-recovery.js +0 -0
  50. /package/dist/{game-client → vgai-live/src/game-client}/index.d.ts +0 -0
  51. /package/dist/{game-client → vgai-live/src/game-client}/index.js +0 -0
  52. /package/dist/{game-client → vgai-live/src/game-client}/perf-sampling.d.ts +0 -0
  53. /package/dist/{game-client → vgai-live/src/game-client}/perf-sampling.js +0 -0
  54. /package/dist/{game-client → vgai-live/src/game-client}/screenshot-target.d.ts +0 -0
  55. /package/dist/{game-client → vgai-live/src/game-client}/screenshot-target.js +0 -0
  56. /package/dist/{game-client → vgai-live/src/game-client}/state-cap.d.ts +0 -0
  57. /package/dist/{game-client → vgai-live/src/game-client}/state-cap.js +0 -0
  58. /package/dist/{game-client → vgai-live/src/game-client}/types.js +0 -0
  59. /package/dist/{game-client → vgai-live/src/game-client}/wait-for.d.ts +0 -0
  60. /package/dist/{game.js → vgai-live/src/game.js} +0 -0
  61. /package/dist/{lazy-proxy.d.ts → vgai-live/src/lazy-proxy.d.ts} +0 -0
  62. /package/dist/{lazy-proxy.js → vgai-live/src/lazy-proxy.js} +0 -0
  63. /package/dist/{recording.d.ts → vgai-live/src/recording.d.ts} +0 -0
  64. /package/dist/{singleton.d.ts → vgai-live/src/singleton.d.ts} +0 -0
  65. /package/dist/{singleton.js → vgai-live/src/singleton.js} +0 -0
@@ -0,0 +1,158 @@
1
+ /**
2
+ * `~/.vgai/editor-sessions.json` — the ONE spelling of the editor-session
3
+ * registry contract, and the shared machinery every reader was hand-copying.
4
+ *
5
+ * The registry's WRITE half stays with its owner
6
+ * (`packages/editor/server/session-registry.ts`); this module owns the
7
+ * FORMAT: the entry shape, the shape guards, the file path, the
8
+ * liveness-filtered read, the `/__editor/project` answer parser, and the
9
+ * deterministic session-selection core. It existed as FOUR drifting copies
10
+ * (server registry, the CLI, and both vgai-sdk transports) held together by
11
+ * a "the CLI has no editor dependency" premise that had stopped being true —
12
+ * and the drift was already real: one copy's `/__editor/project` parser
13
+ * dropped `manifestError`, reporting a degraded session as belonging to no
14
+ * project.
15
+ */
16
+ import { readFileSync, realpathSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import { join, resolve } from 'node:path';
19
+ export const EDITOR_SESSIONS_REGISTRY_FILE = join(homedir(), '.vgai', 'editor-sessions.json');
20
+ export function isEditorSessionEntry(v) {
21
+ if (typeof v !== 'object' || v === null)
22
+ return false;
23
+ const s = v;
24
+ const optionalIdentity = (value) => value === undefined || value === null || typeof value === 'string';
25
+ return ((typeof s['project'] === 'string' || s['project'] === null) &&
26
+ typeof s['port'] === 'number' &&
27
+ typeof s['pid'] === 'number' &&
28
+ typeof s['startedAt'] === 'string' &&
29
+ optionalIdentity(s['sessionId']) &&
30
+ optionalIdentity(s['controlSecret']) &&
31
+ optionalIdentity(s['repositoryId']) &&
32
+ optionalIdentity(s['worktreeId']) &&
33
+ optionalIdentity(s['worktreeRoot']) &&
34
+ optionalIdentity(s['projectRelativePath']) &&
35
+ optionalIdentity(s['branch']) &&
36
+ optionalIdentity(s['headCommit']) &&
37
+ optionalIdentity(s['baseCommit']));
38
+ }
39
+ export function normalizeEditorSessionEntry(session) {
40
+ return {
41
+ ...session,
42
+ sessionId: session.sessionId ?? null,
43
+ controlSecret: session.controlSecret ?? null,
44
+ repositoryId: session.repositoryId ?? null,
45
+ worktreeId: session.worktreeId ?? null,
46
+ worktreeRoot: session.worktreeRoot ?? null,
47
+ projectRelativePath: session.projectRelativePath ?? null,
48
+ branch: session.branch ?? null,
49
+ headCommit: session.headCommit ?? null,
50
+ baseCommit: session.baseCommit ?? null,
51
+ };
52
+ }
53
+ export function pidAlive(pid) {
54
+ try {
55
+ process.kill(pid, 0);
56
+ return true;
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
62
+ /** The registry's live entries — shape-validated and PID-liveness-filtered. */
63
+ export function readLiveRegisteredSessions() {
64
+ try {
65
+ const raw = JSON.parse(readFileSync(EDITOR_SESSIONS_REGISTRY_FILE, 'utf8'));
66
+ return Array.isArray(raw)
67
+ ? raw
68
+ .filter(isEditorSessionEntry)
69
+ .map(normalizeEditorSessionEntry)
70
+ .filter((s) => pidAlive(s.pid))
71
+ : [];
72
+ }
73
+ catch {
74
+ return [];
75
+ }
76
+ }
77
+ /**
78
+ * WHICH PROJECT a `/__editor/project` body says its server is serving.
79
+ *
80
+ * `serving` is that server's own statement of "I AM serving this project, I
81
+ * just cannot describe it" (its manifest is unparseable or fails strict
82
+ * validation) — added to the route precisely because a bare
83
+ * `{ project: null }` is indistinguishable from "no project open". Reading
84
+ * only `project.path` collapses the two, and the cost is that every
85
+ * project-matched command loses a live session the moment a save breaks its
86
+ * manifest, reporting it as belonging to no project rather than as this
87
+ * project's degraded session.
88
+ */
89
+ export function servedProjectAnswer(body) {
90
+ const b = body;
91
+ return {
92
+ path: b.project?.path ?? b.serving?.path ?? null,
93
+ manifestError: b.project ? null : (b.serving?.error ?? null),
94
+ };
95
+ }
96
+ /** Canonical (realpathed when possible) absolute form of a project path. */
97
+ function canonicalizeProjectPath(p) {
98
+ const absolute = resolve(p);
99
+ try {
100
+ return realpathSync(absolute);
101
+ }
102
+ catch {
103
+ return absolute;
104
+ }
105
+ }
106
+ /** Extract the port from an `http(s)://host:port` URL, or undefined if unparseable. */
107
+ function portOfUrl(url) {
108
+ try {
109
+ const port = new URL(url).port;
110
+ return port ? Number(port) : undefined;
111
+ }
112
+ catch {
113
+ return undefined;
114
+ }
115
+ }
116
+ /**
117
+ * Deterministic session selection — the ONE precedence order every surface
118
+ * follows: explicit `ctx.editorUrl` probe > port match against the listing >
119
+ * `ctx.projectRoot` match > lowest live port > not-running. Generic over the
120
+ * session shape so the editor and play transports (whose probe results
121
+ * differ) share the algorithm rather than a copy of it.
122
+ */
123
+ export async function resolveRegisteredSession(ctx, transport, options) {
124
+ const editorUrl = ctx.editorUrl;
125
+ if (editorUrl !== undefined && transport.probeSessionUrl) {
126
+ try {
127
+ const session = await options.withTimeout(transport.probeSessionUrl(editorUrl, options.probeTimeoutMs), options.probeTimeoutMs, 'explicit editor URL probe');
128
+ if (session)
129
+ return session;
130
+ }
131
+ catch { }
132
+ return options.notRunning();
133
+ }
134
+ let sessions;
135
+ try {
136
+ sessions = await options.withTimeout(transport.listSessions(options.discoveryTimeoutMs), options.discoveryTimeoutMs, 'editor session discovery');
137
+ }
138
+ catch {
139
+ return options.notRunning();
140
+ }
141
+ if (sessions.length === 0)
142
+ return options.notRunning();
143
+ if (editorUrl !== undefined) {
144
+ const port = portOfUrl(editorUrl);
145
+ const match = sessions.find((s) => s.port === port);
146
+ if (!match)
147
+ return options.notRunning();
148
+ return { ...match, url: editorUrl.replace(/\/+$/, '') };
149
+ }
150
+ if (ctx.projectRoot !== undefined) {
151
+ const canon = canonicalizeProjectPath(ctx.projectRoot);
152
+ const match = sessions.find((s) => s.project !== null && canonicalizeProjectPath(s.project) === canon);
153
+ if (!match)
154
+ return options.notRunning();
155
+ return match;
156
+ }
157
+ return [...sessions].sort((a, b) => a.port - b.port)[0];
158
+ }
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.21",
5
+ "version": "0.5.23",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
@@ -32,8 +32,8 @@
32
32
  "prepack": "npm run build"
33
33
  },
34
34
  "dependencies": {
35
- "@vgai/editor-sdk": "0.5.21",
36
- "@vgai/sdk": "0.5.21"
35
+ "@vgai/editor-sdk": "0.5.23",
36
+ "@vgai/sdk": "0.5.23"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@playwright/test": ">=1.58.2 <2"
package/src/editor.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * `LiveEditor` — the editor-control half of `@vgai/live`'s `{ editor, game,
3
- * page }` (Wave 2). Methods are named after the
3
+ * page }`. Methods are named after the
4
4
  * ACTION, not the CLI flag spelling (e.g. `select('all')` rather than a
5
5
  * separate `selectAll`, `showPanel('viewport-play')` rather than
6
6
  * `vgai show viewport play`'s two-token shape) — see each method's own doc
7
7
  * comment for the exact CLI verb / `EditorClient` call it mirrors.
8
8
  *
9
9
  * Every method reuses `@vgai/editor-sdk`'s `EditorClient` — this module never
10
- * hand-rolls a `fetch` to `/__editor/command` itself. (WO-8 removed the one
11
- * exception, `applyDiff`, which was FILE mode rather than a live wire command.)
10
+ * hand-rolls a `fetch` to `/__editor/command` itself, and has no FILE-mode
11
+ * escape hatch: every method goes over the wire.
12
12
  */
13
13
 
14
14
  import type {
@@ -103,11 +103,8 @@ export class LiveEditor {
103
103
 
104
104
  /**
105
105
  * The active authoring adapter's persistence destination — where a save would
106
- * land (`status().savePath`).
107
- *
108
- * WO-8: this also took a `path` and OPENED that scene through
109
- * `EditorClient.openScene` -> the `open-scene` relay verb. That verb now rejects
110
- * (the `.vscn.json` format is deleted), so only the read remains.
106
+ * land (`status().savePath`). A read only: a three root has no scene document
107
+ * to open, and its root is activated instead.
111
108
  */
112
109
  async scene(): Promise<string | null> {
113
110
  const state = await this.#client.getState();
@@ -133,14 +130,6 @@ export class LiveEditor {
133
130
  return this.#client.captureActiveDocument(size);
134
131
  }
135
132
 
136
- /*
137
- * `applyDiff` lived here. It dispatched `project.scene.apply`, one of the
138
- * twelve `.vscn` document tools deleted with the format, so there is nothing
139
- * left for it to call. `ApplyDiffInput` / `ApplyDiffResult` went with it, and
140
- * so did `projectRoot` — this was the ONE method that used it (every other
141
- * method goes over the wire).
142
- */
143
-
144
133
  async play(opts?: { seed?: number }): Promise<void> {
145
134
  await this.#client.play(opts);
146
135
  }
@@ -51,7 +51,7 @@ export interface BridgeTransport {
51
51
  * blank/corrupt file. */
52
52
  screenshot(path: string): Promise<CaptureNotes>;
53
53
  /**
54
- * Wave-2 "one dialect, full capability" — runs a UI-automation step
54
+ * One dialect, full capability — runs a UI-automation step
55
55
  * written as a literal `async (page) => {...}` (`GameClient.page()`,
56
56
  * `client.ts`). `src` is `step.toString()`; `step` is the ORIGINAL
57
57
  * function, wrapped so its own parameter type is erased to `unknown` (only
@@ -141,7 +141,7 @@ export class PageTransport implements BridgeTransport {
141
141
  return {};
142
142
  }
143
143
 
144
- /** Wave-2: the ONE transport that runs a `game.page()` step against a REAL
144
+ /** The ONE transport that runs a `game.page()` step against a REAL
145
145
  * Playwright `Page` — no serialization, so `step`'s own closures work
146
146
  * here (see `bridge-transport.ts`'s `runPageScript` doc comment for the
147
147
  * full honesty-boundary contract; `src` is unused on this leg, kept only
@@ -651,7 +651,7 @@ export class GameClient {
651
651
  snapshot: () => this.snapshot(),
652
652
  now: () => Date.now(),
653
653
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
654
- // Fixture heartbeat (Wave-6 findings ledger) — real stdout, the
654
+ // Fixture heartbeat — real stdout, the
655
655
  // same channel a caller watching stdout already treats as
656
656
  // liveness (see wait-for.ts's module doc for the emission
657
657
  // invariant `maybeHeartbeat` enforces).
@@ -688,7 +688,7 @@ export class GameClient {
688
688
  const start = await this.snapshot();
689
689
  let lastTick: number | null = null;
690
690
  let stalledPolls = 0;
691
- // Fixture heartbeat (Wave-6 findings ledger) — same invariant as
691
+ // Fixture heartbeat — same invariant as
692
692
  // `wait-for.ts`'s `runWaitFor`: a heartbeat requires BOTH 60s of wall
693
693
  // silence AND the tick having advanced since the last one emitted, so a
694
694
  // genuinely stalled sim clock (caught by `stalledPolls` above, ~30s)
@@ -770,10 +770,8 @@ export class GameClient {
770
770
  * `events.expect`'s failure path each shoot a frame on their own
771
771
  * (`toSessionFailure`, `GameEvents.expect`), and a route's `whileHeld`
772
772
  * observer may shoot one from inside a callback the driver never sees. The
773
- * only place that sees ALL of them is here, which is why an autoplay run
774
- * stamps its captures by listening rather than by intercepting
775
- * (`src/tools/route-context.ts` in a scaffolded project is the shipped
776
- * listener).
773
+ * only place that sees ALL of them is here, which is why a driving tool
774
+ * stamps its captures by listening rather than by intercepting.
777
775
  */
778
776
  onCapture(listener: CaptureListener): () => void {
779
777
  this.#captureListeners.add(listener);
@@ -783,7 +781,7 @@ export class GameClient {
783
781
  }
784
782
 
785
783
  /**
786
- * Wave-2 "one dialect, full capability" — runs a UI-automation step
784
+ * One dialect, full capability — runs a UI-automation step
787
785
  * written as a literal Playwright `async (page) => {...}` (interface
788
786
  * doctrine §3.2/§4 rung 4: "the AI should think it is basically just
789
787
  * executing Playwright"). Under `PageTransport` this drives the REAL
@@ -102,6 +102,17 @@ export class RelayTransport implements BridgeTransport {
102
102
  this.instance = opts.instance;
103
103
  }
104
104
 
105
+ /**
106
+ * Deliberately NOT guarded against a page-fallback answer the way the browser
107
+ * (`packages/editor/src/editor-server-response.ts`) and the CLI client
108
+ * (`@vgai/editor-sdk`'s `EditorClient.readJson`) are. Those two can be pointed
109
+ * at an arbitrary URL — a share tunnel, a static host — where a `200
110
+ * text/html` for an unserved route is real. This `baseUrl` is
111
+ * `http://127.0.0.1:<port>` and nothing else, and `session.ts` has already
112
+ * PROVED that port is this project's editor server (`probeServedProject`, whose
113
+ * own unparseable-answer path is exactly the refusal): a foreign occupant is
114
+ * refused there, before any command reaches here.
115
+ */
105
116
  private async postCommand(
106
117
  body: Record<string, unknown>,
107
118
  timeoutMs: number,
@@ -176,7 +187,7 @@ export class RelayTransport implements BridgeTransport {
176
187
  *
177
188
  * It runs on every leg, not just `call('snapshot')`, because that is where
178
189
  * the measured gap was: a bot that drives the game with `hold`/`command` and
179
- * reads through them — the shape `npm run playtest` actually has — could run
190
+ * reads through them could run
180
191
  * its entire session against a backgrounded tab and never be told, so a
181
192
  * later failure read as a generic relay timeout instead of "your tab is
182
193
  * hidden". Returns whether the tab is hidden so `call` can decide whether to
@@ -189,7 +200,14 @@ export class RelayTransport implements BridgeTransport {
189
200
  // P21: what was measured, and what this run is doing about it — not a
190
201
  // claim about where the tab is. The reading is a `presence` snapshot
191
202
  // (`isHidden` below), which ages between the tab's own reports.
192
- process.stdout.write(
203
+ // STDERR, not stdout. `vgai status` and every other `--json`-shaped verb
204
+ // put their PAYLOAD on stdout and every banner on stderr; this notice went
205
+ // to stdout and prepended a prose sentence to the JSON, so any machine
206
+ // consumer piping `vgai status` into a parser got a SyntaxError the moment
207
+ // the tab happened to be hidden — measured while reading the coverage
208
+ // table on a backgrounded session. A diagnostic that breaks the payload it
209
+ // annotates is worse than no diagnostic.
210
+ process.stderr.write(
193
211
  'vgai: the editor page last REPORTED document.visibilityState "hidden" — the engine ' +
194
212
  'stops its loop while the page reports itself hidden, so this run drives ' +
195
213
  'deterministic runTicks through the session relay instead of wall clock. Sim time ' +
@@ -302,7 +320,7 @@ export class RelayTransport implements BridgeTransport {
302
320
  }
303
321
 
304
322
  /**
305
- * Wave-2: ships `src` (`step.toString()`) to the editor dev server's
323
+ * Ships `src` (`step.toString()`) to the editor dev server's
306
324
  * `page-script` op — a STANDALONE relay command (like `bridge-screenshot`
307
325
  * above), not a `bridge-call` method (see `command-listener.ts`'s
308
326
  * `handlePageScript` doc comment for why). `step` itself is unused on this
@@ -45,22 +45,22 @@ export interface DebugBridgeInput {
45
45
  setVirtualAction(action: string, value: VirtualActionValue): VirtualActionResult;
46
46
  tapVirtualAction(action: string): VirtualActionResult;
47
47
  clearVirtualActions(): void;
48
- /** D15/T-D15.5 — schedule a virtual actuation for a specific tick, applied
49
- * at the start of that tick's input phase. Declared here (Wave-2
50
- * bridge↔wire coverage-parity gate) for type-shape completeness with
48
+ /** Schedule a virtual actuation for a specific tick, applied
49
+ * at the start of that tick's input phase. Declared here for the
50
+ * bridge↔wire coverage-parity gate type-shape completeness with
51
51
  * `runtime/debug-bridge.ts`'s `VgaiDebugInputHandle` — this package still
52
52
  * exposes no client-side convenience wrapper around it (deliberately
53
53
  * parked; see `GameInput` in `client.ts`), this is pure type-shape
54
54
  * mirroring. */
55
55
  scheduleActionAtTick(tick: number, action: string, value: VirtualActionValue): void;
56
- /** Wave-2 pointer-dispatch op — mirrors `runtime/debug-bridge.ts`'s
56
+ /** Pointer-dispatch op — mirrors `runtime/debug-bridge.ts`'s
57
57
  * `VgaiDebugInputHandle.injectPointerDelta`: accumulates a synthetic
58
58
  * pointer delta for a named test source (sums within a frame, clears each
59
59
  * frame). Declared here for type-shape completeness with the bridge, same
60
60
  * precedent as `scheduleActionAtTick` above — no client-side convenience
61
61
  * wrapper in `client.ts` (deliberately parked). */
62
62
  injectPointerDelta(sourceId: string, delta: { x: number; y: number }): void;
63
- /** Wave-2 pointer-dispatch op — mirrors `runtime/debug-bridge.ts`'s
63
+ /** Pointer-dispatch op — mirrors `runtime/debug-bridge.ts`'s
64
64
  * `VgaiDebugInputHandle.injectPointerPosition`: sets a synthetic absolute
65
65
  * pointer position for a named test source (last-write-wins, persists
66
66
  * until changed). Same type-shape-only precedent as `scheduleActionAtTick`. */
@@ -168,7 +168,7 @@ export interface WaitForClock {
168
168
  }
169
169
 
170
170
  // ---------------------------------------------------------------------------
171
- // Fixture heartbeat (Wave-6 findings ledger: "Watchdog sim-awareness / fixture
171
+ // Fixture heartbeat ("Watchdog sim-awareness / fixture
172
172
  // heartbeat for long silent tests" — Session C hit exit-5 on the runner's
173
173
  // 2x90s stdout-liveness watchdog during a legitimately silent 5-minute test).
174
174
  //
@@ -179,7 +179,7 @@ export interface WaitForClock {
179
179
  // advancing test can never false-wedge regardless of duration, no matter how
180
180
  // long a single `simSeconds` budget runs.
181
181
  //
182
- // INVARIANT (tested below and in fixture.test.ts): a heartbeat requires BOTH
182
+ // INVARIANT (tested below): a heartbeat requires BOTH
183
183
  // (a) >= HEARTBEAT_INTERVAL_MS of wall time since the last heartbeat, AND
184
184
  // (b) the tick has ADVANCED since the last heartbeat (not merely since the
185
185
  // last poll). Without (b), the poll loop itself — which keeps running
package/src/game.ts CHANGED
@@ -22,7 +22,7 @@ import { GameClient, RelayTransport } from './game-client/index.js';
22
22
  /** A `GameClient` addressing ONE mounted instance, tagged with the mount `id`
23
23
  * it drives. `id` is the whole point of the handle to a CALLER: it is what
24
24
  * `tools.run(name, args, { instance: handle.id })` passes to scope a
25
- * node-hosted tool (e.g. `project.autoplay`) to this seat — the two-seat
25
+ * node-hosted tool to this seat — the two-seat
26
26
  * multiplayer verification pattern. Without it the caller has a client it can
27
27
  * drive but no id to hand a tool, so the seat-scoped path silently degrades to
28
28
  * the sole-instance one. */
package/src/index.ts CHANGED
@@ -34,8 +34,8 @@
34
34
  * and driven through the product. It is scoped to the ACTIVE document and
35
35
  * refuses anything outside it by name (`editor-document.ts`).
36
36
  *
37
- * `page(step)` = `GameClient.page(step)` (Wave-2's playwright-shim surface,
38
- * PR #166) — write `step` as a literal `async (page) => {...}` and inline
37
+ * `page(step)` = `GameClient.page(step)`, the playwright-shim surface
38
+ * write `step` as a literal `async (page) => {...}` and inline
39
39
  * every value it needs. KNOWN WIRE LIMITATION: under the relay transport this
40
40
  * binds to, `step` is shipped to the editor dev server as `step.toString()`
41
41
  * and reconstructed there — closures over outer variables do NOT survive that
package/src/recording.ts CHANGED
@@ -54,17 +54,10 @@ export class LiveGameplayRecording {
54
54
  await copyFile(capture.path, path);
55
55
  }
56
56
  return {
57
+ ...capture,
58
+ // The caller's `destination` copy, when there was one — otherwise the
59
+ // project path the recorder wrote. Spread first so this wins.
57
60
  path,
58
- startedAt: capture.startedAt,
59
- mimeType: capture.mimeType,
60
- width: capture.width,
61
- height: capture.height,
62
- fps: capture.fps,
63
- audio: capture.audio,
64
- durationMs: capture.durationMs,
65
- droppedFrames: capture.droppedFrames,
66
- frameErrors: capture.frameErrors,
67
- layers: capture.layers,
68
61
  };
69
62
  }
70
63
  }
package/src/session.ts CHANGED
@@ -54,6 +54,7 @@ import {
54
54
  HttpEditorTransport,
55
55
  withTimeout,
56
56
  } from '@vgai/sdk';
57
+ import { servedProjectAnswer } from '../../vgai-sdk/src/editor/session-registry-format';
57
58
 
58
59
  /**
59
60
  * Lifted verbatim from `packages/vgai-cli/src/project-root.ts` (see this
@@ -144,16 +145,6 @@ function readProjectSession(projectRoot: string): ProjectSessionHint | null {
144
145
  * but none open this project)" — sending the operator to start an editor that
145
146
  * was already running, with the real defect (their own manifest) never named.
146
147
  */
147
- function servedProject(body: unknown): { path: string | null; manifestError: string | null } {
148
- const b = body as {
149
- project?: { path?: string } | null;
150
- serving?: { path?: string; error?: string } | null;
151
- };
152
- return {
153
- path: b.project?.path ?? b.serving?.path ?? null,
154
- manifestError: b.project ? null : (b.serving?.error ?? null),
155
- };
156
- }
157
148
 
158
149
  /**
159
150
  * What the server on `port` says it serves — `undefined` when it did not
@@ -168,7 +159,7 @@ async function probeServedProject(
168
159
  signal: AbortSignal.timeout(EDITOR_SESSION_DISCOVERY_TIMEOUT_MS),
169
160
  });
170
161
  if (!response.ok) return undefined;
171
- return servedProject(await response.json());
162
+ return servedProjectAnswer(await response.json());
172
163
  } catch {
173
164
  return undefined;
174
165
  }
@@ -200,6 +191,62 @@ function manifestRefusal(projectRoot: string, manifestError: string): Error {
200
191
  * silently attaches to an unrelated project's session — see the module doc
201
192
  * above) when no vgai.project.json is found, or no live session covers it.
202
193
  */
194
+ /**
195
+ * THE REFUSAL WHEN NOTHING MATCHED — and it says WHICH nothing.
196
+ *
197
+ * "No live editor session found … run `vgai edit`" used to be the answer to
198
+ * four different states, only one of which it described. The other three sent
199
+ * the operator to start an editor that was already running:
200
+ *
201
+ * - discovery FAILED (a probe timeout under load) — nothing was learned, so
202
+ * "no session is running" is not a fact anyone established;
203
+ * - the registry was read and is genuinely empty — the one case the old text
204
+ * was right about;
205
+ * - sessions exist, but every one resolves to a different canonical path. In
206
+ * a repo worked through git worktrees this is the ORDINARY miss: two
207
+ * checkouts of the same project differ only in a path prefix, and a
208
+ * symlinked worktree's `realpath` diverges from the path the caller typed.
209
+ * Naming both sides is what makes it a two-second diagnosis instead of a
210
+ * hunt.
211
+ *
212
+ * Each branch prescribes only what its own state supports.
213
+ */
214
+ function noMatchingSessionRefusal(
215
+ projectRoot: string,
216
+ canon: string,
217
+ sessions: readonly EditorSessionInfo[],
218
+ discoveryFailure: string | null,
219
+ ): Error {
220
+ if (discoveryFailure !== null) {
221
+ return new Error(
222
+ `@vgai/live: could not READ the editor session registry while looking for ${projectRoot} — ` +
223
+ `${discoveryFailure}. This is not the answer "no editor is running": the question went ` +
224
+ 'unanswered, so nothing is known about what is live. Retry (a probe can time out while ' +
225
+ 'the box is loaded); if it keeps failing, `vgai sessions` asks the same question directly.',
226
+ );
227
+ }
228
+ if (sessions.length === 0) {
229
+ return new Error(
230
+ `@vgai/live: the editor session registry is readable and lists NO live sessions, so none ` +
231
+ `covers ${projectRoot}. @vgai/live only attaches to an already-running session — it ` +
232
+ 'never starts one — so run `vgai edit` in that project first, then retry.',
233
+ );
234
+ }
235
+ const listed = sessions
236
+ .map((s) => ` port ${s.port} → ${s.project === null ? '(no project)' : s.project}`)
237
+ .join('\n');
238
+ return new Error(
239
+ `@vgai/live: ${sessions.length} live editor session(s) are running, but none of them opens ` +
240
+ `${projectRoot}. @vgai/live never silently attaches to a different project.\n` +
241
+ ` looking for (resolved): ${canon}\n` +
242
+ ` live sessions:\n${listed}\n` +
243
+ ' If one of those is meant to be this project, the two paths differ after resolution — ' +
244
+ 'the usual cause is a git worktree or a symlink, where the session was opened through a ' +
245
+ 'different path to the same files. Run `vgai edit` from THIS path, or use the path the ' +
246
+ 'session lists.',
247
+ );
248
+ }
249
+
203
250
  export async function resolveSession(
204
251
  projectDir: string = process.cwd(),
205
252
  deps: SessionResolutionDeps = {},
@@ -239,30 +286,26 @@ export async function resolveSession(
239
286
  }
240
287
 
241
288
  let sessions: EditorSessionInfo[];
289
+ // A FAILED discovery is not an empty one. Collapsing the two into `[]` is
290
+ // what made this door answer "no live editor session found" — and prescribe
291
+ // `vgai edit` — for a probe that merely timed out under load, sending the
292
+ // operator to start an editor that was already running while the real defect
293
+ // went unnamed. The same collapse the manifest refusal above was added for.
294
+ let discoveryFailure: string | null = null;
242
295
  try {
243
296
  sessions = await withTimeout(
244
297
  transport.listSessions(EDITOR_SESSION_DISCOVERY_TIMEOUT_MS),
245
298
  EDITOR_SESSION_DISCOVERY_TIMEOUT_MS,
246
299
  'editor session discovery',
247
300
  );
248
- } catch {
301
+ } catch (err) {
249
302
  sessions = [];
303
+ discoveryFailure = err instanceof Error ? err.message : String(err);
250
304
  }
251
305
 
252
306
  const canon = canonicalPath(projectRoot);
253
307
  const match = sessions.find((s) => s.project !== null && canonicalPath(s.project) === canon);
254
- if (!match) {
255
- const otherCount = sessions.length;
256
- throw new Error(
257
- `@vgai/live: no live editor session found for ${projectRoot}. @vgai/live only attaches to ` +
258
- 'an already-running session — it never starts one — so run `vgai edit` in that project ' +
259
- 'first, then retry.' +
260
- (otherCount > 0
261
- ? ` (${otherCount} other live session(s) found, but none open this project — @vgai/live ` +
262
- 'never silently attaches to a different project.)'
263
- : ''),
264
- );
265
- }
308
+ if (!match) throw noMatchingSessionRefusal(projectRoot, canon, sessions, discoveryFailure);
266
309
 
267
310
  if (match.manifestError != null) throw manifestRefusal(projectRoot, match.manifestError);
268
311
 
package/src/tools.ts CHANGED
@@ -31,7 +31,7 @@ export class LiveTools {
31
31
  *
32
32
  * `instance` names WHICH mounted instance the tool should drive when several
33
33
  * are live (multiplayer authoring) — it reaches the tool as `ctx.instance`,
34
- * and a tool that drives the game (e.g. `project.autoplay`) binds
34
+ * and a tool that drives the game binds
35
35
  * `game.instance(ctx.instance)` from it. Omitted is the single-instance case;
36
36
  * the tool then targets the sole live instance, exactly as before.
37
37
  */
File without changes