@vgai/sdk 0.4.0-canary.20260715.0

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 (51) hide show
  1. package/package.json +27 -0
  2. package/src/cinematic/capabilities-operations.ts +128 -0
  3. package/src/cinematic/cue-operations.ts +198 -0
  4. package/src/cinematic/gsap-operations.ts +126 -0
  5. package/src/cinematic/index.ts +59 -0
  6. package/src/cinematic/preview-operations.ts +279 -0
  7. package/src/cinematic/preview-transport.ts +244 -0
  8. package/src/cinematic/render-operations.ts +409 -0
  9. package/src/cinematic/render-transport.ts +238 -0
  10. package/src/cinematic/theatre-operations.ts +306 -0
  11. package/src/editor/camera-operations.ts +169 -0
  12. package/src/editor/console-operations.ts +87 -0
  13. package/src/editor/hierarchy-operations.ts +95 -0
  14. package/src/editor/index.ts +62 -0
  15. package/src/editor/open-operations.ts +209 -0
  16. package/src/editor/screenshot-operations.ts +99 -0
  17. package/src/editor/selection-operations.ts +144 -0
  18. package/src/editor/session-operations.ts +73 -0
  19. package/src/editor/source-location-operations.ts +106 -0
  20. package/src/editor/transport.ts +647 -0
  21. package/src/errors.ts +72 -0
  22. package/src/http/http-projection.ts +349 -0
  23. package/src/http/index.ts +11 -0
  24. package/src/index.ts +67 -0
  25. package/src/mcp/index.ts +16 -0
  26. package/src/mcp/mcp-projection.ts +288 -0
  27. package/src/operations.ts +83 -0
  28. package/src/play/control-operations.ts +205 -0
  29. package/src/play/debug-command-operations.ts +245 -0
  30. package/src/play/index.ts +66 -0
  31. package/src/play/input-operations.ts +316 -0
  32. package/src/play/lifecycle-operations.ts +271 -0
  33. package/src/play/log-operations.ts +279 -0
  34. package/src/play/run-ticks-operations.ts +141 -0
  35. package/src/play/state-operations.ts +210 -0
  36. package/src/play/status-operations.ts +160 -0
  37. package/src/play/transport.ts +728 -0
  38. package/src/project/asset-operations.ts +243 -0
  39. package/src/project/component-operations.ts +337 -0
  40. package/src/project/discovery-operations.ts +269 -0
  41. package/src/project/entity-operations.ts +366 -0
  42. package/src/project/index.ts +55 -0
  43. package/src/project/input-map-operations.ts +233 -0
  44. package/src/project/manifest-operations.ts +355 -0
  45. package/src/project/scene-operations.ts +426 -0
  46. package/src/project/shared.ts +299 -0
  47. package/src/registry.ts +285 -0
  48. package/src/render/capabilities/ffmpeg.ts +141 -0
  49. package/src/render/index.ts +15 -0
  50. package/src/render/render-cinematic.ts +1847 -0
  51. package/src/types.ts +101 -0
