@volter/editor-sdk 0.5.57

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 (104) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +19 -0
  4. package/package.json +90 -0
  5. package/src/account.ts +210 -0
  6. package/src/chrome.ts +83 -0
  7. package/src/client.ts +1547 -0
  8. package/src/commands.ts +66 -0
  9. package/src/contributions.ts +985 -0
  10. package/src/document-probe.ts +237 -0
  11. package/src/editor-view.ts +220 -0
  12. package/src/extension.ts +40 -0
  13. package/src/generations.ts +178 -0
  14. package/src/host.ts +1167 -0
  15. package/src/http-transport.browser.ts +14 -0
  16. package/src/http-transport.node.ts +19 -0
  17. package/src/index.ts +128 -0
  18. package/src/layout-arrangements.ts +5 -0
  19. package/src/layouts.tsx +108 -0
  20. package/src/looks.ts +14 -0
  21. package/src/project/output-roots.ts +73 -0
  22. package/src/project/tab-census.ts +149 -0
  23. package/src/project-tool-catalog.ts +96 -0
  24. package/src/selection.tsx +108 -0
  25. package/src/services.ts +18 -0
  26. package/src/session/build-report.ts +19 -0
  27. package/src/session/collaboration-types.ts +262 -0
  28. package/src/session/command-table.ts +333 -0
  29. package/src/session/discovery.ts +90 -0
  30. package/src/session/editor-brand.ts +73 -0
  31. package/src/session/editor-compatibility.ts +248 -0
  32. package/src/session/editor-control-lifecycle.ts +68 -0
  33. package/src/session/editor-control-protocol.ts +5 -0
  34. package/src/session/entrypoint-selection-readers.ts +66 -0
  35. package/src/session/entrypoint-selection-source.ts +120 -0
  36. package/src/session/game-css-scope.ts +30 -0
  37. package/src/session/product-create.ts +24 -0
  38. package/src/session/product-locator.ts +389 -0
  39. package/src/session/project-module-url.ts +245 -0
  40. package/src/session/registry-format.ts +203 -0
  41. package/src/session/relative-path-guard.ts +56 -0
  42. package/src/session/source-glob.ts +15 -0
  43. package/src/session/tool-contribution-convention.ts +116 -0
  44. package/src/session/workbench-locator.ts +650 -0
  45. package/src/session.ts +41 -0
  46. package/src/share.ts +160 -0
  47. package/src/tools/errors.ts +91 -0
  48. package/src/tools/provider-execution.ts +70 -0
  49. package/src/tools/registry.ts +341 -0
  50. package/src/tools/types.ts +159 -0
  51. package/src/transport.ts +97 -0
  52. package/src/types.ts +1581 -0
  53. package/src/views.ts +164 -0
  54. package/src/widgets/design-system.ts +93 -0
  55. package/src/widgets/editor-appearance.ts +149 -0
  56. package/src/widgets/editor-material.ts +83 -0
  57. package/src/widgets/icon-set-registry.ts +105 -0
  58. package/src/widgets/index.ts +71 -0
  59. package/src/widgets/inspector-widgets/AlignmentGrid.tsx +182 -0
  60. package/src/widgets/inspector-widgets/AssetSlotPicker.tsx +123 -0
  61. package/src/widgets/inspector-widgets/BorderEditor.tsx +309 -0
  62. package/src/widgets/inspector-widgets/ColorPicker.tsx +549 -0
  63. package/src/widgets/inspector-widgets/CurveEditor.tsx +359 -0
  64. package/src/widgets/inspector-widgets/FilterEditor.tsx +108 -0
  65. package/src/widgets/inspector-widgets/FontPicker.tsx +191 -0
  66. package/src/widgets/inspector-widgets/GradientEditor.tsx +623 -0
  67. package/src/widgets/inspector-widgets/ScrubbableInput.tsx +180 -0
  68. package/src/widgets/inspector-widgets/ShadowEditor.tsx +319 -0
  69. package/src/widgets/inspector-widgets/color-utils.ts +201 -0
  70. package/src/widgets/inspector-widgets/curve-utils.ts +212 -0
  71. package/src/widgets/inspector-widgets/index.ts +24 -0
  72. package/src/widgets/inspector-widgets/shared.tsx +140 -0
  73. package/src/widgets/interactive-edit-scope.ts +33 -0
  74. package/src/widgets/patterns/Dialog.tsx +129 -0
  75. package/src/widgets/patterns/Fields.tsx +44 -0
  76. package/src/widgets/patterns/List.tsx +25 -0
  77. package/src/widgets/patterns/StateSurface.tsx +40 -0
  78. package/src/widgets/patterns/Surfaces.tsx +122 -0
  79. package/src/widgets/patterns/Tabs.tsx +80 -0
  80. package/src/widgets/patterns/Toolbar.tsx +72 -0
  81. package/src/widgets/patterns/Tree.tsx +72 -0
  82. package/src/widgets/primitives/AnchoredMenu.tsx +260 -0
  83. package/src/widgets/primitives/Button.tsx +62 -0
  84. package/src/widgets/primitives/ColorInput.tsx +78 -0
  85. package/src/widgets/primitives/DraftTextInput.tsx +63 -0
  86. package/src/widgets/primitives/EditorIcon.tsx +157 -0
  87. package/src/widgets/primitives/FormControls.tsx +88 -0
  88. package/src/widgets/primitives/HoverPreview.tsx +96 -0
  89. package/src/widgets/primitives/JsonInput.tsx +113 -0
  90. package/src/widgets/primitives/Layout.tsx +100 -0
  91. package/src/widgets/primitives/Menu.tsx +140 -0
  92. package/src/widgets/primitives/NumberInput.tsx +169 -0
  93. package/src/widgets/primitives/Panel.tsx +80 -0
  94. package/src/widgets/primitives/SectionHeader.tsx +77 -0
  95. package/src/widgets/primitives/Text.tsx +54 -0
  96. package/src/widgets/primitives/ThemeRootPortal.tsx +52 -0
  97. package/src/widgets/primitives/Tooltip.tsx +204 -0
  98. package/src/widgets/primitives/Vec3Input.tsx +70 -0
  99. package/src/widgets/primitives/banner-tones.ts +32 -0
  100. package/src/widgets/primitives/clamp-to-viewport.ts +44 -0
  101. package/src/widgets/primitives/editor-icons.ts +245 -0
  102. package/src/widgets/primitives/panel-header-styles.ts +42 -0
  103. package/src/widgets/theme.ts +2633 -0
  104. package/src/widgets/z-index.ts +25 -0
