@vgai/live 0.5.22 → 0.5.24

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 +10 -1
  5. package/dist/{game-client → vgai-live/src/game-client}/client.d.ts +36 -6
  6. package/dist/{game-client → vgai-live/src/game-client}/client.js +80 -9
  7. package/dist/{game-client → vgai-live/src/game-client}/relay-transport.d.ts +15 -2
  8. package/dist/{game-client → vgai-live/src/game-client}/relay-transport.js +38 -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 +14 -1
  24. package/src/game-client/client.ts +98 -9
  25. package/src/game-client/relay-transport.ts +46 -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
@@ -47,6 +47,7 @@
47
47
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
48
48
  import { dirname, join, resolve } from 'node:path';
49
49
  import { EDITOR_SESSION_DISCOVERY_TIMEOUT_MS, HttpEditorTransport, withTimeout, } from '@vgai/sdk';
50
+ import { servedProjectAnswer } from '../../vgai-sdk/src/editor/session-registry-format';
50
51
  /**
51
52
  * Lifted verbatim from `packages/vgai-cli/src/project-root.ts` (see this
52
53
  * module's doc comment above for why it's duplicated rather than imported).
@@ -106,13 +107,6 @@ function readProjectSession(projectRoot) {
106
107
  * but none open this project)" — sending the operator to start an editor that
107
108
  * was already running, with the real defect (their own manifest) never named.
108
109
  */
109
- function servedProject(body) {
110
- const b = body;
111
- return {
112
- path: b.project?.path ?? b.serving?.path ?? null,
113
- manifestError: b.project ? null : (b.serving?.error ?? null),
114
- };
115
- }
116
110
  /**
117
111
  * What the server on `port` says it serves — `undefined` when it did not
118
112
  * answer at all. One probe: the manifest failure arrives with the path, so
@@ -125,7 +119,7 @@ async function probeServedProject(port) {
125
119
  });
126
120
  if (!response.ok)
127
121
  return undefined;
128
- return servedProject(await response.json());
122
+ return servedProjectAnswer(await response.json());
129
123
  }
130
124
  catch {
131
125
  return undefined;
@@ -154,6 +148,50 @@ function manifestRefusal(projectRoot, manifestError) {
154
148
  * silently attaches to an unrelated project's session — see the module doc
155
149
  * above) when no vgai.project.json is found, or no live session covers it.
156
150
  */
151
+ /**
152
+ * THE REFUSAL WHEN NOTHING MATCHED — and it says WHICH nothing.
153
+ *
154
+ * "No live editor session found … run `vgai edit`" used to be the answer to
155
+ * four different states, only one of which it described. The other three sent
156
+ * the operator to start an editor that was already running:
157
+ *
158
+ * - discovery FAILED (a probe timeout under load) — nothing was learned, so
159
+ * "no session is running" is not a fact anyone established;
160
+ * - the registry was read and is genuinely empty — the one case the old text
161
+ * was right about;
162
+ * - sessions exist, but every one resolves to a different canonical path. In
163
+ * a repo worked through git worktrees this is the ORDINARY miss: two
164
+ * checkouts of the same project differ only in a path prefix, and a
165
+ * symlinked worktree's `realpath` diverges from the path the caller typed.
166
+ * Naming both sides is what makes it a two-second diagnosis instead of a
167
+ * hunt.
168
+ *
169
+ * Each branch prescribes only what its own state supports.
170
+ */
171
+ function noMatchingSessionRefusal(projectRoot, canon, sessions, discoveryFailure) {
172
+ if (discoveryFailure !== null) {
173
+ return new Error(`@vgai/live: could not READ the editor session registry while looking for ${projectRoot} — ` +
174
+ `${discoveryFailure}. This is not the answer "no editor is running": the question went ` +
175
+ 'unanswered, so nothing is known about what is live. Retry (a probe can time out while ' +
176
+ 'the box is loaded); if it keeps failing, `vgai sessions` asks the same question directly.');
177
+ }
178
+ if (sessions.length === 0) {
179
+ return new Error(`@vgai/live: the editor session registry is readable and lists NO live sessions, so none ` +
180
+ `covers ${projectRoot}. @vgai/live only attaches to an already-running session — it ` +
181
+ 'never starts one — so run `vgai edit` in that project first, then retry.');
182
+ }
183
+ const listed = sessions
184
+ .map((s) => ` port ${s.port} → ${s.project === null ? '(no project)' : s.project}`)
185
+ .join('\n');
186
+ return new Error(`@vgai/live: ${sessions.length} live editor session(s) are running, but none of them opens ` +
187
+ `${projectRoot}. @vgai/live never silently attaches to a different project.\n` +
188
+ ` looking for (resolved): ${canon}\n` +
189
+ ` live sessions:\n${listed}\n` +
190
+ ' If one of those is meant to be this project, the two paths differ after resolution — ' +
191
+ 'the usual cause is a git worktree or a symlink, where the session was opened through a ' +
192
+ 'different path to the same files. Run `vgai edit` from THIS path, or use the path the ' +
193
+ 'session lists.');
194
+ }
157
195
  export async function resolveSession(projectDir = process.cwd(), deps = {}) {
158
196
  const findRoot = deps.findProjectRootFrom ?? findProjectRootFrom;
159
197
  const transport = deps.transport ?? new HttpEditorTransport();
@@ -187,24 +225,23 @@ export async function resolveSession(projectDir = process.cwd(), deps = {}) {
187
225
  }
188
226
  }