@@ -0,0 +1,647 @@
1
+ /**
2
+ * Transport seam for B3's `editor.*` operations
3
+ * (docs/AI-NATIVE-AUTHORING-IMPLEMENTATION-SPEC.md §8 B3).
4
+ *
5
+ * Every `editor.*` op needs two things this module provides:
6
+ *
7
+ * 1. DETERMINISTIC SESSION SELECTION (`resolveEditorSession`) — when more
8
+ * than one `vgai edit` dev server is running, which one does an op
9
+ * without an explicit target mean? The rule (documented on
10
+ * `resolveEditorSession` below) mirrors the CLI's own existing
11
+ * precedent (`packages/vgai-cli/src/index.ts`'s `getClient()`: explicit
12
+ * override > cwd/project match > default) and adds an explicit,
13
+ * documented tie-break for the *multi-session, no-match* case: lowest
14
+ * port wins.
15
+ *
16
+ * 2. A NAMED-TIMEOUT, NEVER-HANG TRANSPORT (`EditorTransport` +
17
+ * `HttpEditorTransport`) — every network call this module makes is
18
+ * wrapped in `withTimeout`, so a dead/unreachable/slow editor process
19
+ * resolves to a structured failure within a bounded time instead of
20
+ * hanging `dispatch()` forever (§8 B3 AC: "Operations fail with
21
+ * EDITOR_NOT_RUNNING rather than hanging").
22
+ *
23
+ * `EditorTransport` is INJECTABLE via `ctx['editorTransport']` (a plain
24
+ * bag key on `OperationContext`'s open `[key: string]: unknown` — no change
25
+ * to `types.ts` needed) so tests can swap in a mock/fake transport and
26
+ * exercise every op headlessly, with no real browser or dev server
27
+ * (see `../../test/editor-operations.test.ts`).
28
+ *
29
+ * REAL TRANSPORT SURFACE — what `HttpEditorTransport` actually talks to,
30
+ * and the honest limits of today's wire protocol:
31
+ *
32
+ * - Session discovery reads `~/.vgai/editor-sessions.json` (the file-format
33
+ * CONTRACT documented at `packages/editor/server/session-registry.ts` —
34
+ * "the CLI has no editor-package dependency ... the FILE FORMAT is the
35
+ * contract"; this module is the vgai-sdk-side analogue of the CLI's own
36
+ * light reader, `packages/vgai-cli/src/editor-sessions.ts`), then
37
+ * PID-liveness-filters and probes each survivor with `GET
38
+ * /__editor/project` (always answered by a running dev server, whether
39
+ * or not a browser tab is attached — see `editor-server.ts`).
40
+ * - Selection read: `GET /__editor/state` — real, works end to end
41
+ * (`EditorState.selectedEntityId`/`selectedEntityIds`, always populated).
42
+ * - Selection set / scene open / asset open / camera set (via
43
+ * focus-entity/focus-selection/view-preset/set-camera): `POST
44
+ * /__editor/command` — the existing SSE command relay (`editor-server.ts`);
45
+ * these command `type`s are real, already-handled cases in the browser's
46
+ * `command-listener.ts` `handleCommand` switch, so they are genuinely
47
+ * wired end to end. Every command relayed through this path that the
48
+ * switch does NOT recognize now fails loudly (`{ok:false}`) instead of
49
+ * silently acking (B3-followup's false-ack fix — see command-listener.ts)
50
+ * — this module never has to work around that fallthrough by avoiding a
51
+ * command send the way `openStory` below still does.
52
+ * - Hierarchy inspect / viewport camera read (B3-followup, now genuinely
53
+ * wired): `collectState` (`packages/editor/src/command-listener.ts`) now
54
+ * reports the real flattened entity tree (`entities`, reusing
55
+ * `EditorStore.entities` — the same nested `SceneEntity[]` data model
56
+ * `GameHierarchy` renders from) and the real live viewport camera pose
57
+ * (`camera`, off `EditorStore.cameraPose` — the bound Three.js camera +
58
+ * orbit target `ViewportPanel.tsx` binds via `bindScene`/`setOrbitTarget`)
59
+ * alongside the pre-existing `entityCount`/`selectedEntityId` fields.
60
+ * `HttpEditorTransport.getHierarchy`/`getCamera` read them off `GET
61
+ * /__editor/state`. Both fields are absent only in the narrow window
62
+ * before any browser tab has POSTed state at least once (a session IS
63
+ * connected — a timing gap, not a fabricated tree/pose); the operations'
64
+ * declared error codes (`HIERARCHY_UNAVAILABLE` / `CAMERA_STATE_UNAVAILABLE`)
65
+ * cover exactly that window, distinct from `EDITOR_NOT_RUNNING`.
66
+ * - Viewport camera SET now also supports an arbitrary pose (not just the
67
+ * three original relay verbs): `{mode:'pose', position, target, fov?}`
68
+ * sends `{type:'set-camera', ...}`, a real, handled case that calls
69
+ * `EditorViewport.setPose` (moves camera position + orbit target, updates
70
+ * fov/projection matrix when given).
71
+ * - Source-location lookup: `GET /__ui-source/index` — the OID -> {file,
72
+ * line, col, component, tag} map built by `vite-plugin-ui-oid.ts`'s
73
+ * `OidStore` (`packages/editor/src/ui-source/oid-transform.ts`). This
74
+ * endpoint is PURELY a GET of an in-memory index; it never calls any of
75
+ * that plugin's write endpoints (`/__ui-source/write|css|text|prop|
76
+ * struct|struct-many|restore`), so it structurally cannot rewrite JSX.
77
+ * - Viewport screenshot (B3-followup, now genuinely wired for the common
78
+ * case): `getScreenshot` first tries `POST /__editor/command`
79
+ * `{type:'capture-viewport'}` — a real, handled case that renders the
80
+ * LIVE viewport on demand (`EditorStore.captureViewportImage`, the same
81
+ * offscreen-render-target technique the periodic autosave thumbnail
82
+ * already used) and returns PNG bytes through the command-result payload
83
+ * (`commandResponseFor`'s `data` passthrough, `server-utils.ts`). Falls
84
+ * back to `GET /__editor/project-thumbnail?path=<project>` — the
85
+ * LAST-SAVED thumbnail (written via `POST /__editor/save-thumbnail`,
86
+ * which the editor UI also calls on its own cadence) — only when no
87
+ * browser tab can answer the relay, or the viewport hasn't bound a
88
+ * renderer/scene/camera yet. The result schema's `fresh` field names
89
+ * which path answered.
90
+ * - Console/log subscription metadata: describes the real, existing SSE
91
+ * endpoint (`GET /__editor/events`) + log endpoints
92
+ * (`POST /__editor/log-session`, `GET/POST /__editor/log-entries`) and
93
+ * reports the CURRENTLY buffered play-mode log-entry count — metadata
94
+ * about how to subscribe, not a live streaming call (a request/response
95
+ * SDK op cannot itself be a stream).
96
+ * - Story open remains an HONEST GAP: the editor has no Storybook/CSF
97
+ * story-preview surface at all today — there is no browser-side command
98
+ * handler to add one FOR (`command-listener.ts`'s `handleCommand` switch
99
+ * has no such case, and there is no story runner anywhere in
100
+ * `packages/editor/src` to wire one to). `HttpEditorTransport.openStory`
101
+ * therefore never sends a command; it reports the gap as a declared
102
+ * `STORY_OPEN_UNSUPPORTED` error instead of a fabricated success. (Had it
103
+ * sent an unrecognized command anyway, the B3-followup false-ack fix means
104
+ * it would now fail loudly rather than silently ack — but this path is
105
+ * deliberately never exercised in the first place.)
106
+ */
107
+
108
+ import { existsSync, readFileSync } from 'node:fs';
109
+ import { homedir } from 'node:os';
110
+ import { join, resolve } from 'node:path';
111
+ import { z } from 'zod';
112
+ import { OperationError } from '../errors.js';
113
+ import type { ErrorDefinition } from '../registry.js';
114
+ import type { OperationContext } from '../types.js';
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Named timeouts (§8 B3 AC: "enforce a named timeout, don't block forever")
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /** Bound on discovering + probe-verifying live editor sessions. */
121
+ export const EDITOR_SESSION_DISCOVERY_TIMEOUT_MS = 2000;
122
+ /** Bound on a single per-session liveness probe (GET /__editor/project). */
123
+ export const EDITOR_PROBE_TIMEOUT_MS = 1500;
124
+ /** Bound on a read (GET /__editor/state, /__ui-source/index, thumbnail, log-entries). */
125
+ export const EDITOR_READ_TIMEOUT_MS = 3000;
126
+ /**
127
+ * Bound on a relayed command (POST /__editor/command). Mirrors the real
128
+ * relay's own `COMMAND_TIMEOUT_MS` (`packages/editor/server/editor-server.ts`)
129
+ * — an editor IS connected but did not answer in time.
130
+ */
131
+ export const EDITOR_COMMAND_TIMEOUT_MS = 5000;
132
+
133
+ /** Thrown by `withTimeout` when the wrapped promise doesn't settle in time. */
134
+ export class EditorTimeoutError extends Error {
135
+ constructor(label: string, ms: number) {
136
+ super(`${label} timed out after ${ms}ms`);
137
+ this.name = 'EditorTimeoutError';
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Race `promise` against a `ms`-bounded timer. Guarantees THIS function
143
+ * returns/rejects within `ms` regardless of what `promise` (real fetch, or a
144
+ * test's deliberately-never-resolving mock transport) actually does — the
145
+ * mechanism that makes "no hang" true for every `editor.*` op, not just the
146
+ * ones talking to a well-behaved transport.
147
+ */
148
+ export function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
149
+ return new Promise<T>((resolvePromise, reject) => {
150
+ const timer = setTimeout(() => reject(new EditorTimeoutError(label, ms)), ms);
151
+ promise.then(
152
+ (v) => {
153
+ clearTimeout(timer);
154
+ resolvePromise(v);
155
+ },
156
+ (err) => {
157
+ clearTimeout(timer);
158
+ reject(err instanceof Error ? err : new Error(String(err)));
159
+ },
160
+ );
161
+ });
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Shared shapes
166
+ // ---------------------------------------------------------------------------
167
+
168
+ export interface EditorSessionInfo {
169
+ /** The port this session's dev server is bound to. */
170
+ port: number;
171
+ /** Canonical project root path currently open, or null when none. */
172
+ project: string | null;
173
+ /** Dev-server process id, when known from the local session registry (null for an unregistered/legacy server the caller only probed by port). */
174
+ pid: number | null;
175
+ }
176
+
177
+ export interface EditorCommandResult {
178
+ ok: boolean;
179
+ error?: string;
180
+ /** Optional payload for commands that answer with data (e.g. `capture-viewport`'s base64 PNG). */
181
+ data?: Record<string, unknown>;
182
+ }
183
+
184
+ export interface OidSourceEntry {
185
+ file: string;
186
+ line: number;
187
+ col: number;
188
+ component: string | null;
189
+ tag: string;
190
+ }
191
+
192
+ export interface HierarchyNodeInfo {
193
+ id: string;
194
+ name: string;
195
+ childIds: string[];
196
+ }
197
+
198
+ export interface HierarchySummary {
199
+ savePath: string | null;
200
+ entityCount: number;
201
+ /** Full node list, or null when the connected editor's wire protocol doesn't carry a tree (see module jsdoc). */
202
+ entities: HierarchyNodeInfo[] | null;
203
+ }
204
+
205
+ export interface CameraPose {
206
+ position: { x: number; y: number; z: number };
207
+ target: { x: number; y: number; z: number };
208
+ fov: number;
209
+ }
210
+
211
+ export type CameraSetRequest =
212
+ | { mode: 'focusEntity'; entityId: string }
213
+ | { mode: 'focusSelection' }
214
+ | { mode: 'viewPreset'; preset: 'top' | 'front' | 'right' | 'perspective' }
215
+ | {
216
+ mode: 'pose';
217
+ position: { x: number; y: number; z: number };
218
+ target: { x: number; y: number; z: number };
219
+ fov?: number | undefined;
220
+ };
221
+
222
+ export interface ScreenshotResult {
223
+ base64: string;
224
+ mimeType: string;
225
+ /** True when this came from an on-demand `capture-viewport` render just now; false for the last-saved thumbnail fallback. */
226
+ fresh: boolean;
227
+ }
228
+
229
+ export interface ConsoleSubscriptionMetadata {
230
+ sseUrl: string;
231
+ events: string[];
232
+ logEntriesUrl: string;
233
+ logSessionUrl: string;
234
+ bufferedLogCount: number;
235
+ }
236
+
237
+ /**
238
+ * The transport seam every `editor.*` op's `impl` goes through. Real
239
+ * production dispatch uses `HttpEditorTransport` (the default, lazily
240
+ * constructed); tests inject a fake via `ctx['editorTransport']` — see
241
+ * `getTransport` below.
242
+ */
243
+ export interface EditorTransport {
244
+ listSessions(timeoutMs: number): Promise<EditorSessionInfo[]>;
245
+ getSelection(
246
+ session: EditorSessionInfo,
247
+ timeoutMs: number,
248
+ ): Promise<{ selectedEntityId: string | null; selectedEntityIds: string[] } | undefined>;
249
+ getHierarchy(
250
+ session: EditorSessionInfo,
251
+ timeoutMs: number,
252
+ ): Promise<HierarchySummary | undefined>;
253
+ getCamera(session: EditorSessionInfo, timeoutMs: number): Promise<CameraPose | undefined>;
254
+ setCamera(
255
+ session: EditorSessionInfo,
256
+ request: CameraSetRequest,
257
+ timeoutMs: number,
258
+ ): Promise<EditorCommandResult>;
259
+ /** The whole live OID -> source-location index, or undefined if unreachable. */
260
+ getSourceIndex(
261
+ session: EditorSessionInfo,
262
+ timeoutMs: number,
263
+ ): Promise<Record<string, OidSourceEntry> | undefined>;
264
+ getScreenshot(
265
+ session: EditorSessionInfo,
266
+ timeoutMs: number,
267
+ ): Promise<ScreenshotResult | undefined>;
268
+ getConsoleSubscriptionMetadata(
269
+ session: EditorSessionInfo,
270
+ timeoutMs: number,
271
+ ): Promise<ConsoleSubscriptionMetadata | undefined>;
272
+ sendCommand(
273
+ session: EditorSessionInfo,
274
+ command: Record<string, unknown>,
275
+ timeoutMs: number,
276
+ ): Promise<EditorCommandResult>;
277
+ /** Undefined = the live editor has no wire support for opening a story today (see module jsdoc). */
278
+ openStory(
279
+ session: EditorSessionInfo,
280
+ path: string,
281
+ timeoutMs: number,
282
+ ): Promise<EditorCommandResult | undefined>;
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Real transport
287
+ // ---------------------------------------------------------------------------
288
+
289
+ interface RegistrySessionEntry {
290
+ project: string | null;
291
+ port: number;
292
+ pid: number;
293
+ startedAt: string;
294
+ }
295
+
296
+ function isRegistrySessionEntry(v: unknown): v is RegistrySessionEntry {
297
+ if (typeof v !== 'object' || v === null) return false;
298
+ const s = v as Record<string, unknown>;
299
+ return (
300
+ (typeof s['project'] === 'string' || s['project'] === null) &&
301
+ typeof s['port'] === 'number' &&
302
+ typeof s['pid'] === 'number' &&
303
+ typeof s['startedAt'] === 'string'
304
+ );
305
+ }
306
+
307
+ function pidAlive(pid: number): boolean {
308
+ try {
309
+ process.kill(pid, 0);
310
+ return true;
311
+ } catch {
312
+ return false;
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Read `~/.vgai/editor-sessions.json` (the format contract owned by
318
+ * `packages/editor/server/session-registry.ts`), PID-liveness-filtered.
319
+ * Deliberate light duplicate of the read half — see module jsdoc.
320
+ */
321
+ function readRegisteredSessions(): RegistrySessionEntry[] {
322
+ const registryFile = join(homedir(), '.vgai', 'editor-sessions.json');
323
+ if (!existsSync(registryFile)) return [];
324
+ try {
325
+ const raw: unknown = JSON.parse(readFileSync(registryFile, 'utf8'));
326
+ return Array.isArray(raw)
327
+ ? raw.filter(isRegistrySessionEntry).filter((s) => pidAlive(s.pid))
328
+ : [];
329
+ } catch {
330
+ return [];
331
+ }
332
+ }
333
+
334
+ async function fetchJson(url: string, timeoutMs: number): Promise<unknown | undefined> {
335
+ try {
336
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
337
+ if (!res.ok) return undefined;
338
+ return await res.json();
339
+ } catch {
340
+ return undefined;
341
+ }
342
+ }
343
+
344
+ async function postJson(
345
+ url: string,
346
+ body: unknown,
347
+ timeoutMs: number,
348
+ ): Promise<{ status: number; json: unknown } | undefined> {
349
+ try {
350
+ const res = await fetch(url, {
351
+ method: 'POST',
352
+ headers: { 'Content-Type': 'application/json' },
353
+ body: JSON.stringify(body),
354
+ signal: AbortSignal.timeout(timeoutMs),
355
+ });
356
+ const json = await res.json().catch(() => undefined);
357
+ return { status: res.status, json };
358
+ } catch {
359
+ return undefined;
360
+ }
361
+ }
362
+
363
+ function baseUrl(session: EditorSessionInfo): string {
364
+ return `http://localhost:${session.port}`;
365
+ }
366
+
367
+ /** The real, production transport — HTTP against a live `vgai edit` dev server. */
368
+ export class HttpEditorTransport implements EditorTransport {
369
+ async listSessions(timeoutMs: number): Promise<EditorSessionInfo[]> {
370
+ const registered = readRegisteredSessions();
371
+ const perProbeTimeout = Math.min(EDITOR_PROBE_TIMEOUT_MS, Math.max(200, timeoutMs));
372
+ const probes = await Promise.all(
373
+ registered.map(async (s) => {
374
+ const body = await fetchJson(
375
+ `${baseUrl({ port: s.port, project: null, pid: s.pid })}/__editor/project`,
376
+ perProbeTimeout,
377
+ );
378
+ if (body === undefined) return undefined;
379
+ const project = (body as { project?: { path?: string } | null }).project?.path ?? null;
380
+ const info: EditorSessionInfo = { port: s.port, project, pid: s.pid };
381
+ return info;
382
+ }),
383
+ );
384
+ return probes.filter((p): p is EditorSessionInfo => p !== undefined);
385
+ }
386
+
387
+ async getSelection(
388
+ session: EditorSessionInfo,
389
+ timeoutMs: number,
390
+ ): Promise<{ selectedEntityId: string | null; selectedEntityIds: string[] } | undefined> {
391
+ const body = (await fetchJson(`${baseUrl(session)}/__editor/state`, timeoutMs)) as
392
+ | { selectedEntityId?: string | null; selectedEntityIds?: string[] }
393
+ | undefined;
394
+ if (!body) return undefined;
395
+ return {
396
+ selectedEntityId: body.selectedEntityId ?? null,
397
+ selectedEntityIds: body.selectedEntityIds ?? [],
398
+ };
399
+ }
400
+
401
+ async getHierarchy(
402
+ session: EditorSessionInfo,
403
+ timeoutMs: number,
404
+ ): Promise<HierarchySummary | undefined> {
405
+ const body = (await fetchJson(`${baseUrl(session)}/__editor/state`, timeoutMs)) as
406
+ | { savePath?: string | null; entityCount?: number; entities?: HierarchyNodeInfo[] }
407
+ | undefined;
408
+ if (!body) return undefined;
409
+ // `collectState` (packages/editor/src/command-listener.ts) now reports the
410
+ // real flattened entity tree alongside entityCount — `entities` is real
411
+ // data whenever a browser tab has POSTed state at least once, `null` only
412
+ // if none ever has (e.g. a session probed before its first command/connect
413
+ // report, which the state route's initial `editorState = {}` also implies).
414
+ return {
415
+ savePath: body.savePath ?? null,
416
+ entityCount: body.entityCount ?? 0,
417
+ entities: body.entities ?? null,
418
+ };
419
+ }
420
+
421
+ async getCamera(session: EditorSessionInfo, timeoutMs: number): Promise<CameraPose | undefined> {
422
+ const body = (await fetchJson(`${baseUrl(session)}/__editor/state`, timeoutMs)) as
423
+ | { camera?: CameraPose }
424
+ | undefined;
425
+ // `collectState` now reports the live viewport camera pose (position,
426
+ // orbit target, fov) whenever `ViewportPanel.tsx` has bound one — `camera`
427
+ // is absent only pre-mount / in a headless store (see EditorStore.cameraPose).
428
+ return body?.camera;
429
+ }
430
+
431
+ async setCamera(
432
+ session: EditorSessionInfo,
433
+ request: CameraSetRequest,
434
+ timeoutMs: number,
435
+ ): Promise<EditorCommandResult> {
436
+ const command =
437
+ request.mode === 'focusEntity'
438
+ ? { type: 'focus-entity', id: request.entityId }
439
+ : request.mode === 'focusSelection'
440
+ ? { type: 'focus-selection' }
441
+ : request.mode === 'viewPreset'
442
+ ? { type: 'view-preset', preset: request.preset }
443
+ : {
444
+ type: 'set-camera',
445
+ position: request.position,
446
+ target: request.target,
447
+ ...(request.fov !== undefined ? { fov: request.fov } : {}),
448
+ };
449
+ return this.sendCommand(session, command, timeoutMs);
450
+ }
451
+
452
+ async getSourceIndex(
453
+ session: EditorSessionInfo,
454
+ timeoutMs: number,
455
+ ): Promise<Record<string, OidSourceEntry> | undefined> {
456
+ const body = await fetchJson(`${baseUrl(session)}/__ui-source/index`, timeoutMs);
457
+ if (!body || typeof body !== 'object') return undefined;
458
+ return body as Record<string, OidSourceEntry>;
459
+ }
460
+
461
+ async getScreenshot(
462
+ session: EditorSessionInfo,
463
+ timeoutMs: number,
464
+ ): Promise<ScreenshotResult | undefined> {
465
+ // Try a fresh, on-demand capture first (`capture-viewport` — a real,
466
+ // already-handled case in command-listener.ts's handleCommand switch as
467
+ // of B3-followup). Falls back to the last-saved thumbnail snapshot below
468
+ // when no browser tab is attached to answer the relay, or the viewport
469
+ // hasn't bound a renderer/scene/camera yet (e.g. right after page load).
470
+ const fresh = await this.sendCommand(session, { type: 'capture-viewport' }, timeoutMs).catch(
471
+ () => undefined,
472
+ );
473
+ if (fresh?.ok && fresh.data) {
474
+ const base64 = fresh.data['base64'];
475
+ const mimeType = fresh.data['mimeType'];
476
+ if (typeof base64 === 'string' && typeof mimeType === 'string') {
477
+ return { base64, mimeType, fresh: true };
478
+ }
479
+ }
480
+ return this.getSavedThumbnail(session, timeoutMs);
481
+ }
482
+
483
+ /** The LAST-SAVED thumbnail PNG (fallback path — see `getScreenshot`). */
484
+ private async getSavedThumbnail(
485
+ session: EditorSessionInfo,
486
+ timeoutMs: number,
487
+ ): Promise<ScreenshotResult | undefined> {
488
+ if (!session.project) return undefined;
489
+ try {
490
+ const res = await fetch(
491
+ `${baseUrl(session)}/__editor/project-thumbnail?path=${encodeURIComponent(session.project)}`,
492
+ { signal: AbortSignal.timeout(timeoutMs) },
493
+ );
494
+ if (!res.ok) return undefined;
495
+ const buf = new Uint8Array(await res.arrayBuffer());
496
+ let binary = '';
497
+ for (const byte of buf) binary += String.fromCharCode(byte);
498
+ return { base64: btoa(binary), mimeType: 'image/png', fresh: false };
499
+ } catch {
500
+ return undefined;
501
+ }
502
+ }
503
+
504
+ async getConsoleSubscriptionMetadata(
505
+ session: EditorSessionInfo,
506
+ timeoutMs: number,
507
+ ): Promise<ConsoleSubscriptionMetadata | undefined> {
508
+ const body = (await fetchJson(`${baseUrl(session)}/__editor/log-entries`, timeoutMs)) as
509
+ | { entries?: unknown[] }
510
+ | undefined;
511
+ if (!body) return undefined;
512
+ return {
513
+ sseUrl: `${baseUrl(session)}/__editor/events`,
514
+ events: ['editor-command', 'project-changed', 'asset-moved', 'scene-changed'],
515
+ logEntriesUrl: `${baseUrl(session)}/__editor/log-entries`,
516
+ logSessionUrl: `${baseUrl(session)}/__editor/log-session`,
517
+ bufferedLogCount: Array.isArray(body.entries) ? body.entries.length : 0,
518
+ };
519
+ }
520
+
521
+ async sendCommand(
522
+ session: EditorSessionInfo,
523
+ command: Record<string, unknown>,
524
+ timeoutMs: number,
525
+ ): Promise<EditorCommandResult> {
526
+ const result = await postJson(`${baseUrl(session)}/__editor/command`, command, timeoutMs);
527
+ if (!result) return { ok: false, error: 'Command relay unreachable.' };
528
+ const json = result.json as
529
+ | { ok?: boolean; error?: string; [key: string]: unknown }
530
+ | undefined;
531
+ if (json?.ok) {
532
+ // `commandResponseFor` (editor-server.ts) spreads a command's `data`
533
+ // payload alongside `ok: true` at the top level — collect everything
534
+ // that isn't `ok` itself back into `data` for callers like getScreenshot.
535
+ const { ok: _ok, ...data } = json;
536
+ return Object.keys(data).length > 0 ? { ok: true, data } : { ok: true };
537
+ }
538
+ return { ok: false, error: json?.error ?? `Command relay returned status ${result.status}.` };
539
+ }
540
+
541
+ async openStory(
542
+ _session: EditorSessionInfo,
543
+ _path: string,
544
+ _timeoutMs: number,
545
+ ): Promise<EditorCommandResult | undefined> {
546
+ // No browser-side command handler exists for this today (see module
547
+ // jsdoc) — undefined signals the gap; the op turns this into a declared
548
+ // STORY_OPEN_UNSUPPORTED error rather than sending a command the
549
+ // browser would silently no-op and falsely ack.
550
+ return undefined;
551
+ }
552
+ }
553
+
554
+ const defaultTransport = new HttpEditorTransport();
555
+
556
+ /** Resolve the transport to use: `ctx['editorTransport']` when injected (tests), else the shared real transport. */
557
+ export function getTransport(ctx: OperationContext): EditorTransport {
558
+ const injected = ctx['editorTransport'];
559
+ return (injected as EditorTransport | undefined) ?? defaultTransport;
560
+ }
561
+
562
+ // ---------------------------------------------------------------------------
563
+ // EDITOR_NOT_RUNNING + deterministic session resolution
564
+ // ---------------------------------------------------------------------------
565
+
566
+ export const EDITOR_NOT_RUNNING_ERROR: ErrorDefinition = {
567
+ code: 'EDITOR_NOT_RUNNING',
568
+ summary:
569
+ 'No live, responsive editor session is available to target (none running, none matching ' +
570
+ 'the requested target, or discovery timed out).',
571
+ data: z.object({ editorUrl: z.string().optional() }),
572
+ };
573
+
574
+ function canonicalize(p: string): string {
575
+ return resolve(p);
576
+ }
577
+
578
+ /** Extract the port from an `http(s)://host:port` URL, or undefined if unparseable. */
579
+ function portOf(url: string): number | undefined {
580
+ try {
581
+ const port = new URL(url).port;
582
+ return port ? Number(port) : undefined;
583
+ } catch {
584
+ return undefined;
585
+ }
586
+ }
587
+
588
+ /**
589
+ * Deterministic session selection (§8 B3 AC: "Session selection is
590
+ * deterministic when multiple editors are running"). Precedence, in order:
591
+ *
592
+ * 1. `ctx.editorUrl` given -> the live session on THAT exact port, or
593
+ * EDITOR_NOT_RUNNING if no live session answers there. (Explicit
594
+ * targeting always wins — mirrors `vgai-cli`'s own `--url` override.)
595
+ * 2. `ctx.projectRoot` given (no editorUrl) -> the live session whose
596
+ * canonical project path matches `ctx.projectRoot`'s canonical path.
597
+ * (Mirrors `packages/vgai-cli/src/index.ts`'s `getClient()` cwd rule —
598
+ * "run `vgai play` from inside a project and it targets THAT project's
599
+ * editor, whichever port it landed on".)
600
+ * 3. Neither given, or neither matched -> the LOWEST-numbered port among
601
+ * all live sessions (a total order over the only session field that is
602
+ * always present and stable, so the rule never depends on registration
603
+ * order or wall-clock time).
604
+ * 4. Zero live sessions at all -> EDITOR_NOT_RUNNING.
605
+ *
606
+ * Bounded by `EDITOR_SESSION_DISCOVERY_TIMEOUT_MS` via `withTimeout` — a
607
+ * transport whose `listSessions` never resolves (or takes too long) still
608
+ * surfaces EDITOR_NOT_RUNNING within that bound, never a hang.
609
+ */
610
+ export async function resolveEditorSession(
611
+ ctx: OperationContext,
612
+ transport: EditorTransport,
613
+ ): Promise<EditorSessionInfo> {
614
+ const editorUrl = ctx.editorUrl;
615
+ const notRunning = (): never => {
616
+ throw new OperationError('EDITOR_NOT_RUNNING', 'No editor connected.', {
617
+ ...(editorUrl !== undefined ? { editorUrl } : {}),
618
+ });
619
+ };
620
+
621
+ let sessions: EditorSessionInfo[];
622
+ try {
623
+ sessions = await withTimeout(
624
+ transport.listSessions(EDITOR_SESSION_DISCOVERY_TIMEOUT_MS),
625
+ EDITOR_SESSION_DISCOVERY_TIMEOUT_MS,
626
+ 'editor session discovery',
627
+ );
628
+ } catch {
629
+ return notRunning();
630
+ }
631
+ if (sessions.length === 0) return notRunning();
632
+
633
+ if (editorUrl !== undefined) {
634
+ const port = portOf(editorUrl);
635
+ const match = sessions.find((s) => s.port === port);
636
+ if (!match) return notRunning();
637
+ return match;
638
+ }
639
+
640
+ if (ctx.projectRoot !== undefined) {
641
+ const canon = canonicalize(ctx.projectRoot);
642
+ const match = sessions.find((s) => s.project !== null && canonicalize(s.project) === canon);
643
+ if (match) return match;
644
+ }
645
+
646
+ return [...sessions].sort((a, b) => a.port - b.port)[0]!;
647
+ }