package/src/client.ts ADDED
@@ -0,0 +1,1547 @@
1
+ import type { GenerationJobsDocument } from '@volter/editor-sdk/generations';
2
+ import type { Dispatcher } from 'undici';
3
+ import { createDispatcher, dispatchFetch } from '#http-transport';
4
+ import type { DocumentProbeResult, DocumentProbeStep } from './document-probe.js';
5
+ import type {
6
+ ActiveDocumentCapture,
7
+ AssetCompareCapture,
8
+ AssetCompareOptions,
9
+ AssetKind,
10
+ AssetPreviewCapture,
11
+ AssetPreviewOptions,
12
+ AssetPreviewShotSetDefinition,
13
+ AssetPreviewSource,
14
+ CaptureDimensions,
15
+ DocumentCameraPose,
16
+ DocumentLookOutcome,
17
+ DocumentTableProjection,
18
+ EditorChromeCapture,
19
+ EditorChromeCaptureOptions,
20
+ EditorState,
21
+ EditorView,
22
+ EditorWorkspaceName,
23
+ GameCapture,
24
+ GameplayRecordingCapture,
25
+ GameplayRecordingOptions,
26
+ GameplayRecordingStarted,
27
+ GameplayRecordingTimeline,
28
+ GameplayReplayCapture,
29
+ HelperVisibility,
30
+ InspectedFieldWrite,
31
+ InspectedHierarchy,
32
+ InspectedInspection,
33
+ LabeledShotSetCapture,
34
+ PlayStarted,
35
+ PresentedEditorView,
36
+ ProjectInfo,
37
+ ProjectTemplate,
38
+ ProjectToolCatalog,
39
+ ProjectToolOutcome,
40
+ RecentProject,
41
+ ShadingMode,
42
+ StoryCaptureOptions,
43
+ StoryVariantCapture,
44
+ StructureOp,
45
+ StructureOpOptions,
46
+ StructureOpResult,
47
+ TransformMode,
48
+ TransformSpace,
49
+ Vec3Value,
50
+ ViewPreset,
51
+ ViewportCapture,
52
+ ViewportTab,
53
+ } from './types.js';
54
+
55
+ // The editor's default origin, spelled out because this package deliberately
56
+ // does not depend on `@volter/editor-project`. `DEFAULT_EDITOR_PORT` in
57
+ // `packages/project/src/manifest/editor-port.ts` is the owner of the number and
58
+ // of the never-`localhost` rule; keep this in step with it.
59
+ const DEFAULT_URL = 'http://127.0.0.1:20173';
60
+
61
+ /**
62
+ * A refused editor command, carrying the relay's own STRUCTURED failure code
63
+ * alongside the prose.
64
+ *
65
+ * `/__editor/command` has always answered `{ ok: false, error, code }` and
66
+ * this client has always dropped the `code` on the floor, so every caller that
67
+ * wanted to react to a specific refusal had to substring-match an English
68
+ * sentence. `vgai screenshot`'s loop-recovery fallback is the first caller that
69
+ * genuinely must branch (`BRIDGE_SCREENSHOT_STALE` has a working recovery;
70
+ * "not in play mode" does not), and a fallback keyed on prose would fire on
71
+ * the wrong failure the first time someone rewords the message.
72
+ */
73
+ export class EditorCommandError extends Error {
74
+ readonly code: string | undefined;
75
+ /**
76
+ * True when the RELAY ended the command itself rather than the editor
77
+ * answering it — the HTTP 504 that `server/server-utils.ts`'s
78
+ * `commandResponseFor` gives any `timedOut` result, or this client's own
79
+ * deadline below.
80
+ *
81
+ * Read it as "no answer", not as "the budget expired". `editor-server.ts`
82
+ * raises `timedOut` for five conditions and only one of them takes the full
83
+ * budget: the command's timer expiring, the controlling tab's socket dying,
84
+ * the receipt window closing unanswered, a beating-but-dead tab, and no tab
85
+ * present at all. The last four can fail in milliseconds.
86
+ *
87
+ * A caller that converges by retrying (`vgai restart`) needs the distinction
88
+ * because a refusal the editor ANSWERED may go differently next time, while
89
+ * a command the relay abandoned tells you nothing new on a second identical
90
+ * attempt — and when the abandonment was a 120s budget, re-running it three
91
+ * times is `restart-readiness.ts`'s 361-seconds-of-silence defect.
92
+ */
93
+ readonly timedOut: boolean;
94
+ constructor(message: string, code?: string | undefined, timedOut = false) {
95
+ super(message);
96
+ this.name = 'EditorCommandError';
97
+ this.code = code;
98
+ this.timedOut = timedOut;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * The client's own ceiling on ONE relayed command.
104
+ *
105
+ * A backstop for a LOST server, not a per-command budget: the server already
106
+ * owns per-type budgets (`server/server-utils.ts`'s `relayCommandTimeoutMs`)
107
+ * and its timer must always be the one that fires, because its message names
108
+ * the tab and the remedy while this one can only say "no answer". So this is
109
+ * deliberately ONE number, comfortably above the longest server budget
110
+ * (120s, `play`/`capture-story-variants`) rather than a mirror of that table —
111
+ * a second copy of it would drift silently, and the drift would show up as
112
+ * this timer winning a race it must always lose.
113
+ *
114
+ * Without it a `fetch` with no `AbortSignal` waits on the OS: a dev server
115
+ * that stops answering mid-command holds the CLI open indefinitely, with no
116
+ * output and nothing to read.
117
+ */
118
+ const COMMAND_DEADLINE_MS = 150_000;
119
+ /** The Blender lane's own ceiling: a chunk is a whole modeling step, not a tick. */
120
+ const BLENDER_DEADLINE_MS = 30 * 60_000;
121
+ /** undici's default `headersTimeout`; a command deadline beyond it needs its own dispatcher (see `command`). */
122
+ const UNDICI_DEFAULT_HEADERS_TIMEOUT_MS = 300_000;
123
+
124
+ /**
125
+ * Ceiling on {@link EditorClient.getUnresolvedConsole}. The CLI drains this
126
+ * on every verb, including ones that never wait for a command envelope, so
127
+ * a silent hang here would become a silent hang on `vgai sessions`. The
128
+ * server route is a plain in-process GET; 1.5s is already longer than it
129
+ * should ever take.
130
+ */
131
+ const CONSOLE_DRAIN_TIMEOUT_MS = 1_500;
132
+
133
+ /**
134
+ * Node's `fetch` collapses EVERY network-layer failure into one two-word
135
+ * `TypeError: fetch failed`. The real reason — `ECONNREFUSED`, `ECONNRESET`,
136
+ * `EPIPE`, a DNS miss — lives only on `error.cause` (sometimes two links down,
137
+ * or inside an `AggregateError`), and nothing prints it unless something walks
138
+ * the chain. That is the whole reason `project.bake.preview` was observed
139
+ * failing with a bare "fetch failed" and no way to tell a dead editor from a
140
+ * momentary one (WORK.md, cold barrel 2026-08-29): the code below used to
141
+ * rethrow the `TypeError` untouched, on the belief — stated in a comment right
142
+ * where it happened — that "a connection refused / DNS failure still surfaces
143
+ * as itself". It does not. This walks the chain so the message can say which.
144
+ */
145
+ function describeFetchFailure(error: unknown): { code: string; detail: string } {
146
+ const messages: string[] = [];
147
+ let current: unknown = error;
148
+ for (let depth = 0; depth < 8; depth++) {
149
+ if (!(current instanceof Error)) break;
150
+ if (current.message) messages.push(current.message);
151
+ const code = (current as { code?: unknown }).code;
152
+ if (typeof code === 'string' && code !== '') {
153
+ return { code, detail: messages.join(' <- ') };
154
+ }
155
+ const aggregate = (current as { errors?: unknown }).errors;
156
+ if (Array.isArray(aggregate) && aggregate.length > 0) {
157
+ const inner = describeFetchFailure(aggregate[0]);
158
+ return { code: inner.code, detail: [...messages, inner.detail].join(' <- ') };
159
+ }
160
+ current = (current as { cause?: unknown }).cause;
161
+ }
162
+ return { code: 'UNKNOWN', detail: messages.join(' <- ') || String(error) };
163
+ }
164
+
165
+ /**
166
+ * Transport failures where a second attempt is worth making: the connection
167
+ * itself failed or died, rather than the editor answering something unwelcome.
168
+ * A dev server that is restarting (any watched source edit restarts it) is
169
+ * unreachable for a fraction of a second and reachable again after — which is
170
+ * exactly the "fails, then succeeds unchanged" shape that was reported.
171
+ */
172
+ const RETRYABLE_TRANSPORT_CODES = new Set([
173
+ 'ECONNREFUSED',
174
+ 'ECONNRESET',
175
+ 'EPIPE',
176
+ 'ETIMEDOUT',
177
+ 'EHOSTUNREACH',
178
+ 'UND_ERR_SOCKET',
179
+ 'UND_ERR_CONNECT_TIMEOUT',
180
+ ]);
181
+
182
+ /** Gap before the one automatic retry — long enough for a dev-server restart's
183
+ * listen socket to come back, short enough to stay invisible. */
184
+ const TRANSPORT_RETRY_DELAY_MS = 400;
185
+
186
+ /** Context accompanying one {@link EditorClient} response observation. */
187
+ export interface EditorEnvelopeObservation {
188
+ /** True only when this body came from the console-ledger endpoint and its
189
+ * `entries` field is therefore the complete named console set. Command
190
+ * payloads may also own an unrelated `entries` field. */
191
+ readonly unresolvedConsoleComplete: boolean;
192
+ }
193
+
194
+ /**
195
+ * The GAME DEBUG PLANE, as a contribution's client sees it.
196
+ *
197
+ * Deliberately the same two words `@vgai/live`'s session binding uses
198
+ * (`game.state(name)` / `game.command(name, ...args)`), because it is the same
199
+ * plane: whatever the running game registered through `ctx.debug` — a provider
200
+ * read by name, a command invoked by name. A tool contribution that wants the
201
+ * game's own vitals in its panel has this door and no other; there is
202
+ * deliberately no per-capability method, because a game names its own
203
+ * providers and commands.
204
+ *
205
+ * Both legs reject LOUDLY (`EditorCommandError`) rather than answer with a
206
+ * placeholder: play not running is `'not in play mode — start play before
207
+ * using the debug seam'`, and an unregistered command carries
208
+ * `code: 'DEBUG_COMMAND_NOT_REGISTERED'`. A panel decides what to show for
209
+ * those; the client never invents one.
210
+ */
211
+ /** What one undo/redo step reports back — `moved` is false when there was
212
+ * nothing left in that direction, which is an answer, not an error. */
213
+ export interface HistoryStep {
214
+ readonly moved: boolean;
215
+ readonly canUndo: boolean;
216
+ readonly canRedo: boolean;
217
+ readonly undoLabel: string | null;
218
+ readonly redoLabel: string | null;
219
+ }
220
+
221
+ /** What `open()` acknowledges: the workspace document the scene-table entry
222
+ * resolved to, and the title its tab now carries — the game's own word for
223
+ * that composition, not a filename. */
224
+ export interface OpenedDocument {
225
+ readonly documentId: string;
226
+ readonly title: string;
227
+ /**
228
+ * The GAME's own answer, present only when opening navigated a running game
229
+ * (a scene the adapter declares reachable through the game's scenes
230
+ * contract, or a native swap-slot remount): what was asked for, and which
231
+ * scene the game reports it is in once its own navigation settled. `current`
232
+ * can differ from `requested` — that is the game's reading, not a host claim.
233
+ */
234
+ readonly scene?: { readonly requested: string; readonly current: string | null };
235
+ /**
236
+ * Present when opening restarted play at a native swap-slot key rather than
237
+ * navigating a live contract — the slot is a module-level const.
238
+ */
239
+ readonly restart?: true;
240
+ }
241
+
242
+ export interface GameDebugDoor {
243
+ /**
244
+ * Read ONE registered state provider by name (`'bot.tester'`). `undefined`
245
+ * when the running game registered no such provider — or no debug adapter at
246
+ * all, which is an honest answer rather than a refusal.
247
+ */
248
+ state(name: string): Promise<unknown>;
249
+ /** Invoke ONE registered debug command by name, with its own arguments. */
250
+ command(name: string, ...args: unknown[]): Promise<unknown>;
251
+ }
252
+
253
+ export class EditorClient {
254
+ private readonly baseUrl: string;
255
+
256
+ /**
257
+ * The running game's debug plane — see {@link GameDebugDoor}. It rides the
258
+ * SAME `/__editor/command` relay every other method here uses (relay cases
259
+ * `inspect-gameplay-state` / `invoke-debug-command`, `command-listener.ts`),
260
+ * so a tool contribution reaches the game through the client it already has
261
+ * rather than a second channel of its own.
262
+ */
263
+ readonly game: GameDebugDoor;
264
+
265
+ /**
266
+ * Called with the raw body of EVERY response this client receives — command
267
+ * envelopes and `/__editor/state` alike, on success AND on refusal.
268
+ *
269
+ * It exists for exactly one contract: the server stamps `unresolvedConsole`
270
+ * onto every envelope (`server-utils.ts`'s `commandResponseFor`), and the CLI
271
+ * has to see those counts to be loud about them. Routing that through a
272
+ * single observer here — rather than teaching each of the CLI's output sites
273
+ * to unpack a response — is what keeps the loudness contract ONE mechanism.
274
+ * The observer must not throw; anything it raises is swallowed, because a
275
+ * reporting hook may never break the command it is reporting on.
276
+ */
277
+ private readonly transport:
278
+ | ((body: Record<string, unknown>) => Promise<Record<string, unknown>>)
279
+ | null;
280
+ private readonly onEnvelope:
281
+ | ((body: unknown, observation: EditorEnvelopeObservation) => void)
282
+ | null;
283
+
284
+ constructor(opts?: {
285
+ url?: string;
286
+ onEnvelope?: (body: unknown, observation: EditorEnvelopeObservation) => void;
287
+ /**
288
+ * An IN-PAGE command channel, for a client that lives inside the editor
289
+ * page itself and has no editor server to reach. Given, every command goes through it instead of
290
+ * `POST /__editor/command`, and answers in the route's own body shape
291
+ * (`{ ok: true, ...data }` / `{ ok: false, error, code? }`).
292
+ */
293
+ transport?: (body: Record<string, unknown>) => Promise<Record<string, unknown>>;
294
+ }) {
295
+ if (opts !== undefined && (typeof opts !== 'object' || opts === null || Array.isArray(opts))) {
296
+ throw new TypeError(
297
+ 'EditorClient options must be an object. Use new EditorClient({ url: "http://127.0.0.1:20173" }), not new EditorClient("...").',
298
+ );
299
+ }
300
+ const unknownOptions = Object.keys(opts ?? {}).filter(
301
+ (key) => key !== 'url' && key !== 'onEnvelope' && key !== 'transport',
302
+ );
303
+ if (unknownOptions.length > 0) {
304
+ throw new Error(
305
+ `EditorClient: unknown option${unknownOptions.length === 1 ? '' : 's'} ${unknownOptions.map((key) => `"${key}"`).join(', ')}. Use { url: "http://127.0.0.1:<port>" } to target an editor.`,
306
+ );
307
+ }
308
+ this.baseUrl = (opts?.url ?? DEFAULT_URL).replace(/\/$/, '');
309
+ this.onEnvelope = opts?.onEnvelope ?? null;
310
+ this.transport = opts?.transport ?? null;
311
+ this.game = {
312
+ state: async (name: string): Promise<unknown> => {
313
+ // `keys` narrows the relay to the one provider asked for, so a panel
314
+ // polling one vital never drags the whole plane's `stateAll()` across
315
+ // the wire. A game with no debug adapter answers `{ state: null }`.
316
+ const data = await this.command<{ state: Record<string, unknown> | null }>({
317
+ type: 'inspect-gameplay-state',
318
+ keys: [name],
319
+ });
320
+ return data.state?.[name];
321
+ },
322
+ command: async (name: string, ...args: unknown[]): Promise<unknown> =>
323
+ (await this.command<{ result: unknown }>({ type: 'invoke-debug-command', name, args }))
324
+ .result,
325
+ };
326
+ }
327
+
328
+ /**
329
+ * `retryTransport` opts a command into ONE automatic retry after a transport
330
+ * failure (see {@link RETRYABLE_TRANSPORT_CODES}). It is deliberately
331
+ * OPT-IN and off by default: a socket that died after the request was written
332
+ * cannot prove the editor did not already run the command, so a blanket retry
333
+ * would risk playing/stopping/writing twice. Read-only relays — the captures —
334
+ * have no such hazard and turn it on.
335
+ */
336
+ private async command<T extends object = Record<string, never>>(
337
+ body: Record<string, unknown>,
338
+ options?: { retryTransport?: boolean; deadlineMs?: number },
339
+ ): Promise<T> {
340
+ const type = String(body['type'] ?? 'command');
341
+ const deadlineMs = options?.deadlineMs ?? COMMAND_DEADLINE_MS;
342
+ if (this.transport) {
343
+ const answered = (await this.transport(body)) as {
344
+ ok: boolean;
345
+ error?: string;
346
+ code?: string;
347
+ } & T;
348
+ if (!answered.ok) {
349
+ throw new EditorCommandError(
350
+ answered.error ?? `Editor command "${type}" failed`,
351
+ answered.code,
352
+ );
353
+ }
354
+ return answered;
355
+ }
356
+ const url = `${this.baseUrl}/__editor/command`;
357
+ const request = JSON.stringify(body);
358
+ let res: Response | undefined;
359
+ let retried = false;
360
+ let commandDispatcher: Dispatcher | undefined;
361
+ for (;;) {
362
+ try {
363
+ const init: RequestInit = {
364
+ method: 'POST',
365
+ headers: { 'Content-Type': 'application/json' },
366
+ body: request,
367
+ signal: AbortSignal.timeout(deadlineMs),
368
+ };
369
+ // The command route answers only when the command completes, and
370
+ // Node's fetch (undici) gives a server 300 s to send response HEADERS
371
+ // regardless of the abort signal: a modeling step measured at eleven
372
+ // minutes in the tab died as UND_ERR_HEADERS_TIMEOUT well inside its
373
+ // half-hour budget. A deadline past that default carries its own
374
+ // dispatcher, through undici's own fetch so the two agree.
375
+ commandDispatcher = deadlineMs > UNDICI_DEFAULT_HEADERS_TIMEOUT_MS
376
+ ? createDispatcher(deadlineMs + 30_000)
377
+ : undefined;
378
+ res = await dispatchFetch(url, init, commandDispatcher);
379
+ break;
380
+ } catch (error) {
381
+ await commandDispatcher?.destroy?.();
382
+ commandDispatcher = undefined;
383
+ // Only the deadline is reshaped into a "no answer" verdict; everything
384
+ // else is a TRANSPORT failure, and Node hides its reason behind a bare
385
+ // `fetch failed` (see `describeFetchFailure`).
386
+ if ((error as { name?: string } | null)?.name === 'TimeoutError') {
387
+ throw new EditorCommandError(
388
+ `The editor at ${this.baseUrl} never answered "${type}" ` +
389
+ `within ${Math.round(deadlineMs / 1000)}s — past every server-side budget, so ` +
390
+ 'the server itself is not answering. Check the terminal running `volter-editor edit`.',
391
+ undefined,
392
+ true,
393
+ );
394
+ }
395
+ const { code, detail } = describeFetchFailure(error);
396
+ if (options?.retryTransport === true && !retried && RETRYABLE_TRANSPORT_CODES.has(code)) {
397
+ retried = true;
398
+ await new Promise((resolve) => setTimeout(resolve, TRANSPORT_RETRY_DELAY_MS));
399
+ continue;
400
+ }
401
+ throw new EditorCommandError(
402
+ `POST ${url} ("${type}") never reached the editor: ${code}${detail ? ` (${detail})` : ''}.` +
403
+ (retried
404
+ ? ` Retried once after ${TRANSPORT_RETRY_DELAY_MS}ms; it failed the same way.`
405
+ : '') +
406
+ (options?.retryTransport === true
407
+ ? ''
408
+ : ' Not retried automatically: this command can change editor state, and a socket that' +
409
+ ' died after the request was written cannot prove the editor did not already run it.') +
410
+ ' A transport failure means the port stopped answering, not that the editor refused —' +
411
+ ' the dev server restarts on any watched source edit, and it shuts itself down after an' +
412
+ ' idle window. Check the terminal running `volter-editor edit` and confirm the port' +
413
+ ' this client resolved.',
414
+ code,
415
+ false,
416
+ );
417
+ }
418
+ }
419
+ let data: { ok: boolean; error?: string; code?: string } & T;
420
+ try {
421
+ data = (await this.readJson(res)) as typeof data;
422
+ } finally {
423
+ await commandDispatcher?.destroy?.();
424
+ }
425
+ if (!data.ok) {
426
+ throw new EditorCommandError(
427
+ data.error ?? `Editor command failed: ${res.status}`,
428
+ data.code,
429
+ // 504 is every `timedOut` result (`commandResponseFor`) — the relay
430
+ // gave up, on any of its five grounds. A 200 body with `ok: false` is
431
+ // an ANSWER from the editor, however unwelcome. See `timedOut` above.
432
+ res.status === 504,
433
+ );
434
+ }
435
+ return data;
436
+ }
437
+
438
+ // --- Play control ---
439
+
440
+ /** `opts.seed` (D15/T-D15.6, objection-4 fix) — `vgai play --seed <n>`'s
441
+ * explicit config leg, relayed as `cmd['seed']`; `handleCommand`'s
442
+ * `'play'` case threads it into `enterPlayMode`'s highest-precedence seed
443
+ * argument (beats manifest.determinism.defaultSeed/?vgai-seed=). Omitted,
444
+ * boot seeding falls back to that precedence unchanged.
445
+ *
446
+ * `opts.name` (`vgai play --name <text>`) — an OPTIONAL label for this run,
447
+ * relayed as `cmd['name']` and slugified server-side into the run's
448
+ * `logs/play-*.jsonl` filename and its session-journal line. Findability
449
+ * only: no registry, no uniqueness, no lookup verb — grep and `ls` are the
450
+ * query engine. Omitted, the filename keeps its exact unnamed shape. */
451
+ /* `opts.record` (`vgai play --record <name>`) — NAMES this run's recording
452
+ * file. It does not ENABLE recording: every relayed play records, with no
453
+ * flag (see `@vgai/game`'s `src/play/play-recording.ts`). Omitted, the clip is named for
454
+ * the durable Gameplay Session; named, it becomes an explicit keepsake in
455
+ * `.vgai/recordings/<name>.webm`. */
456
+ async play(opts?: {
457
+ seed?: number;
458
+ name?: string | null;
459
+ record?: string | null;
460
+ }): Promise<PlayStarted> {
461
+ return this.command<PlayStarted>({
462
+ type: 'play',
463
+ ...(opts?.seed !== undefined ? { seed: opts.seed } : {}),
464
+ ...(opts?.name ? { name: opts.name } : {}),
465
+ ...(opts?.record ? { record: opts.record } : {}),
466
+ });
467
+ }
468
+
469
+ /** Dispose the current play session and mount it again from fresh project entry source. */
470
+ async restart(): Promise<void> {
471
+ await this.command({ type: 'play' });
472
+ }
473
+
474
+ /** Stops play, and finalizes this run's recording before the surface it was
475
+ * photographing is torn down. The capture is absent when nothing recorded. */
476
+ async stop(): Promise<{ recording?: GameplayRecordingCapture }> {
477
+ return this.command<{ recording?: GameplayRecordingCapture }>({ type: 'stop' });
478
+ }
479
+
480
+ async pause(): Promise<void> {
481
+ await this.command({ type: 'pause' });
482
+ }
483
+
484
+ async resume(): Promise<void> {
485
+ await this.command({ type: 'resume' });
486
+ }
487
+
488
+ async step(): Promise<void> {
489
+ await this.command({ type: 'step' });
490
+ }
491
+
492
+ // --- Selection ---
493
+
494
+ async select(id: string | null): Promise<void> {
495
+ await this.command({ type: 'select', id });
496
+ }
497
+
498
+ async selectMultiple(ids: string[]): Promise<void> {
499
+ await this.command({ type: 'select-multiple', ids });
500
+ }
501
+
502
+ async selectAll(): Promise<void> {
503
+ await this.command({ type: 'select-all' });
504
+ }
505
+
506
+ // --- Viewport ---
507
+
508
+ async focusEntity(id: string): Promise<void> {
509
+ await this.command({ type: 'focus-entity', id });
510
+ }
511
+
512
+ async focusSelection(): Promise<void> {
513
+ await this.command({ type: 'focus-selection' });
514
+ }
515
+
516
+ /**
517
+ * Frame the EDIT viewport camera on one entity — the strict sibling of
518
+ * {@link focusEntity}. Same framing; an id the scene does not know is a
519
+ * refusal naming the id (`EditorCommandError`, code `ENTITY_NOT_FOUND`)
520
+ * rather than `focusEntity`'s silent no-op, so a caller that frames an
521
+ * entity before capturing it cannot photograph the wrong thing.
522
+ */
523
+ async frameEntity(id: string): Promise<void> {
524
+ await this.command({ type: 'frame-entity', id });
525
+ }
526
+
527
+ async viewPreset(preset: ViewPreset): Promise<void> {
528
+ await this.command({ type: 'view-preset', preset });
529
+ }
530
+
531
+ /**
532
+ * LOOK AROUND THE OPEN MODEL, visibly. Swings the active Object3D
533
+ * document's camera — the one on the human's screen — by `azimuth`/
534
+ * `elevation` radians, animated over `duration` seconds, and resolves when
535
+ * the move ends. A human drag during the move cancels it where it stands
536
+ * (`cancelledBy: 'human'`); the promise still resolves.
537
+ */
538
+ async orbitDocument(options: {
539
+ azimuth?: number;
540
+ elevation?: number;
541
+ duration?: number;
542
+ }): Promise<DocumentLookOutcome> {
543
+ return this.command<DocumentLookOutcome>({ type: 'document-orbit', ...options });
544
+ }
545
+
546
+ /** A slow full revolution around the open document's subject, at a constant rate. */
547
+ async turntableDocument(options?: {
548
+ seconds?: number;
549
+ revolutions?: number;
550
+ }): Promise<DocumentLookOutcome> {
551
+ return this.command<DocumentLookOutcome>({ type: 'document-turntable', ...options });
552
+ }
553
+
554
+ /**
555
+ * Frame the open document's subject (its selection if it has one). `fit`
556
+ * scales the fitted distance: 1 is the toolbar Frame button's tight fit.
557
+ */
558
+ async frameDocument(fit?: number): Promise<DocumentCameraPose> {
559
+ return this.command<DocumentCameraPose>({
560
+ type: 'document-frame',
561
+ ...(fit === undefined ? {} : { fit }),
562
+ });
563
+ }
564
+
565
+ async setCamera(position: Vec3Value, target: Vec3Value, fov?: number): Promise<void> {
566
+ await this.command({
567
+ type: 'set-camera',
568
+ position,
569
+ target,
570
+ ...(fov === undefined ? {} : { fov }),
571
+ });
572
+ }
573
+
574
+ async captureViewport(size?: number): Promise<ViewportCapture> {
575
+ const data = await this.command<ViewportCapture>({
576
+ type: 'capture-viewport',
577
+ ...(size === undefined ? {} : { size }),
578
+ });
579
+ return { base64: data.base64, mimeType: data.mimeType };
580
+ }
581
+
582
+ /**
583
+ * Unit 4 (live-front-door wave) — capture the RUNNING GAME (`vgai
584
+ * screenshot`'s wire leg). Sends the SAME `bridge-screenshot` relay op
585
+ * `@vgai/live`'s `RelayTransport.screenshot` (and therefore
586
+ * `game.screenshot()` on the relay path) already sends, so all three
587
+ * surfaces composite the identical full game stack — canvas(es) plus the
588
+ * HUD/react DOM layers — rather than any of them inventing a second,
589
+ * subtly-different capture path. Contrast {@link captureViewport}, which
590
+ * captures the EDITOR viewport's canvas and would silently hand back an
591
+ * editor-only (HUD-less, possibly not-even-playing) image.
592
+ *
593
+ * Rejects — loudly, via `command`'s own `{ok:false}` unwrap — when play
594
+ * mode isn't running ("not in play mode — start play before using the
595
+ * debug seam") or no game canvas is mounted yet. Never returns a blank or
596
+ * editor-only frame as a stand-in.
597
+ *
598
+ * `opts.refreshStarvedFrame` is the loop-starvation leg: without recent rAF
599
+ * progress the canvas holds a provably stale frame and the relay
600
+ * refuses it with `BRIDGE_SCREENSHOT_STALE` rather than pass it off as
601
+ * current. Setting this asks the relay to render exactly ONE deterministic
602
+ * tick (`runTicks(1, {render:'last'})`) first — the same escape
603
+ * `@vgai/live`'s `RelayTransport.screenshot` has always used, which is why
604
+ * `vgai eval` could recover these frames while `vgai screenshot` could not.
605
+ * Off by default: a caller who does not ask must never be handed a frame
606
+ * that only exists because the capture drove the game.
607
+ */
608
+ async captureGame(opts?: { refreshStarvedFrame?: boolean }): Promise<GameCapture> {
609
+ const data = await this.command<GameCapture>({
610
+ type: 'bridge-screenshot',
611
+ ...(opts?.refreshStarvedFrame === true ? { refreshStarvedFrame: true } : {}),
612
+ });
613
+ const layers = data.layers;
614
+ const flatness = data.flatness;
615
+ return {
616
+ base64: data.base64,
617
+ mimeType: data.mimeType,
618
+ composite: data.composite === true,
619
+ ...(layers && Number.isInteger(layers.canvases) && Number.isInteger(layers.domOverlays)
620
+ ? { layers }
621
+ : {}),
622
+ // Pass the pixel-honesty fields through as the page reported them: the
623
+ // warning sentence is written where the pixels are, so nothing here
624
+ // re-derives (or softens) it.
625
+ ...(flatness && typeof flatness.dominantFraction === 'number' ? { flatness } : {}),
626
+ ...(data.loopRecoveryFrame === true ? { loopRecoveryFrame: true } : {}),
627
+ // Same pass-through rule: the recorded-run notice is written where the
628
+ // pixels are, so nothing here re-derives or softens it.
629
+ ...(data.recording && typeof data.recording.notice === 'string'
630
+ ? { recording: data.recording }
631
+ : {}),
632
+ };
633
+ }
634
+
635
+ /** Start recording the same clean running-game composite `captureGame`
636
+ * photographs. Recording state lives in the editor page, so another process
637
+ * may stop it later through the same project session. */
638
+ async startGameplayRecording(
639
+ options: GameplayRecordingOptions = {},
640
+ ): Promise<GameplayRecordingStarted> {
641
+ return this.command<GameplayRecordingStarted>({
642
+ type: 'bridge-recording-start',
643
+ ...(options.fps !== undefined ? { fps: options.fps } : {}),
644
+ ...(options.name !== undefined ? { name: options.name } : {}),
645
+ ...(options.format !== undefined ? { format: options.format } : {}),
646
+ });
647
+ }
648
+
649
+ /** Export a paused run as fixed-step video. Advances game state; maximum
650
+ * five minutes. `audio` describes the muxed track, or is `false` when the
651
+ * world implements no `AudioAdapter.renderOffline` and the file is
652
+ * genuinely silent — read it, never assume either. */
653
+ async exportGameplayVideo(options: { frames: number; fps?: number; name?: string }): Promise<{
654
+ path: string;
655
+ frames: number;
656
+ fps: number;
657
+ durationMs: number;
658
+ wallMs: number;
659
+ width: number;
660
+ height: number;
661
+ audio:
662
+ | false
663
+ | {
664
+ codec: 'opus';
665
+ sampleRate: number;
666
+ channels: number;
667
+ durationSeconds: number;
668
+ rms: number;
669
+ peak: number;
670
+ };
671
+ }> {
672
+ return this.command(
673
+ { type: 'bridge-recording-export', ...options },
674
+ { deadlineMs: 610_000, retryTransport: false },
675
+ );
676
+ }
677
+
678
+ /** Stop the page-owned recorder and return its WebM path and metadata. */
679
+ async stopGameplayRecording(): Promise<GameplayRecordingCapture> {
680
+ return this.command<GameplayRecordingCapture>({ type: 'bridge-recording-stop' });
681
+ }
682
+
683
+ /** Read the active capture's monotonic media position. This is the only clock
684
+ * suitable for selecting intervals inside the finalized recording. */
685
+ async getGameplayRecordingTimeline(): Promise<GameplayRecordingTimeline> {
686
+ const timeline = await this.command<GameplayRecordingTimeline>({
687
+ type: 'bridge-recording-timeline',
688
+ });
689
+ return { startedAt: timeline.startedAt, elapsedMs: timeline.elapsedMs };
690
+ }
691
+
692
+ /** Encode a recorded canvas/DOM interval into a normal composite WebM. */
693
+ async exportGameplayReplay(options: {
694
+ replayPath: string;
695
+ fps?: number;
696
+ startMs?: number;
697
+ endMs?: number;
698
+ name?: string;
699
+ }): Promise<{
700
+ path: string;
701
+ frames: number;
702
+ fps: number;
703
+ durationMs: number;
704
+ width: number;
705
+ height: number;
706
+ audio: boolean;
707
+ }> {
708
+ return this.command(
709
+ { type: 'bridge-recording-replay-export', ...options },
710
+ { deadlineMs: 610_000, retryTransport: false },
711
+ );
712
+ }
713
+
714
+ async captureGameplayReplay(
715
+ replayPath: string,
716
+ positionMs: number,
717
+ ): Promise<GameplayReplayCapture> {
718
+ return this.command<GameplayReplayCapture>({
719
+ type: 'bridge-recording-replay-capture',
720
+ replayPath,
721
+ positionMs,
722
+ });
723
+ }
724
+
725
+ /**
726
+ * Capture an isolated, deterministic four-view preview through the editor's
727
+ * native Asset Lab. The SDK delegates rendering to the editor; it never
728
+ * loads, clones, or interprets Three.js assets itself.
729
+ */
730
+ async captureAssetPreview(
731
+ source: AssetPreviewSource,
732
+ options: AssetPreviewOptions = {},
733
+ ): Promise<AssetPreviewCapture> {
734
+ const data = await this.command<AssetPreviewCapture>(
735
+ {
736
+ type: 'capture-asset-preview',
737
+ ...source,
738
+ ...options,
739
+ },
740
+ // Photographing changes nothing, and this is the relay `vgai screenshot
741
+ // <module>` / `project.bake.preview` rides — the lane where a momentary
742
+ // transport failure cost a cold agent three probe modules.
743
+ { retryTransport: true },
744
+ );
745
+ return {
746
+ width: data.width,
747
+ height: data.height,
748
+ // Absent from editors that predate orientation reporting.
749
+ ...(data.orientation ? { orientation: data.orientation } : {}),
750
+ views: data.views,
751
+ contactSheet: data.contactSheet,
752
+ };
753
+ }
754
+
755
+ /**
756
+ * A project-defined labeled shot set (`vgai screenshot <target> --shots <set>`):
757
+ * the DEFINITION travels with the command (project data — see
758
+ * `AssetPreviewShotSetDefinition`; the CLI resolves it from the registered
759
+ * `project.<set>.previewShots` tool), and the editor's generic
760
+ * capture engine renders it — see `packages/editor/src/asset-preview.ts`'s
761
+ * `captureShotSetAssetPreview`. Throws (via `command`'s `{ok:false}`
762
+ * unwrap) with a clear message naming the missing joint(s) when the asset
763
+ * lacks a bone the definition requires.
764
+ */
765
+ async captureShotSetPreview(
766
+ source: AssetPreviewSource,
767
+ definition: AssetPreviewShotSetDefinition,
768
+ options: AssetPreviewOptions = {},
769
+ ): Promise<LabeledShotSetCapture> {
770
+ const data = await this.command<LabeledShotSetCapture>(
771
+ {
772
+ type: 'capture-asset-preview',
773
+ ...source,
774
+ ...options,
775
+ shotSet: definition,
776
+ },
777
+ { retryTransport: true },
778
+ );
779
+ return {
780
+ width: data.width,
781
+ height: data.height,
782
+ shots: data.shots,
783
+ // An older editor predates the empty-frame guard and sends none.
784
+ warnings: data.warnings ?? [],
785
+ contactSheet: data.contactSheet,
786
+ };
787
+ }
788
+
789
+ /**
790
+ * B8.4 — score the asset against a reference GLB (`vgai screenshot
791
+ * <model.glb> --compare <ref.glb>`): matched orthographic front + side silhouettes
792
+ * (equal-height bounding-box framing, both yaw-normalized to face the
793
+ * camera), per-view IoU numbers, and overlay evidence images. The
794
+ * reference GLB's raw bytes travel base64 in the command; the editor
795
+ * renders and scores — the SDK never interprets Three.js assets itself.
796
+ */
797
+ async captureAssetComparePreview(
798
+ source: AssetPreviewSource,
799
+ refGlbBase64: string,
800
+ options: AssetCompareOptions = {},
801
+ ): Promise<AssetCompareCapture> {
802
+ const { refForward, ...dimensions } = options;
803
+ const data = await this.command<AssetCompareCapture>({
804
+ type: 'capture-asset-preview',
805
+ ...source,
806
+ ...dimensions,
807
+ compare: { glbBase64: refGlbBase64, ...(refForward ? { forward: refForward } : {}) },
808
+ });
809
+ return { width: data.width, height: data.height, views: data.views };
810
+ }
811
+
812
+ /**
813
+ * The STORY lane (`vgai screenshot <file>.stories.tsx`): every CSF export of
814
+ * one project story file rendered in the live session's DOM and captured
815
+ * through the same composite leg {@link captureGame} uses, returned as
816
+ * per-export images plus one variant sheet. `options.story` narrows to a
817
+ * single export.
818
+ *
819
+ * Rendering happens in the EDITOR — the SDK never imports, composes or
820
+ * mounts a CSF module itself; the session already owns that machinery for
821
+ * its Stories panel and this drives it.
822
+ */
823
+ async captureStoryVariants(
824
+ modulePath: string,
825
+ options: StoryCaptureOptions = {},
826
+ ): Promise<StoryVariantCapture> {
827
+ const data = await this.command<StoryVariantCapture>({
828
+ type: 'capture-story-variants',
829
+ modulePath,
830
+ ...options,
831
+ });
832
+ return {
833
+ modulePath: data.modulePath,
834
+ width: data.width,
835
+ height: data.height,
836
+ variants: data.variants,
837
+ contactSheet: data.contactSheet,
838
+ };
839
+ }
840
+
841
+ // --- Panels ---
842
+
843
+ async showViewport(tab: ViewportTab): Promise<void> {
844
+ await this.command({ type: 'viewport-tab', tab });
845
+ }
846
+
847
+ /** Focus a static workspace panel by the key the editor's panel registry
848
+ * holds; an unknown key refuses naming the keys it does hold. */
849
+ async showPanel(panel: string): Promise<void> {
850
+ await this.command({ type: 'show-panel', panel });
851
+ }
852
+
853
+ /** Show several instances of the running game split-screen — multiplayer
854
+ * authoring. Pass a total `count` (default "Player N" labels) or an array of
855
+ * `names` (its length is the count; index 0 is the primary). Requires a live
856
+ * play session. */
857
+ async setInstanceCount(countOrNames: number | string[]): Promise<void> {
858
+ await this.command(
859
+ Array.isArray(countOrNames)
860
+ ? { type: 'set-instance-count', names: countOrNames }
861
+ : { type: 'set-instance-count', count: countOrNames },
862
+ );
863
+ }
864
+
865
+ async openAsset(path: string, kind: AssetKind): Promise<void> {
866
+ await this.command({ type: 'open-asset-tab', path, kind });
867
+ }
868
+
869
+ /** SELECT a project asset — the other half of the browser's
870
+ * selection-vs-open contract (single click selects and fills the
871
+ * Inspector; double click opens a document). */
872
+ async selectAsset(path: string): Promise<void> {
873
+ await this.command({ type: 'select-asset', path });
874
+ }
875
+
876
+ async closeAsset(key: string): Promise<void> {
877
+ await this.command({ type: 'close-asset-tab', key });
878
+ }
879
+
880
+ async toggleCommandPalette(): Promise<void> {
881
+ await this.command({ type: 'toggle-command-palette' });
882
+ }
883
+
884
+ async toggleConsole(): Promise<void> {
885
+ await this.command({ type: 'toggle-console' });
886
+ }
887
+
888
+ /** Switch the editor's NAMED WORKSPACE — the task-named layout memory
889
+ * (`game`/`model`/`sculpt`/`texture`/`animate`/`look`). Resolves once the
890
+ * dock has finished rebuilding, so a following capture photographs the
891
+ * arrangement that was asked for. */
892
+ async setWorkspace(workspace: EditorWorkspaceName): Promise<void> {
893
+ await this.command({ type: 'set-workspace', workspace });
894
+ }
895
+
896
+ /** Apply a STYLE BUNDLE — palette, material, icon set and region defaults
897
+ * in one gesture (`classic`/`glass`/`maya`/`substance`, or one a package
898
+ * the project declares carries, `blender`). */
899
+ async setStyle(style: string): Promise<void> {
900
+ await this.command({ type: 'set-style', style });
901
+ }
902
+
903
+ /** Set the MATERIAL apart from the bundle that usually carries it.
904
+ * Answers with what the chrome wears afterwards. */
905
+ async setAppearance(appearance: {
906
+ readonly material?: string;
907
+ }): Promise<{ material: string; style: string | null }> {
908
+ return this.command<{ material: string; style: string | null }>({
909
+ type: 'set-appearance',
910
+ ...appearance,
911
+ });
912
+ }
913
+
914
+ async showBuild(): Promise<void> {
915
+ await this.command({ type: 'show-build' });
916
+ }
917
+
918
+ /** Atomically present a durable editor view and return its shareable URL. */
919
+ async present(view: EditorView): Promise<PresentedEditorView> {
920
+ const presented = await this.command<PresentedEditorView>({ type: 'present-view', view });
921
+ return { view: presented.view, url: presented.url, warnings: presented.warnings };
922
+ }
923
+
924
+ /**
925
+ * The INSPECTION SUBJECT the editor is showing right now, as data — the
926
+ * serialized projection of the inspection model (design:
927
+ * `docs/ARCHITECTURE-CORE.md` §Editor chrome, "The Inspection Model").
928
+ *
929
+ * The same subject a human reads in the inspector: identity, presentation,
930
+ * verbs, and the identified sections in display order — with a `fields`
931
+ * section's CURRENT VALUES read through the same io the field rows edit
932
+ * through. With nothing selected it answers the active surface's own
933
+ * no-selection subject when it has one, exactly as the panel does; it never
934
+ * reports another surface's, and when the panel itself is unmounted it
935
+ * answers `{none: true}` rather than a subject nobody is looking at. A
936
+ * `custom` section body is a named opaque (`{kind, id, title}`) — the editor
937
+ * renders those with React — plus its displayed values under `data` when it
938
+ * has any (the Transform section's position/rotation/scale).
939
+ */
940
+ async inspect(): Promise<InspectedInspection> {
941
+ const data = await this.command<{ subject: InspectedInspection }>({ type: 'inspect' });
942
+ return data.subject;
943
+ }
944
+
945
+ /** Run one verb exposed by the active Inspector subject, by its id. */
946
+ async runInspectionAction(actionId: string): Promise<InspectedInspection> {
947
+ const data = await this.command<{ subject: InspectedInspection }>({
948
+ type: 'run-inspection-action',
949
+ actionId,
950
+ });
951
+ return data.subject;
952
+ }
953
+
954
+ /**
955
+ * Run ONE command by id — the door to everything the command palette lists.
956
+ *
957
+ * Under the Code-OSS frame this is the workbench's own `ICommandService`, so
958
+ * any command id works: a view's `vgai.<view>.<verb>`, an editor action's
959
+ * `vgai.action.<id>`, or one of VS Code's own. Standalone `vgai edit` has no
960
+ * command service and answers the `vgai.<view>.<verb>` shape directly off
961
+ * the views registry, refusing anything else BY NAME.
962
+ *
963
+ * The result is whatever the command answered — a view verb's state, or
964
+ * `null` for a command that returns nothing.
965
+ */
966
+ async runCommand(commandId: string, args?: unknown): Promise<unknown> {
967
+ const data = await this.command<{ result: unknown }>({
968
+ type: 'run-command',
969
+ commandId,
970
+ ...(args === undefined ? {} : { args }),
971
+ });
972
+ return data.result;
973
+ }
974
+
975
+ /**
976
+ * One STRUCTURE op on the authored tree — the hierarchy context menu's own
977
+ * verbs, on the same helpers, for a caller with no pointer to right-click
978
+ * with. `id`/`ids` default to the current selection.
979
+ */
980
+ async structureOp(op: StructureOp, options: StructureOpOptions = {}): Promise<StructureOpResult> {
981
+ return this.command<StructureOpResult>({ type: 'structure-op', op, ...options });
982
+ }
983
+
984
+ /** "Extract Component…" — the hierarchy row's action, as a command. Answers
985
+ * the action's own sentence, which NAMES the files it created. */
986
+ async extractComponent(options: { id?: string; name?: string } = {}): Promise<{ hint: string }> {
987
+ return this.command<{ hint: string }>({ type: 'extract-component', ...options });
988
+ }
989
+
990
+ /** "Fork Component…" — extract's twin: one new file, one callsite retargeted. */
991
+ async forkComponent(options: { id?: string } = {}): Promise<{ hint: string }> {
992
+ return this.command<{ hint: string }>({ type: 'fork-component', ...options });
993
+ }
994
+
995
+ /**
996
+ * The HIERARCHY PANEL's actual rendered row tree, as data.
997
+ *
998
+ * The same rows a human is looking at: the adapter's tree after the component
999
+ * marks fold implementation subtrees, after the internals reveal, after the
1000
+ * document promotion, the child cap, the collapse state, the search filter
1001
+ * and the selection scope. Works in play mode and edit mode alike — the
1002
+ * answer reports which (`playState`, `activeViewportTab`), because a
1003
+ * play-mode tree and an edit-mode tree come from different adapters.
1004
+ *
1005
+ * Deliberately NOT `status().entities`, which walks the raw adapter tree and
1006
+ * therefore answers a different question: a panel defect is invisible in it.
1007
+ *
1008
+ * Each row carries `childCount` (what its caret opens), `internalChildCount`
1009
+ * (what is folded behind "Reveal Internals") and `expandable` (whether the
1010
+ * panel draws a caret at all) — so "this subtree exists but the UI offers no
1011
+ * way to open it" is a readable fact rather than something only a human
1012
+ * squinting at the panel can notice.
1013
+ *
1014
+ * Rejects, naming the panel, when no hierarchy panel is mounted: an empty
1015
+ * tree would be a fabricated answer about a surface nobody is being shown.
1016
+ */
1017
+ async hierarchy(): Promise<InspectedHierarchy> {
1018
+ const data = await this.command<{ hierarchy: InspectedHierarchy }>({ type: 'hierarchy' });
1019
+ return data.hierarchy;
1020
+ }
1021
+
1022
+ /** Run the Hierarchy panel's own Expand All action. */
1023
+ async expandHierarchyAll(): Promise<void> {
1024
+ await this.command<Record<string, never>>({ type: 'expand-hierarchy-all' });
1025
+ }
1026
+
1027
+ /** Run the Hierarchy panel's own Collapse All action — Expand All's other
1028
+ * half, and the only way back to the tree's rest state through the product
1029
+ * (see `HierarchyPanelSnapshot.collapseAll`). */
1030
+ async collapseHierarchyAll(): Promise<void> {
1031
+ await this.command<Record<string, never>>({ type: 'collapse-hierarchy-all' });
1032
+ }
1033
+
1034
+ /**
1035
+ * Write one editable path through the active Inspector's own IO.
1036
+ *
1037
+ * The answer carries `write` as well as the subject, because an ack alone
1038
+ * cannot be believed: a write with no persistence route open succeeds and
1039
+ * changes no byte, and `write.persisted` is how the caller tells the two
1040
+ * apart without diffing the tree (`InspectedWriteDestination` in `types.ts`).
1041
+ */
1042
+ async setInspectionField(path: string, value: unknown): Promise<InspectedFieldWrite> {
1043
+ const data = await this.command<InspectedFieldWrite>({
1044
+ type: 'set-inspection-field',
1045
+ path,
1046
+ value,
1047
+ });
1048
+ return { subject: data.subject, write: data.write };
1049
+ }
1050
+
1051
+ /**
1052
+ * REMOVE one editable path's authored override — the other half of the write
1053
+ * door, and the only one that can express byte-ABSENCE.
1054
+ *
1055
+ * {@link setInspectionField} writes a VALUE, so reverting a property an
1056
+ * authoring gesture ADDED puts the default back EXPLICITLY and leaves the
1057
+ * source one attribute heavier than it started. This drops the property, so
1058
+ * whatever governs it in its absence takes over — the same `io.remove` the
1059
+ * Inspector's revert arrow calls, the same persistence pipe, the same awaited
1060
+ * `{ destination, persisted }` ack.
1061
+ *
1062
+ * Rejects with `code: 'REMOVAL_UNAVAILABLE'` when the field does not declare
1063
+ * itself removable or the lane implements no removal door. That refusal is a
1064
+ * MISSING SEAM, not a failed removal, and it is coded rather than phrased
1065
+ * precisely so a caller can grade the two differently.
1066
+ */
1067
+ async removeInspectionField(path: string): Promise<InspectedFieldWrite> {
1068
+ const data = await this.command<InspectedFieldWrite>({
1069
+ type: 'remove-inspection-field',
1070
+ path,
1071
+ });
1072
+ return { subject: data.subject, write: data.write };
1073
+ }
1074
+
1075
+ /**
1076
+ * OPEN one piece of the adapter's SCENE TABLE by id — a scene, a prefab, or
1077
+ * a story state, because the table makes them siblings (they differ only in
1078
+ * instance site). The ids are exactly what `getState().adapter.scenes.entries`
1079
+ * reports, so the table is both the menu and the address space.
1080
+ *
1081
+ * With a game LIVE in the session, opening a scene the adapter declares
1082
+ * reachable through that game's own scenes contract NAVIGATES it — the same
1083
+ * switch the editor's own scene picker makes — and the answer carries the
1084
+ * game's own reading (`scene`).
1085
+ *
1086
+ * Rejects with a coded reason rather than prose: `SCENE_NOT_FOUND` (and it
1087
+ * names the ids that DO exist), `SCENE_NOT_OPENABLE` carrying the adapter's
1088
+ * own declared reason for a scene it says nothing can reach,
1089
+ * `SCENE_NAVIGATION_NOT_RUNNING` for a live-only scene with no game running,
1090
+ * `SCENE_CONTRACT_UNAVAILABLE` / `SCENE_NOT_IN_CONTRACT` (naming the ids the
1091
+ * game itself publishes) / `SCENE_SWITCH_FAILED` when the running game's own
1092
+ * navigation cannot take it, `SCENE_NOT_OPENABLE_LIVE` when this session has
1093
+ * no remount for a native swap-slot scene,
1094
+ * `SCENE_TABLE_UNAVAILABLE` before the adapter has loaded, and
1095
+ * `SCENE_DOCUMENT_NOT_MOUNTED` when the host has no document for a piece the
1096
+ * table says is openable — a host gap, not a table statement.
1097
+ */
1098
+ async open(id: string): Promise<OpenedDocument> {
1099
+ return this.command<OpenedDocument>({ type: 'open', id });
1100
+ }
1101
+
1102
+ /**
1103
+ * Undo / redo one project transaction — the same queue the keyboard shortcut
1104
+ * drives. `moved` is false when there was nothing left in that direction.
1105
+ */
1106
+ async undo(): Promise<HistoryStep> {
1107
+ return this.command<HistoryStep>({ type: 'undo' });
1108
+ }
1109
+
1110
+ async redo(): Promise<HistoryStep> {
1111
+ return this.command<HistoryStep>({ type: 'redo' });
1112
+ }
1113
+
1114
+ /** Read the editor's actual current durable projection. */
1115
+ async currentView(): Promise<EditorView> {
1116
+ const data = await this.command<{ view: EditorView }>({ type: 'current-view' });
1117
+ return data.view;
1118
+ }
1119
+
1120
+ /**
1121
+ * Capture the active center document exactly as presented to the user.
1122
+ *
1123
+ * A number is a SQUARE of that size (the default shape); `{width, height}`
1124
+ * asks for a shaped frame — a video-aspect look that needs no crop. Both are
1125
+ * bounded by the relay budget; see {@link CaptureDimensions}.
1126
+ */
1127
+ /** Photograph the editor PAGE itself — every panel as the person sees it, at
1128
+ * `scale` output pixels per CSS pixel (default `devicePixelRatio`), which is
1129
+ * what a 1 px border or a glyph edge is judged through. */
1130
+ async captureEditorChrome(options?: EditorChromeCaptureOptions): Promise<EditorChromeCapture> {
1131
+ return this.command<EditorChromeCapture>({
1132
+ type: 'capture-editor-chrome',
1133
+ ...(options?.scale === undefined ? {} : { scale: options.scale }),
1134
+ });
1135
+ }
1136
+
1137
+ /** With a view, present and capture it in one request so document discovery
1138
+ * cannot retarget the capture between two client calls. */
1139
+ async captureActiveDocument(
1140
+ size?: CaptureDimensions,
1141
+ view?: EditorView,
1142
+ ): Promise<ActiveDocumentCapture> {
1143
+ return this.command<ActiveDocumentCapture>({
1144
+ type: 'capture-active-document',
1145
+ ...(view ? { view } : {}),
1146
+ ...(typeof size === 'number' ? { size } : {}),
1147
+ ...(typeof size === 'object' && size !== null
1148
+ ? { width: size.width, height: size.height }
1149
+ : {}),
1150
+ });
1151
+ }
1152
+
1153
+ /**
1154
+ * Read or drive the ACTIVE center document's own DOM — the scoped
1155
+ * editor-chrome door, and the read/gesture half of the same subject
1156
+ * {@link captureActiveDocument} photographs. NOT play-mode gated, and NOT
1157
+ * page automation: a target outside the active document's container is
1158
+ * refused by name. Design and scope contract:
1159
+ * `packages/editor/src/editor-document-probe.ts`.
1160
+ */
1161
+ async documentProbe(step: DocumentProbeStep): Promise<DocumentProbeResult> {
1162
+ return this.command<DocumentProbeResult>({ type: 'document-probe', step });
1163
+ }
1164
+
1165
+ /**
1166
+ * Run a wire-carried step against the ACTIVE document's published context
1167
+ * (`packages/editor/src/document-context-registry.ts`) — the REPL door over
1168
+ * an open document, in Edit mode. `src` is the step's own `toString()`;
1169
+ * same serialization contract as `page-script` (no closures survive).
1170
+ */
1171
+ /**
1172
+ * The Blender lane's doors (`blender-execute`, `blender-scene-info`,
1173
+ * `blender-object-info`, `blender-screenshot-view`, `blender-read-file`,
1174
+ * `blender-write-file`, `blender-list-files`, `blender-start`,
1175
+ * `blender-status`): Blender runs in the editor tab's worker, and
1176
+ * `vgai blender-mcp` is transport onto these. `blender-status` is the only
1177
+ * one that creates nothing — it answers whether this tab already has a
1178
+ * session, which is how a caller survives an editor restart.
1179
+ */
1180
+ async blender<T extends object = Record<string, unknown>>(
1181
+ type: `blender-${string}`,
1182
+ fields: Record<string, unknown> = {},
1183
+ ): Promise<T> {
1184
+ // One modeling chunk can run for minutes in the tab (an exact boolean
1185
+ // over a dense mesh measured 80-90 s under Wasm); the relay's server-side
1186
+ // budget for blender-execute is the same half hour.
1187
+ return this.command<T>({ type, ...fields }, { deadlineMs: BLENDER_DEADLINE_MS });
1188
+ }
1189
+
1190
+ async documentScript<T = unknown>(src: string): Promise<T> {
1191
+ const outcome = await this.command<{ result: T }>({ type: 'document-script', src });
1192
+ return outcome.result;
1193
+ }
1194
+
1195
+ // --- Display (set semantics) ---
1196
+
1197
+ async setGrid(enabled: boolean): Promise<void> {
1198
+ await this.command({ type: 'set-grid', enabled });
1199
+ }
1200
+
1201
+ async setHelpers(enabled: boolean): Promise<void> {
1202
+ await this.command({ type: 'set-helpers', enabled });
1203
+ }
1204
+
1205
+ async setStats(enabled: boolean): Promise<void> {
1206
+ await this.command({ type: 'set-stats', enabled });
1207
+ }
1208
+
1209
+ async setShadingMode(mode: ShadingMode): Promise<void> {
1210
+ await this.command({ type: 'set-shading-mode', mode });
1211
+ }
1212
+
1213
+ async setHelperType(helperType: keyof HelperVisibility, enabled: boolean): Promise<void> {
1214
+ await this.command({ type: 'set-helper-type', helperType, enabled });
1215
+ }
1216
+
1217
+ // --- Transform tools (set semantics) ---
1218
+
1219
+ async setTransformMode(mode: TransformMode): Promise<void> {
1220
+ await this.command({ type: 'set-transform-mode', mode });
1221
+ }
1222
+
1223
+ async setTransformSpace(space: TransformSpace): Promise<void> {
1224
+ await this.command({ type: 'set-transform-space', space });
1225
+ }
1226
+
1227
+ async setSnap(enabled: boolean): Promise<void> {
1228
+ await this.command({ type: 'set-snap', enabled });
1229
+ }
1230
+
1231
+ // --- Project management ---
1232
+
1233
+ async createProject(
1234
+ name: string,
1235
+ location: string,
1236
+ template: ProjectTemplate = 'default',
1237
+ exampleId?: string,
1238
+ ): Promise<ProjectInfo> {
1239
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/create-project`, {
1240
+ method: 'POST',
1241
+ headers: { 'Content-Type': 'application/json' },
1242
+ body: JSON.stringify({ name, location, template, ...(exampleId ? { exampleId } : {}) }),
1243
+ });
1244
+ const data = (await this.readJson(res)) as {
1245
+ ok?: boolean;
1246
+ error?: string;
1247
+ path?: string;
1248
+ config?: ProjectInfo['config'];
1249
+ };
1250
+ if (!res.ok) {
1251
+ throw new Error(data.error ?? `Create project failed: ${res.status}`);
1252
+ }
1253
+ return { path: data.path as string, config: data.config as ProjectInfo['config'] };
1254
+ }
1255
+
1256
+ async openProject(path: string): Promise<void> {
1257
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/open-project`, {
1258
+ method: 'POST',
1259
+ headers: { 'Content-Type': 'application/json' },
1260
+ body: JSON.stringify({ path }),
1261
+ });
1262
+ const body = (await this.readJson(res)) as { error?: string };
1263
+ if (!res.ok) {
1264
+ throw new Error(body.error ?? `Open project failed: ${res.status}`);
1265
+ }
1266
+ }
1267
+
1268
+ async getProject(): Promise<ProjectInfo | null> {
1269
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/project`);
1270
+ const data = (await this.readJson(res)) as { project: ProjectInfo | null; error?: string };
1271
+ if (!res.ok) throw new Error(data.error ?? `Failed to get project: ${res.status}`);
1272
+ return data.project;
1273
+ }
1274
+
1275
+ async listRecentProjects(): Promise<RecentProject[]> {
1276
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/recent-projects`);
1277
+ const data = (await this.readJson(res)) as { projects: RecentProject[]; error?: string };
1278
+ if (!res.ok) throw new Error(data.error ?? `Failed to list projects: ${res.status}`);
1279
+ return data.projects;
1280
+ }
1281
+
1282
+ // --- Registered project tools ---
1283
+
1284
+ /** List tools explicitly registered in `package.json#vgai.tools`.
1285
+ * The editor server loads callable metadata in Node; modules never enter the
1286
+ * editor browser merely because they were listed. */
1287
+ async listProjectTools(): Promise<ProjectToolCatalog> {
1288
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/project-tools`);
1289
+ const body = await this.readJson(res);
1290
+ if (!res.ok) throw new Error(`Failed to list project tools: ${res.status}`);
1291
+ return body as ProjectToolCatalog;
1292
+ }
1293
+
1294
+ /** Execute one Node-hosted project tool through the shared validated
1295
+ * dispatcher. Write/destructive tools require `confirm:true`. */
1296
+ async runProjectTool(
1297
+ name: string,
1298
+ input: unknown = {},
1299
+ options: { confirm?: boolean; instance?: string } = {},
1300
+ ): Promise<ProjectToolOutcome> {
1301
+ // Provider subscriptions can outlive Node fetch's five-minute header limit.
1302
+ // Match browser fetch for this operation; dispose its sockets after reading the result.
1303
+ const dispatcher = createDispatcher(0);
1304
+ try {
1305
+ const res = await this.httpFetch(
1306
+ `${this.baseUrl}/__editor/project-tools/run`,
1307
+ {
1308
+ method: 'POST',
1309
+ headers: { 'Content-Type': 'application/json' },
1310
+ body: JSON.stringify({
1311
+ name,
1312
+ input,
1313
+ confirm: options.confirm === true,
1314
+ // Omitted (not null) when unset — the wire body is JSON and the tool
1315
+ // host reads absence as "the sole instance", same convention as the
1316
+ // relay's `instance`.
1317
+ ...(options.instance !== undefined ? { instance: options.instance } : {}),
1318
+ }),
1319
+ },
1320
+ dispatcher,
1321
+ );
1322
+ const body = (await this.readJson(res)) as ProjectToolOutcome;
1323
+ if (!body || typeof body !== 'object' || typeof body.ok !== 'boolean') {
1324
+ throw new Error(`Project tool returned an invalid response (${res.status}).`);
1325
+ }
1326
+ return body;
1327
+ } finally {
1328
+ await dispatcher?.destroy?.();
1329
+ }
1330
+ }
1331
+
1332
+ // --- First-party generation job activity ---
1333
+
1334
+ /** Read the one project-local generation job ledger. Provider-native
1335
+ * request/result shapes remain on their registered operations. */
1336
+ async listGenerationJobs(): Promise<GenerationJobsDocument> {
1337
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/generations`);
1338
+ const body = await this.readJson(res);
1339
+ if (!res.ok) throw new Error(`Failed to list generation jobs: ${res.status}`);
1340
+ return body as GenerationJobsDocument;
1341
+ }
1342
+
1343
+ /** Forget operational job state. Accepted provenance and project assets
1344
+ * are deliberately unaffected. */
1345
+ async forgetGenerationJob(id: string): Promise<boolean> {
1346
+ const res = await this.httpFetch(
1347
+ `${this.baseUrl}/__editor/generations/${encodeURIComponent(id)}`,
1348
+ {
1349
+ method: 'DELETE',
1350
+ },
1351
+ );
1352
+ const body = (await this.readJson(res)) as { removed?: boolean; error?: string };
1353
+ if (!res.ok) throw new Error(body.error ?? `Failed to forget generation job: ${res.status}`);
1354
+ return body.removed === true;
1355
+ }
1356
+
1357
+ // --- Logs ---
1358
+
1359
+ async getLogEntries(): Promise<
1360
+ Array<{ t: number; level: string; msg: string; source?: string }>
1361
+ > {
1362
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/log-entries`);
1363
+ const data = (await this.readJson(res)) as {
1364
+ entries: Array<{ t: number; level: string; msg: string; source?: string }>;
1365
+ };
1366
+ if (!res.ok) return [];
1367
+ return data.entries;
1368
+ }
1369
+
1370
+ // --- State ---
1371
+
1372
+ /**
1373
+ * The document table the host resolved — every scene, prefab, page, model,
1374
+ * shot, take … the project's finders produced (`getState().adapter.scenes`
1375
+ * is the same projection). A command, so it answers wherever the control
1376
+ * channel reaches, not only where `/__editor/state` is served.
1377
+ */
1378
+ async documentTable(): Promise<DocumentTableProjection> {
1379
+ return this.command<DocumentTableProjection>({ type: 'document-table' });
1380
+ }
1381
+
1382
+ async getState(): Promise<EditorState> {
1383
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/state`);
1384
+ const state = (await this.readJson(res)) as EditorState;
1385
+ if (!res.ok) throw new Error(`Failed to get editor state: ${res.status} ${res.statusText}`);
1386
+ return state;
1387
+ }
1388
+
1389
+ /**
1390
+ * The complete unresolved console set the session is holding right now.
1391
+ *
1392
+ * Command envelopes only carry COUNTS (`unresolvedConsole` on
1393
+ * `commandResponseFor`). The named conditions live on GET `/__editor/console`.
1394
+ * This is the method that turns "a command that exits before an envelope
1395
+ * arrives" into a real reading: the CLI calls it at start and at exit
1396
+ * through the same {@link onEnvelope} observer every other response uses.
1397
+ * A session that does not answer within {@link CONSOLE_DRAIN_TIMEOUT_MS} is
1398
+ * a thrown error the caller treats as "nothing learned", never a hang.
1399
+ */
1400
+ async getUnresolvedConsole(opts?: { all?: boolean }): Promise<unknown> {
1401
+ const res = await this.httpFetch(
1402
+ `${this.baseUrl}/__editor/console${opts?.all === true ? '?all=1' : ''}`,
1403
+ { signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS) },
1404
+ );
1405
+ // Status first: a 404's empty body would otherwise die inside readJson
1406
+ // with a parse error that hides the one fact the caller classifies on
1407
+ // (does this server SERVE the console route at all?).
1408
+ if (!res.ok) {
1409
+ throw new Error(`Failed to read unresolved console: ${res.status}`);
1410
+ }
1411
+ return await this.readJson(res, true);
1412
+ }
1413
+
1414
+ /** Acknowledge one named console condition. The response is observed and
1415
+ * hydrated through the same path as every other client response. */
1416
+ async acknowledgeConsole(input: {
1417
+ readonly id: string;
1418
+ readonly reason: string;
1419
+ readonly by: string;
1420
+ }): Promise<unknown> {
1421
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/console/ack`, {
1422
+ method: 'POST',
1423
+ headers: { 'Content-Type': 'application/json' },
1424
+ body: JSON.stringify(input),
1425
+ signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS * 4),
1426
+ });
1427
+ const body = await this.readJson(res);
1428
+ return body;
1429
+ }
1430
+
1431
+ /**
1432
+ * `fetch` for this client's plain routes, with the ONE thing Node's `fetch`
1433
+ * will not do: name why it failed.
1434
+ *
1435
+ * `readJson` below already owns "the server answered the wrong thing"; this
1436
+ * owns "nothing answered at all", which used to reach the caller as the bare
1437
+ * `TypeError: fetch failed` with the real code buried on `.cause`. No retry
1438
+ * here — these routes create projects, run tools and acknowledge console
1439
+ * conditions, so repeating one is the caller's decision. The relayed
1440
+ * `command` path above has its own opt-in retry for the read-only captures.
1441
+ */
1442
+ private async httpFetch(
1443
+ url: string,
1444
+ init?: RequestInit,
1445
+ dispatcher?: Dispatcher,
1446
+ ): Promise<Response> {
1447
+ try {
1448
+ return await dispatchFetch(url, init, dispatcher);
1449
+ } catch (error) {
1450
+ if ((error as { name?: string } | null)?.name === 'TimeoutError') throw error;
1451
+ const { code, detail } = describeFetchFailure(error);
1452
+ throw new EditorCommandError(
1453
+ `${init?.method ?? 'GET'} ${url} never reached the editor: ${code}` +
1454
+ `${detail ? ` (${detail})` : ''}. Nothing answered on that port — check the terminal ` +
1455
+ 'running `volter-editor edit` and confirm the port this client resolved.',
1456
+ code,
1457
+ false,
1458
+ );
1459
+ }
1460
+ }
1461
+
1462
+ /** Parse one JSON body and hand it to {@link onEnvelope}.
1463
+ *
1464
+ * A command/state envelope carries current counts but not the named set. If
1465
+ * an observer is installed, do the bounded console GET before resolving the
1466
+ * original request. That makes a subsequent `process.exit()` safe: the
1467
+ * observer has already received every condition and occurrence count. */
1468
+ private async readJson(res: Response, consoleComplete = false): Promise<unknown> {
1469
+ // Every one of this client's twelve routes funnels through here, so this is
1470
+ // where "did the editor server answer?" is asked — the same question, and
1471
+ // the same JSON-content-type rule, that
1472
+ // `packages/editor/src/editor-server-response.ts` owns on the browser side.
1473
+ // It is asked again rather than imported because THIS package is published
1474
+ // and depends on neither `@volter/editor-project` nor the editor bundle (see
1475
+ // `DEFAULT_URL` above for that policy).
1476
+ //
1477
+ // Not theoretical here: `baseUrl` is whatever `--url`/`VGAI_EDITOR_URL`
1478
+ // says, so the CLI is routinely pointed at a SHARE TUNNEL or a static host
1479
+ // — both of which answer `200 text/html` for a route nothing serves, and
1480
+ // `res.json()` then died as `Unexpected token '<'`, naming neither the URL
1481
+ // nor the cause.
1482
+ const contentType = res.headers.get('content-type') ?? '';
1483
+ if (!contentType.includes('application/json')) {
1484
+ throw new Error(
1485
+ `The editor at ${this.baseUrl} answered with its page fallback ` +
1486
+ `(${contentType || 'no content-type'}) rather than JSON, so no editor server handled ` +
1487
+ 'the request. Check that this URL is a running `volter-editor edit` session.',
1488
+ );
1489
+ }
1490
+ const body: unknown = await res.json();
1491
+ this.observe(body, { unresolvedConsoleComplete: consoleComplete });
1492
+ if (
1493
+ this.onEnvelope !== null &&
1494
+ !consoleComplete &&
1495
+ body !== null &&
1496
+ typeof body === 'object' &&
1497
+ 'unresolvedConsole' in body
1498
+ ) {
1499
+ try {
1500
+ const consoleRes = await this.httpFetch(`${this.baseUrl}/__editor/console`, {
1501
+ signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS),
1502
+ });
1503
+ if (consoleRes.ok) {
1504
+ const consoleBody: unknown = await consoleRes.json();
1505
+ this.observe(consoleBody, { unresolvedConsoleComplete: true });
1506
+ }
1507
+ } catch {
1508
+ // The original response remains authoritative. A reporting follow-up
1509
+ // may degrade to its count-only envelope, never break the command.
1510
+ }
1511
+ }
1512
+ return body;
1513
+ }
1514
+
1515
+ /** Hand one response body to {@link onEnvelope}, never letting it throw. */
1516
+ private observe(body: unknown, observation: EditorEnvelopeObservation): void {
1517
+ if (this.onEnvelope === null) return;
1518
+ try {
1519
+ this.onEnvelope(body, observation);
1520
+ } catch {
1521
+ // A reporting hook may never break the command it is reporting on.
1522
+ }
1523
+ }
1524
+
1525
+ /**
1526
+ * Whether an editor browser tab is connected to the server *right now*.
1527
+ * Unlike {@link getState}, this reflects live SSE connections, not cached
1528
+ * state — use it to check whether commands will actually reach an editor.
1529
+ */
1530
+ async isConnected(): Promise<boolean> {
1531
+ const state = await this.getState();
1532
+ return state.connected === true;
1533
+ }
1534
+
1535
+ async waitForState(
1536
+ predicate: (s: EditorState) => boolean,
1537
+ timeoutMs = 10_000,
1538
+ ): Promise<EditorState> {
1539
+ const start = Date.now();
1540
+ while (Date.now() - start < timeoutMs) {
1541
+ const state = await this.getState();
1542
+ if (predicate(state)) return state;
1543
+ await new Promise((r) => setTimeout(r, 100));
1544
+ }
1545
+ throw new Error(`waitForState timed out after ${timeoutMs}ms`);
1546
+ }
1547
+ }