189
227
  let sessions;
228
+ // A FAILED discovery is not an empty one. Collapsing the two into `[]` is
229
+ // what made this door answer "no live editor session found" — and prescribe
230
+ // `vgai edit` — for a probe that merely timed out under load, sending the
231
+ // operator to start an editor that was already running while the real defect
232
+ // went unnamed. The same collapse the manifest refusal above was added for.
233
+ let discoveryFailure = null;
190
234
  try {
191
235
  sessions = await withTimeout(transport.listSessions(EDITOR_SESSION_DISCOVERY_TIMEOUT_MS), EDITOR_SESSION_DISCOVERY_TIMEOUT_MS, 'editor session discovery');
192
236
  }
193
- catch {
237
+ catch (err) {
194
238
  sessions = [];
239
+ discoveryFailure = err instanceof Error ? err.message : String(err);
195
240
  }
196
241
  const canon = canonicalPath(projectRoot);
197
242
  const match = sessions.find((s) => s.project !== null && canonicalPath(s.project) === canon);
198
- if (!match) {
199
- const otherCount = sessions.length;
200
- throw new Error(`@vgai/live: no live editor session found for ${projectRoot}. @vgai/live only attaches to ` +
201
- 'an already-running session — it never starts one — so run `vgai edit` in that project ' +
202
- 'first, then retry.' +
203
- (otherCount > 0
204
- ? ` (${otherCount} other live session(s) found, but none open this project — @vgai/live ` +
205
- 'never silently attaches to a different project.)'
206
- : ''));
207
- }
243
+ if (!match)
244
+ throw noMatchingSessionRefusal(projectRoot, canon, sessions, discoveryFailure);
208
245
  if (match.manifestError != null)
209
246
  throw manifestRefusal(projectRoot, match.manifestError);
210
247
  return { port: match.port, projectRoot };
@@ -12,7 +12,7 @@ export declare class LiveTools {
12
12
  *
13
13
  * `instance` names WHICH mounted instance the tool should drive when several
14
14
  * are live (multiplayer authoring) — it reaches the tool as `ctx.instance`,
15
- * and a tool that drives the game (e.g. `project.autoplay`) binds
15
+ * and a tool that drives the game binds
16
16
  * `game.instance(ctx.instance)` from it. Omitted is the single-instance case;
17
17
  * the tool then targets the sole live instance, exactly as before.
18
18
  */
@@ -19,7 +19,7 @@ export class LiveTools {
19
19
  *
20
20
  * `instance` names WHICH mounted instance the tool should drive when several
21
21
  * are live (multiplayer authoring) — it reaches the tool as `ctx.instance`,
22
- * and a tool that drives the game (e.g. `project.autoplay`) binds
22
+ * and a tool that drives the game binds
23
23
  * `game.instance(ctx.instance)` from it. Omitted is the single-instance case;
24
24
  * the tool then targets the sole live instance, exactly as before.
25
25
  */
@@ -0,0 +1,81 @@
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
+ /** One registry entry, exactly as the server's write half records it. */
17
+ export interface EditorSessionEntry {
18
+ /** Canonical project root currently open, or null when none. */
19
+ project: string | null;
20
+ port: number;
21
+ /** PID of the dev-server process. */
22
+ pid: number;
23
+ /** ISO timestamp of server start. */
24
+ startedAt: string;
25
+ sessionId: string | null;
26
+ /** Private loopback control credential — never returned by an editor HTTP
27
+ * route; exists only in this user-private registry. */
28
+ controlSecret?: string | null;
29
+ repositoryId: string | null;
30
+ worktreeId: string | null;
31
+ worktreeRoot: string | null;
32
+ projectRelativePath: string | null;
33
+ branch: string | null;
34
+ headCommit: string | null;
35
+ baseCommit: string | null;
36
+ }
37
+ export declare const EDITOR_SESSIONS_REGISTRY_FILE: string;
38
+ export declare function isEditorSessionEntry(v: unknown): v is EditorSessionEntry;
39
+ export declare function normalizeEditorSessionEntry(session: EditorSessionEntry): EditorSessionEntry;
40
+ export declare function pidAlive(pid: number): boolean;
41
+ /** The registry's live entries — shape-validated and PID-liveness-filtered. */
42
+ export declare function readLiveRegisteredSessions(): EditorSessionEntry[];
43
+ /**
44
+ * WHICH PROJECT a `/__editor/project` body says its server is serving.
45
+ *
46
+ * `serving` is that server's own statement of "I AM serving this project, I
47
+ * just cannot describe it" (its manifest is unparseable or fails strict
48
+ * validation) — added to the route precisely because a bare
49
+ * `{ project: null }` is indistinguishable from "no project open". Reading
50
+ * only `project.path` collapses the two, and the cost is that every
51
+ * project-matched command loses a live session the moment a save breaks its
52
+ * manifest, reporting it as belonging to no project rather than as this
53
+ * project's degraded session.
54
+ */
55
+ export declare function servedProjectAnswer(body: unknown): {
56
+ path: string | null;
57
+ manifestError: string | null;
58
+ };
59
+ /**
60
+ * Deterministic session selection — the ONE precedence order every surface
61
+ * follows: explicit `ctx.editorUrl` probe > port match against the listing >
62
+ * `ctx.projectRoot` match > lowest live port > not-running. Generic over the
63
+ * session shape so the editor and play transports (whose probe results
64
+ * differ) share the algorithm rather than a copy of it.
65
+ */
66
+ export declare function resolveRegisteredSession<T extends {
67
+ port: number;
68
+ project: string | null;
69
+ url?: string;
70
+ }>(ctx: {
71
+ editorUrl?: string | undefined;
72
+ projectRoot?: string | undefined;
73
+ }, transport: {
74
+ listSessions(timeoutMs: number): Promise<T[]>;
75
+ probeSessionUrl?: ((url: string, timeoutMs: number) => Promise<T | null | undefined>) | undefined;
76
+ }, options: {
77
+ probeTimeoutMs: number;
78
+ discoveryTimeoutMs: number;
79
+ withTimeout: <V>(work: Promise<V>, timeoutMs: number, what: string) => Promise<V>;
80
+ notRunning: () => never;
81
+ }): Promise<T>;
@@ -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.22",
5
+ "version": "0.5.24",
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.22",
36
- "@vgai/sdk": "0.5.22"
35
+ "@vgai/editor-sdk": "0.5.24",
36
+ "@vgai/sdk": "0.5.24"
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
@@ -75,6 +75,19 @@ 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
+ * THE MODULE LANE — run a step INSIDE the editor page against
80
+ * `{ page, modules, instanceId }`, where `modules(path)` imports the
81
+ * RUNNING mount's own instance of a project module (never a phantom
82
+ * second copy). Same serialization contract as `runPageScript`: the
83
+ * step's source travels as text, closures do not survive, and the return
84
+ * value must be plain data. `instance` scopes multi-instance sessions.
85
+ */
86
+ runGameScript(
87
+ src: string,
88
+ step: (scope: unknown) => unknown,
89
+ instance?: string,
90
+ ): Promise<BridgeCallOutcome>;
78
91
  /**
79
92
  * P20 — reload the document showing the game, resolving only once the page
80
93
  * is BACK and taking commands again.
@@ -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
@@ -158,6 +158,41 @@ export class PageTransport implements BridgeTransport {
158
158
  }
159
159
  }
160
160
 
161
+ /** THE MODULE LANE under a real Playwright host: the step's source is
162
+ * evaluated INSIDE the editor page via the same in-page handler the relay
163
+ * op uses (`window.__vgaiGameEval`), because `modules()` only means
164
+ * anything in the page's own module space — a Node-side call could never
165
+ * hand back the running mount's instances. Same serialization contract
166
+ * as the relay leg. */
167
+ async runGameScript(
168
+ src: string,
169
+ _step: (scope: unknown) => unknown,
170
+ instance?: string,
171
+ ): Promise<BridgeCallOutcome> {
172
+ try {
173
+ const result = await this.page.evaluate(
174
+ async (args: { src: string; instance?: string }) => {
175
+ const hook = (window as unknown as Record<string, unknown>)['__vgaiGameEval'] as
176
+ | ((src: string, instance?: string) => Promise<unknown>)
177
+ | undefined;
178
+ if (typeof hook !== 'function') {
179
+ throw new Error(
180
+ 'game-eval: this page has no __vgaiGameEval hook — is the editor page loaded?',
181
+ );
182
+ }
183
+ return hook(args.src, args.instance);
184
+ },
185
+ { src, ...(instance === undefined ? {} : { instance }) },
186
+ );
187
+ return { ok: true, result };
188
+ } catch (err) {
189
+ return {
190
+ ok: false,
191
+ error: { code: undefined, message: err instanceof Error ? err.message : String(err) },
192
+ };
193
+ }
194
+ }
195
+
161
196
  /** Playwright's own reload already waits for the new document's `load`
162
197
  * event, which is exactly the completion signal this method's contract
163
198
  * asks for — nothing to reconstruct on this leg. */
@@ -528,6 +563,9 @@ export class GameClient {
528
563
  /** Per-test tick-rate samples, fed by every `snapshot()` read (a poll the
529
564
  * client was making anyway — zero extra page.evaluate round trips). */
530
565
  readonly #tps = new TpsAccumulator();
566
+ /** True after the settled run-ticks door proved absent on this page (an older exported
567
+ * game's engine) — see `fastForward`'s `runTicksBatch`. */
568
+ #legacyRunTicksDoor = false;
531
569
  /** Hidden-tab recovery (hollowstone field lesson: the engine hard-stops
532
570
  * while `document.hidden`). Client-lifetime state so `bringToFront()`
533
571
  * fires at most once per test, across ALL waitFor/waitSimTime loops. */
@@ -619,7 +657,29 @@ export class GameClient {
619
657
  // names neither the method nor the shape).
620
658
  assertValidWaitForBudget(budget, 'fastForward');
621
659
  const clock: FastForwardClock = {
622
- runTicksBatch: (n, render) => this.callBridgeVoid('runTicks', n, { render }),
660
+ // The SETTLED door (`runTicksSettled`, an async bridge method): ticks never race a scene
661
+ // remount's async commit, so which tick first runs a freshly reloaded world is
662
+ // deterministic (see engine/runtime/run-ticks-settled.ts — measured: without it, one
663
+ // drive script produced 7 or 8 post-respawn walked ticks depending on wall timing).
664
+ // Falls back ONCE to the plain sync door for a page whose engine predates the method
665
+ // (an older exported game), and remembers the verdict for the rest of the burst.
666
+ runTicksBatch: async (n, render) => {
667
+ if (this.#legacyRunTicksDoor) {
668
+ await this.callBridgeVoid('runTicks', n, { render });
669
+ return;
670
+ }
671
+ try {
672
+ await this.callBridgeAsync<void>('runTicksSettled', n, { render });
673
+ } catch (error) {
674
+ const code = (error as { code?: string }).code;
675
+ const message = error instanceof Error ? error.message : String(error);
676
+ const doorAbsent =
677
+ code === 'UNKNOWN_BRIDGE_METHOD' || /is not a function|undefined/i.test(message);
678
+ if (!doorAbsent) throw error;
679
+ this.#legacyRunTicksDoor = true;
680
+ await this.callBridgeVoid('runTicks', n, { render });
681
+ }
682
+ },
623
683
  readTime: async () => {
624
684
  // Raw bridge read — deliberately NOT `this.snapshot()`, which would
625
685
  // feed the TpsAccumulator (see fast-forward.ts's module doc, point 2).
@@ -651,7 +711,7 @@ export class GameClient {
651
711
  snapshot: () => this.snapshot(),
652
712
  now: () => Date.now(),
653
713
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
654
- // Fixture heartbeat (Wave-6 findings ledger) — real stdout, the
714
+ // Fixture heartbeat — real stdout, the
655
715
  // same channel a caller watching stdout already treats as
656
716
  // liveness (see wait-for.ts's module doc for the emission
657
717
  // invariant `maybeHeartbeat` enforces).
@@ -688,7 +748,7 @@ export class GameClient {
688
748
  const start = await this.snapshot();
689
749
  let lastTick: number | null = null;
690
750
  let stalledPolls = 0;
691
- // Fixture heartbeat (Wave-6 findings ledger) — same invariant as
751
+ // Fixture heartbeat — same invariant as
692
752
  // `wait-for.ts`'s `runWaitFor`: a heartbeat requires BOTH 60s of wall
693
753
  // silence AND the tick having advanced since the last one emitted, so a
694
754
  // genuinely stalled sim clock (caught by `stalledPolls` above, ~30s)
@@ -770,10 +830,8 @@ export class GameClient {
770
830
  * `events.expect`'s failure path each shoot a frame on their own
771
831
  * (`toSessionFailure`, `GameEvents.expect`), and a route's `whileHeld`
772
832
  * 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).
833
+ * only place that sees ALL of them is here, which is why a driving tool
834
+ * stamps its captures by listening rather than by intercepting.
777
835
  */
778
836
  onCapture(listener: CaptureListener): () => void {
779
837
  this.#captureListeners.add(listener);
@@ -783,7 +841,7 @@ export class GameClient {
783
841
  }
784
842
 
785
843
  /**
786
- * Wave-2 "one dialect, full capability" — runs a UI-automation step
844
+ * One dialect, full capability — runs a UI-automation step
787
845
  * written as a literal Playwright `async (page) => {...}` (interface
788
846
  * doctrine §3.2/§4 rung 4: "the AI should think it is basically just
789
847
  * executing Playwright"). Under `PageTransport` this drives the REAL
@@ -804,6 +862,37 @@ export class GameClient {
804
862
  return this.unwrap<T>(outcome);
805
863
  }
806
864
 
865
+ /**
866
+ * THE MODULE LANE — run literal JS INSIDE the game's page, with the
867
+ * running mount's modules in reach:
868
+ *
869
+ * ```js
870
+ * await game.run(async ({ modules }) => {
871
+ * const { simHost } = await modules('src/sim/host.ts');
872
+ * return simHost().state.day;
873
+ * })
874
+ * ```
875
+ *
876
+ * `scope` is `{ page, modules, instanceId }`. Serialization contract as
877
+ * `game.page()`: the step travels as source (no closures), and the return
878
+ * value must be plain data. `modules(path)` resolves through the ACTIVE
879
+ * mount's own url space, so what you touch IS the running game — never a
880
+ * phantom second copy. Dev-server sessions only; a shipped build's curated
881
+ * surface is its adapter exports.
882
+ */
883
+ async run<T = unknown>(
884
+ step: (scope: {
885
+ page: Page;
886
+ modules: (path: string) => Promise<Record<string, unknown>>;
887
+ instanceId: string;
888
+ }) => T | Promise<T>,
889
+ opts?: { instance?: string },
890
+ ): Promise<T> {
891
+ const erased = (arg: unknown) => step(arg as Parameters<typeof step>[0]);
892
+ const outcome = await this.#transport.runGameScript(step.toString(), erased, opts?.instance);
893
+ return this.unwrap<T>(outcome);
894
+ }
895
+
807
896
  /**
808
897
  * Reload the document showing the game, resolving only once it is back and
809
898
  * answering commands (see `bridge-transport.ts`'s `reloadPage`).