@volter/live 0.5.210 → 0.5.212

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 (67) hide show
  1. package/BUNDLED_NOTICES +13 -13
  2. package/README.md +7 -11
  3. package/dist/chunk-7SZ3C4CC.js +768 -0
  4. package/dist/chunk-7SZ3C4CC.js.map +7 -0
  5. package/dist/chunk-7WKVJGHZ.js +15 -0
  6. package/dist/chunk-7WKVJGHZ.js.map +7 -0
  7. package/dist/chunk-SDIWRX5X.js +189 -0
  8. package/dist/chunk-SDIWRX5X.js.map +7 -0
  9. package/dist/chunk-USXOIBVX.js +81 -0
  10. package/dist/chunk-USXOIBVX.js.map +7 -0
  11. package/dist/chunk-Y5W77HYT.js +123 -0
  12. package/dist/chunk-Y5W77HYT.js.map +7 -0
  13. package/dist/chunk-YPAOTPIF.js +202 -0
  14. package/dist/chunk-YPAOTPIF.js.map +7 -0
  15. package/dist/client.d.ts +370 -0
  16. package/dist/client.js +9 -0
  17. package/dist/client.js.map +7 -0
  18. package/dist/discovery.d.ts +32 -0
  19. package/dist/discovery.js +19 -0
  20. package/dist/discovery.js.map +7 -0
  21. package/dist/editor-document.d.ts +12 -47
  22. package/dist/editor.d.ts +34 -181
  23. package/dist/http-transport.d.ts +6 -0
  24. package/dist/index.d.ts +1 -5
  25. package/dist/index.js +76 -1722
  26. package/dist/index.js.map +4 -4
  27. package/dist/installed-package.d.ts +6 -0
  28. package/dist/installed-package.js +7 -0
  29. package/dist/installed-package.js.map +7 -0
  30. package/dist/lazy-proxy.d.ts +11 -11
  31. package/dist/member-hints.d.ts +6 -9
  32. package/dist/product-create.d.ts +29 -0
  33. package/dist/product-create.js +13 -0
  34. package/dist/product-create.js.map +7 -0
  35. package/dist/product-locator.d.ts +100 -0
  36. package/dist/product-locator.js +25 -0
  37. package/dist/product-locator.js.map +7 -0
  38. package/dist/registry-format.d.ts +44 -0
  39. package/dist/registry-format.js +21 -0
  40. package/dist/registry-format.js.map +7 -0
  41. package/dist/session.d.ts +10 -3
  42. package/dist/singleton.d.ts +1 -13
  43. package/dist/volter-manifest.d.ts +55 -0
  44. package/dist/volter-manifest.js +9 -0
  45. package/dist/volter-manifest.js.map +7 -0
  46. package/dist/workbench-locator.d.ts +84 -0
  47. package/dist/workbench-locator.js +416 -0
  48. package/dist/workbench-locator.js.map +7 -0
  49. package/package.json +35 -3
  50. package/src/client.ts +1052 -0
  51. package/src/discovery.ts +105 -0
  52. package/src/editor-document.ts +12 -49
  53. package/src/editor.ts +45 -212
  54. package/src/http-transport.ts +19 -0
  55. package/src/index.ts +3 -7
  56. package/src/installed-package.ts +15 -0
  57. package/src/lazy-proxy.ts +11 -11
  58. package/src/member-hints.ts +8 -12
  59. package/src/product-create.ts +38 -0
  60. package/src/product-locator.ts +327 -0
  61. package/src/registry-format.ts +185 -0
  62. package/src/session.ts +38 -66
  63. package/src/singleton.ts +1 -16
  64. package/src/volter-manifest.ts +97 -0
  65. package/src/workbench-locator.ts +625 -0
  66. package/dist/tools.d.ts +0 -23
  67. package/src/tools.ts +0 -45
package/src/client.ts ADDED
@@ -0,0 +1,1052 @@
1
+ import type { Dispatcher } from 'undici';
2
+ import { createDispatcher, dispatchFetch } from './http-transport.js';
3
+ import { verbCommandId } from '@volter/sdk/commands';
4
+ import type { DocumentProbeResult, DocumentProbeStep } from '@volter/sdk/document-probe';
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
+ StageFrameCostReading,
18
+ DocumentTableProjection,
19
+ EditorChromeCapture,
20
+ EditorChromeCaptureOptions,
21
+ EditorState,
22
+ EditorView,
23
+ EditorWorkspaceName,
24
+ HelperVisibility,
25
+ InspectedFieldWrite,
26
+ InspectedHierarchy,
27
+ InspectedInspection,
28
+ LabeledShotSetCapture,
29
+ PresentedEditorView,
30
+ ProjectInfo,
31
+ RecentProject,
32
+ ShadingMode,
33
+ StructureOp,
34
+ StructureOpOptions,
35
+ StructureOpResult,
36
+ TransformMode,
37
+ TransformSpace,
38
+ Vec3Value,
39
+ ViewPreset,
40
+ ViewportCapture,
41
+ } from '@volter/sdk';
42
+
43
+ // The editor's default origin, spelled out because this package deliberately
44
+ // does not depend on `@volter/project`. `DEFAULT_EDITOR_PORT` in
45
+ // `packages/project/src/manifest/editor-port.ts` is the owner of the number and
46
+ // of the never-`localhost` rule; keep this in step with it.
47
+ const DEFAULT_URL = 'http://127.0.0.1:20173';
48
+
49
+ /**
50
+ * A refused editor command, carrying the relay's own STRUCTURED failure code
51
+ * (`/__editor/command`'s `{ ok: false, error, code }`) alongside the prose, so
52
+ * a caller that reacts to a specific refusal matches the code, never the
53
+ * sentence.
54
+ */
55
+ export class EditorCommandError extends Error {
56
+ readonly code: string | undefined;
57
+ constructor(message: string, code?: string | undefined) {
58
+ super(message);
59
+ this.name = 'EditorCommandError';
60
+ this.code = code;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * The client's own ceiling on ONE relayed command.
66
+ *
67
+ * A backstop for a LOST server, not a per-command budget: the server already
68
+ * owns per-type budgets (`server/server-utils.ts`'s `relayCommandTimeoutMs`)
69
+ * and its timer must always be the one that fires, because its message names
70
+ * the tab and the remedy while this one can only say "no answer". So this is
71
+ * deliberately ONE number, comfortably above the longest server budget
72
+ * (120s, `play`/`capture-story-variants`) rather than a mirror of that table —
73
+ * a second copy of it would drift silently, and the drift would show up as
74
+ * this timer winning a race it must always lose.
75
+ *
76
+ * Without it a `fetch` with no `AbortSignal` waits on the OS: a dev server
77
+ * that stops answering mid-command holds the CLI open indefinitely, with no
78
+ * output and nothing to read.
79
+ */
80
+ const COMMAND_DEADLINE_MS = 150_000;
81
+ /** undici's default `headersTimeout`; a command deadline beyond it needs its own dispatcher (see `command`). */
82
+ const UNDICI_DEFAULT_HEADERS_TIMEOUT_MS = 300_000;
83
+
84
+ /** Deadline for console-ledger reads and acknowledgements. */
85
+ const CONSOLE_DRAIN_TIMEOUT_MS = 1_500;
86
+
87
+ /**
88
+ * Node's `fetch` collapses EVERY network-layer failure into one two-word
89
+ * `TypeError: fetch failed`. The real reason — `ECONNREFUSED`, `ECONNRESET`,
90
+ * `EPIPE`, a DNS miss — lives only on `error.cause` (sometimes two links down,
91
+ * or inside an `AggregateError`), and nothing prints it unless something walks
92
+ * the chain, so a bare "fetch failed" cannot tell a dead editor from a
93
+ * momentary one. This walks the chain so the message can say which.
94
+ */
95
+ function describeFetchFailure(error: unknown): { code: string; detail: string } {
96
+ const messages: string[] = [];
97
+ let current: unknown = error;
98
+ for (let depth = 0; depth < 8; depth++) {
99
+ if (!(current instanceof Error)) break;
100
+ if (current.message) messages.push(current.message);
101
+ const code = (current as { code?: unknown }).code;
102
+ if (typeof code === 'string' && code !== '') {
103
+ return { code, detail: messages.join(' <- ') };
104
+ }
105
+ const aggregate = (current as { errors?: unknown }).errors;
106
+ if (Array.isArray(aggregate) && aggregate.length > 0) {
107
+ const inner = describeFetchFailure(aggregate[0]);
108
+ return { code: inner.code, detail: [...messages, inner.detail].join(' <- ') };
109
+ }
110
+ current = (current as { cause?: unknown }).cause;
111
+ }
112
+ return { code: 'UNKNOWN', detail: messages.join(' <- ') || String(error) };
113
+ }
114
+
115
+ /**
116
+ * Connection failures eligible for one retry when a command opts in. An editor
117
+ * response is not a transport failure and does not use this retry path.
118
+ */
119
+ const RETRYABLE_TRANSPORT_CODES = new Set([
120
+ 'ECONNREFUSED',
121
+ 'ECONNRESET',
122
+ 'EPIPE',
123
+ 'ETIMEDOUT',
124
+ 'EHOSTUNREACH',
125
+ 'UND_ERR_SOCKET',
126
+ 'UND_ERR_CONNECT_TIMEOUT',
127
+ ]);
128
+
129
+ /** Gap before the one automatic retry — long enough for a dev-server restart's
130
+ * listen socket to come back, short enough to stay invisible. */
131
+ const TRANSPORT_RETRY_DELAY_MS = 400;
132
+
133
+ /** What one undo/redo step reports back — `moved` is false when there was
134
+ * nothing left in that direction, which is an answer, not an error. */
135
+ export interface HistoryStep {
136
+ readonly moved: boolean;
137
+ readonly canUndo: boolean;
138
+ readonly canRedo: boolean;
139
+ readonly undoLabel: string | null;
140
+ readonly redoLabel: string | null;
141
+ }
142
+
143
+ /** What `open()` acknowledges: the workspace document the scene-table entry
144
+ * resolved to, and the title its tab now carries — the project's own word for
145
+ * that composition, not a filename. */
146
+ export interface OpenedDocument {
147
+ readonly documentId: string;
148
+ readonly title: string;
149
+ }
150
+
151
+ export class EditorClient {
152
+ private readonly baseUrl: string;
153
+ private readonly fetchOverride: typeof fetch | undefined;
154
+ private readonly transport:
155
+ | ((body: Record<string, unknown>) => Promise<Record<string, unknown>>)
156
+ | null;
157
+
158
+ constructor(opts?: {
159
+ url?: string;
160
+ /** An authenticated transport for the same HTTP routes (e.g. a hosted tab). */
161
+ fetch?: typeof fetch;
162
+ /**
163
+ * An IN-PAGE command channel, for a client that lives inside the editor
164
+ * page itself and has no editor server to reach. Given, every command goes through it instead of
165
+ * `POST /__editor/command`, and answers in the route's own body shape
166
+ * (`{ ok: true, ...data }` / `{ ok: false, error, code? }`).
167
+ */
168
+ transport?: (body: Record<string, unknown>) => Promise<Record<string, unknown>>;
169
+ }) {
170
+ if (opts !== undefined && (typeof opts !== 'object' || opts === null || Array.isArray(opts))) {
171
+ throw new TypeError(
172
+ 'EditorClient options must be an object. Use new EditorClient({ url: "http://127.0.0.1:20173" }), not new EditorClient("...").',
173
+ );
174
+ }
175
+ const unknownOptions = Object.keys(opts ?? {}).filter(
176
+ (key) => key !== 'url' && key !== 'transport' && key !== 'fetch',
177
+ );
178
+ if (unknownOptions.length > 0) {
179
+ throw new Error(
180
+ `EditorClient: unknown option${unknownOptions.length === 1 ? '' : 's'} ${unknownOptions.map((key) => `"${key}"`).join(', ')}. Use { url: "http://127.0.0.1:<port>" } to target an editor.`,
181
+ );
182
+ }
183
+ this.baseUrl = (opts?.url ?? DEFAULT_URL).replace(/\/$/, '');
184
+ this.fetchOverride = opts?.fetch;
185
+ this.transport = opts?.transport ?? null;
186
+ }
187
+
188
+ /**
189
+ * `retryTransport` opts a command into ONE automatic retry after a transport
190
+ * failure (see {@link RETRYABLE_TRANSPORT_CODES}). It is deliberately
191
+ * OPT-IN and off by default: a socket that died after the request was written
192
+ * cannot prove the editor did not already run the command, so a blanket retry
193
+ * would risk playing/stopping/writing twice. Read-only relays — the captures —
194
+ * have no such hazard and turn it on.
195
+ */
196
+ private async command<T extends object = Record<string, never>>(
197
+ body: Record<string, unknown>,
198
+ options?: { retryTransport?: boolean; deadlineMs?: number },
199
+ ): Promise<T> {
200
+ const type = String(body['type'] ?? 'command');
201
+ const deadlineMs = options?.deadlineMs ?? COMMAND_DEADLINE_MS;
202
+ if (this.transport) {
203
+ const answered = (await this.transport(body)) as {
204
+ ok: boolean;
205
+ error?: string;
206
+ code?: string;
207
+ } & T;
208
+ if (!answered.ok) {
209
+ throw new EditorCommandError(
210
+ answered.error ?? `Editor command "${type}" failed`,
211
+ answered.code,
212
+ );
213
+ }
214
+ return answered;
215
+ }
216
+ const url = `${this.baseUrl}/__editor/command`;
217
+ const request = JSON.stringify(body);
218
+ let res: Response | undefined;
219
+ let retried = false;
220
+ let commandDispatcher: Dispatcher | undefined;
221
+ for (;;) {
222
+ try {
223
+ const init: RequestInit = {
224
+ method: 'POST',
225
+ headers: { 'Content-Type': 'application/json' },
226
+ body: request,
227
+ signal: AbortSignal.timeout(deadlineMs),
228
+ };
229
+ // The route answers when the command completes. Undici's default header
230
+ // timeout is 300s, independent of the abort signal; longer command budgets
231
+ // use an explicit dispatcher through undici's fetch to extend that timeout.
232
+ commandDispatcher = deadlineMs > UNDICI_DEFAULT_HEADERS_TIMEOUT_MS
233
+ ? createDispatcher(deadlineMs + 30_000)
234
+ : undefined;
235
+ res = this.fetchOverride ? await this.fetchOverride(url, init) : await dispatchFetch(url, init, commandDispatcher);
236
+ break;
237
+ } catch (error) {
238
+ await commandDispatcher?.destroy();
239
+ commandDispatcher = undefined;
240
+ // Only the deadline is reshaped into a "no answer" verdict; everything
241
+ // else is a TRANSPORT failure, and Node hides its reason behind a bare
242
+ // `fetch failed` (see `describeFetchFailure`).
243
+ if ((error as { name?: string } | null)?.name === 'TimeoutError') {
244
+ throw new EditorCommandError(
245
+ `The editor at ${this.baseUrl} never answered "${type}" ` +
246
+ `within ${Math.round(deadlineMs / 1000)}s — past every server-side budget, so ` +
247
+ 'the server itself is not answering. Check the terminal that started this editor session.',
248
+ );
249
+ }
250
+ const { code, detail } = describeFetchFailure(error);
251
+ if (options?.retryTransport === true && !retried && RETRYABLE_TRANSPORT_CODES.has(code)) {
252
+ retried = true;
253
+ await new Promise((resolve) => setTimeout(resolve, TRANSPORT_RETRY_DELAY_MS));
254
+ continue;
255
+ }
256
+ throw new EditorCommandError(
257
+ `POST ${url} ("${type}") never reached the editor: ${code}${detail ? ` (${detail})` : ''}.` +
258
+ (retried
259
+ ? ` Retried once after ${TRANSPORT_RETRY_DELAY_MS}ms; it failed the same way.`
260
+ : '') +
261
+ (options?.retryTransport === true
262
+ ? ''
263
+ : ' Not retried automatically: this command can change editor state, and a socket that' +
264
+ ' died after the request was written cannot prove the editor did not already run it.') +
265
+ ' A transport failure means the port stopped answering, not that the editor refused —' +
266
+ ' the dev server restarts on any watched source edit, and it shuts itself down after an' +
267
+ ' idle window. Check the terminal that started this editor session and confirm the port' +
268
+ ' this client resolved.',
269
+ code,
270
+ );
271
+ }
272
+ }
273
+ let data: { ok: boolean; error?: string; code?: string } & T;
274
+ try {
275
+ data = (await this.readJson(res)) as typeof data;
276
+ } finally {
277
+ await commandDispatcher?.destroy();
278
+ }
279
+ if (!data.ok) {
280
+ throw new EditorCommandError(
281
+ data.error ?? `Editor command failed: ${res.status}`,
282
+ data.code,
283
+ );
284
+ }
285
+ return data;
286
+ }
287
+
288
+ /** Refuse shutdown if a document cannot be saved. Does not stop the server. */
289
+ async prepareClose(): Promise<void> {
290
+ await this.command({ type: 'session-prepare-close' });
291
+ }
292
+
293
+ // --- Selection ---
294
+
295
+ async select(id: string | null): Promise<void> {
296
+ await this.command({ type: 'select', id });
297
+ }
298
+
299
+ async selectMultiple(ids: string[]): Promise<void> {
300
+ await this.command({ type: 'select-multiple', ids });
301
+ }
302
+
303
+ async selectAll(): Promise<void> {
304
+ await this.command({ type: 'select-all' });
305
+ }
306
+
307
+ // --- Viewport ---
308
+
309
+ async focusEntity(id: string): Promise<void> {
310
+ await this.command({ type: 'focus-entity', id });
311
+ }
312
+
313
+ async focusSelection(): Promise<void> {
314
+ await this.command({ type: 'focus-selection' });
315
+ }
316
+
317
+ /**
318
+ * Frame the EDIT viewport camera on one entity — the strict sibling of
319
+ * {@link focusEntity}. Same framing; an id the scene does not know is a
320
+ * refusal naming the id (`EditorCommandError`, code `ENTITY_NOT_FOUND`)
321
+ * rather than `focusEntity`'s silent no-op, so a caller that frames an
322
+ * entity before capturing it cannot photograph the wrong thing.
323
+ */
324
+ async frameEntity(id: string): Promise<void> {
325
+ await this.command({ type: 'frame-entity', id });
326
+ }
327
+
328
+ async viewPreset(preset: ViewPreset): Promise<void> {
329
+ await this.command({ type: 'view-preset', preset });
330
+ }
331
+
332
+ /**
333
+ * LOOK AROUND THE OPEN MODEL, visibly. Swings the active asset
334
+ * document's camera — the one on the human's screen — by `azimuth`/
335
+ * `elevation` radians, animated over `duration` seconds, and resolves when
336
+ * the move ends. A human drag during the move cancels it where it stands
337
+ * (`cancelledBy: 'human'`); the promise still resolves.
338
+ */
339
+ async orbitDocument(options: {
340
+ azimuth?: number;
341
+ elevation?: number;
342
+ duration?: number;
343
+ }): Promise<DocumentLookOutcome> {
344
+ return this.command<DocumentLookOutcome>({ type: 'document-orbit', ...options });
345
+ }
346
+
347
+ /** What a frame of a 3D document's stage costs, uncapped (`document-frame-cost`). */
348
+ async frameCostDocument(options?: { readonly frames?: number; readonly stage?: string; readonly quality?: 'full' | 'navigation' }): Promise<StageFrameCostReading> {
349
+ return this.command<StageFrameCostReading>({ type: 'document-frame-cost', ...options });
350
+ }
351
+
352
+ /** A slow full revolution around the open document's subject, at a constant rate. */
353
+ async turntableDocument(options?: {
354
+ seconds?: number;
355
+ revolutions?: number;
356
+ }): Promise<DocumentLookOutcome> {
357
+ return this.command<DocumentLookOutcome>({ type: 'document-turntable', ...options });
358
+ }
359
+
360
+ /**
361
+ * Frame the open document's subject (its selection if it has one). `fit`
362
+ * scales the fitted distance: 1 is the toolbar Frame button's tight fit.
363
+ */
364
+ async frameDocument(fit?: number): Promise<DocumentCameraPose> {
365
+ return this.command<DocumentCameraPose>({
366
+ type: 'document-frame',
367
+ ...(fit === undefined ? {} : { fit }),
368
+ });
369
+ }
370
+
371
+ async setCamera(position: Vec3Value, target: Vec3Value, fov?: number): Promise<void> {
372
+ await this.command({
373
+ type: 'set-camera',
374
+ position,
375
+ target,
376
+ ...(fov === undefined ? {} : { fov }),
377
+ });
378
+ }
379
+
380
+ async captureViewport(size?: number): Promise<ViewportCapture> {
381
+ const data = await this.command<ViewportCapture>({
382
+ type: 'capture-viewport',
383
+ ...(size === undefined ? {} : { size }),
384
+ });
385
+ return { base64: data.base64, mimeType: data.mimeType };
386
+ }
387
+
388
+ /**
389
+ * Capture an isolated, deterministic four-view preview through the editor's
390
+ * native Asset Lab. The SDK delegates rendering to the editor; it never
391
+ * loads, clones, or interprets 3D assets itself.
392
+ */
393
+ async captureAssetPreview(
394
+ source: AssetPreviewSource,
395
+ options: AssetPreviewOptions = {},
396
+ ): Promise<AssetPreviewCapture> {
397
+ const data = await this.command<AssetPreviewCapture>(
398
+ {
399
+ type: 'capture-asset-preview',
400
+ ...source,
401
+ ...options,
402
+ },
403
+ // Photographing changes nothing, so a momentary transport failure is
404
+ // retried rather than costing the caller its capture.
405
+ { retryTransport: true },
406
+ );
407
+ return {
408
+ width: data.width,
409
+ height: data.height,
410
+ orientation: data.orientation,
411
+ views: data.views,
412
+ contactSheet: data.contactSheet,
413
+ };
414
+ }
415
+
416
+ /**
417
+ * A caller-defined labeled shot set (`live`'s `assetPreviewShots`): the
418
+ * DEFINITION travels with the command (see `AssetPreviewShotSetDefinition`),
419
+ * and the editor's generic
420
+ * capture engine renders it — see the 3D integration's
421
+ * `captureShotSetAssetPreview`. Throws (via `command`'s `{ok:false}`
422
+ * unwrap) with a clear message naming the missing joint(s) when the asset
423
+ * lacks a bone the definition requires.
424
+ */
425
+ async captureShotSetPreview(
426
+ source: AssetPreviewSource,
427
+ definition: AssetPreviewShotSetDefinition,
428
+ options: AssetPreviewOptions = {},
429
+ ): Promise<LabeledShotSetCapture> {
430
+ const data = await this.command<LabeledShotSetCapture>(
431
+ {
432
+ type: 'capture-asset-preview',
433
+ ...source,
434
+ ...options,
435
+ shotSet: definition,
436
+ },
437
+ { retryTransport: true },
438
+ );
439
+ return {
440
+ width: data.width,
441
+ height: data.height,
442
+ shots: data.shots,
443
+ warnings: data.warnings,
444
+ contactSheet: data.contactSheet,
445
+ };
446
+ }
447
+
448
+ /**
449
+ * Score the asset against a reference GLB (the editor's `screenshot
450
+ * <model.glb> --compare <ref.glb>` command): matched orthographic front + side silhouettes
451
+ * (equal-height bounding-box framing, both yaw-normalized to face the
452
+ * camera), per-view IoU numbers, and overlay evidence images. The
453
+ * reference GLB's raw bytes travel base64 in the command; the editor
454
+ * renders and scores — the SDK never interprets 3D assets itself.
455
+ */
456
+ async captureAssetComparePreview(
457
+ source: AssetPreviewSource,
458
+ refGlbBase64: string,
459
+ options: AssetCompareOptions = {},
460
+ ): Promise<AssetCompareCapture> {
461
+ const { refForward, ...dimensions } = options;
462
+ const data = await this.command<AssetCompareCapture>({
463
+ type: 'capture-asset-preview',
464
+ ...source,
465
+ ...dimensions,
466
+ compare: { glbBase64: refGlbBase64, ...(refForward ? { forward: refForward } : {}) },
467
+ });
468
+ return { width: data.width, height: data.height, views: data.views };
469
+ }
470
+
471
+ // --- Panels ---
472
+
473
+ /** Focus a static workspace panel by the key the editor's panel registry
474
+ * holds; an unknown key refuses naming the keys it does hold. */
475
+ async showPanel(panel: string): Promise<void> {
476
+ await this.command({ type: 'show-panel', panel });
477
+ }
478
+
479
+ /** Show several instances of the running project split-screen — multiplayer
480
+ * authoring. Pass a total `count` (default "Player N" labels) or an array of
481
+ * `names` (its length is the count; index 0 is the primary). Requires a live
482
+ * play session. */
483
+ async setInstanceCount(countOrNames: number | string[]): Promise<void> {
484
+ await this.command(
485
+ Array.isArray(countOrNames)
486
+ ? { type: 'set-instance-count', names: countOrNames }
487
+ : { type: 'set-instance-count', count: countOrNames },
488
+ );
489
+ }
490
+
491
+ async openAsset(path: string, kind: AssetKind): Promise<void> {
492
+ await this.command({ type: 'open-asset-tab', path, kind });
493
+ }
494
+
495
+ /** SELECT a project asset — the other half of the browser's
496
+ * selection-vs-open contract (single click selects and fills the
497
+ * Inspector; double click opens a document). */
498
+ async selectAsset(path: string): Promise<void> {
499
+ await this.command({ type: 'select-asset', path });
500
+ }
501
+
502
+ async closeAsset(key: string): Promise<void> {
503
+ await this.command({ type: 'close-asset-tab', key });
504
+ }
505
+
506
+ async toggleCommandPalette(): Promise<void> {
507
+ await this.command({ type: 'toggle-command-palette' });
508
+ }
509
+
510
+ async toggleConsole(): Promise<void> {
511
+ await this.command({ type: 'toggle-console' });
512
+ }
513
+
514
+ /** Switch the editor's NAMED WORKSPACE — the task-named layout memory
515
+ * (`design`/`animate`/`look`, or one a package contributes). Resolves once the
516
+ * dock has finished rebuilding, so a following capture photographs the
517
+ * arrangement that was asked for. */
518
+ async setWorkspace(workspace: EditorWorkspaceName): Promise<void> {
519
+ await this.command({ type: 'set-workspace', workspace });
520
+ }
521
+
522
+ /** Choose a STYLE (`volter.style`) — its material, icon set and type, and the colour theme it
523
+ * was drawn with (`classic`/`glass`/`maya`/`substance`, or one a package the project or
524
+ * product composes carries). */
525
+ async setStyle(style: string): Promise<void> {
526
+ await this.command({ type: 'set-style', style });
527
+ }
528
+
529
+ /** A view's presentation, answered resolved. With a LAYER the person's choice is recorded
530
+ * first. */
531
+ async viewportPresentation(
532
+ documentId: string,
533
+ layer?: import('@volter/sdk/viewport-presentation').PresentationLayer,
534
+ ): Promise<{
535
+ presentation: import('@volter/sdk/viewport-presentation').ViewportPresentation;
536
+ binding: { stageKind: string; documentLayer: import('@volter/sdk/viewport-presentation').PresentationLayer | null } | null;
537
+ bound: { viewId: string; stageKind: string }[];
538
+ lastDraw: import('@volter/sdk/viewport-presentation').ViewDrawReport | null;
539
+ presets: string[];
540
+ /** The environment images registered (`./environment-images`), for `lighting.preview.environment.image`. */
541
+ environmentImages: string[];
542
+ }> {
543
+ return this.command({
544
+ type: 'viewport-presentation',
545
+ documentId,
546
+ ...(layer ? { layer } : {}),
547
+ });
548
+ }
549
+
550
+ async showBuild(): Promise<void> {
551
+ await this.command({ type: 'show-build' });
552
+ }
553
+
554
+ /** Atomically present a durable editor view and return its shareable URL. */
555
+ async present(view: EditorView): Promise<PresentedEditorView> {
556
+ const presented = await this.command<PresentedEditorView>({ type: 'present-view', view });
557
+ return { view: presented.view, url: presented.url, warnings: presented.warnings };
558
+ }
559
+
560
+
561
+ async inspect(): Promise<InspectedInspection> {
562
+ const data = await this.command<{ subject: InspectedInspection }>({ type: 'inspect' });
563
+ return data.subject;
564
+ }
565
+
566
+ /** Run one verb exposed by the active Inspector subject, by its id. */
567
+ async runInspectionAction(actionId: string): Promise<InspectedInspection> {
568
+ const data = await this.command<{ subject: InspectedInspection }>({
569
+ type: 'run-inspection-action',
570
+ actionId,
571
+ });
572
+ return data.subject;
573
+ }
574
+
575
+ /**
576
+ * Run ONE command by id — the door to everything the command palette lists.
577
+ *
578
+ * Under the Code-OSS frame this is the workbench's own `ICommandService`, so
579
+ * any command id works: a view's `volter.<view>.<verb>`, an editor action's
580
+ * `volter.action.<id>`, or one of VS Code's own. A call made before the
581
+ * command service lands waits for it.
582
+ *
583
+ * The result is whatever the command answered — a view verb's state, or
584
+ * `null` for a command that returns nothing.
585
+ */
586
+ async runCommand(commandId: string, args?: unknown): Promise<unknown> {
587
+ const data = await this.command<{ result: unknown }>({
588
+ type: 'run-command',
589
+ commandId,
590
+ ...(args === undefined ? {} : { args }),
591
+ });
592
+ return data.result;
593
+ }
594
+
595
+ /**
596
+ * One STRUCTURE op on the authored tree — the hierarchy context menu's own
597
+ * verbs, on the same helpers, for a caller with no pointer to right-click
598
+ * with. `id`/`ids` default to the current selection.
599
+ */
600
+ async structureOp(op: StructureOp, options: StructureOpOptions = {}): Promise<StructureOpResult> {
601
+ return this.command<StructureOpResult>({ type: 'structure-op', op, ...options });
602
+ }
603
+
604
+ /** "Extract Component…" — the hierarchy row's action, as a command. Answers
605
+ * the action's own sentence, which NAMES the files it created. */
606
+ async extractComponent(options: { id?: string; name?: string } = {}): Promise<{ hint: string }> {
607
+ return this.command<{ hint: string }>({ type: 'extract-component', ...options });
608
+ }
609
+
610
+ /** "Fork Component…" — extract's twin: one new file, one callsite retargeted. */
611
+ async forkComponent(options: { id?: string } = {}): Promise<{ hint: string }> {
612
+ return this.command<{ hint: string }>({ type: 'fork-component', ...options });
613
+ }
614
+
615
+ /**
616
+ * The HIERARCHY PANEL's actual rendered row tree, as data.
617
+ *
618
+ * The panel projects component marks, document promotion, child caps,
619
+ * collapse state, search and selection scope onto the adapter tree.
620
+ *
621
+ * Deliberately NOT `status().entities`, which walks the raw adapter tree and
622
+ * therefore answers a different question: a panel defect is invisible in it.
623
+ *
624
+ * Each row reports the projected child count and whether the panel draws a disclosure control.
625
+ *
626
+ * Rejects, naming the panel, when no hierarchy panel is mounted: an empty
627
+ * tree would be a fabricated answer about a surface nobody is being shown.
628
+ */
629
+ async hierarchy(): Promise<InspectedHierarchy> {
630
+ const data = await this.command<{ hierarchy: InspectedHierarchy }>({ type: 'hierarchy' });
631
+ return data.hierarchy;
632
+ }
633
+
634
+ /** Run the Hierarchy panel's own Expand All action. */
635
+ async expandHierarchyAll(): Promise<void> {
636
+ await this.command<Record<string, never>>({ type: 'expand-hierarchy-all' });
637
+ }
638
+
639
+ /** Run the Hierarchy panel's own Collapse All action — Expand All's other
640
+ * half, and the only way back to the tree's rest state through the product
641
+ * (see `HierarchyPanelSnapshot.collapseAll`). */
642
+ async collapseHierarchyAll(): Promise<void> {
643
+ await this.command<Record<string, never>>({ type: 'collapse-hierarchy-all' });
644
+ }
645
+
646
+ /**
647
+ * Write one editable path through the active Inspector's own IO.
648
+ *
649
+ * The answer carries `write` as well as the subject, because an ack alone
650
+ * cannot be believed: a write with no persistence route open succeeds and
651
+ * changes no byte, and `write.persisted` is how the caller tells the two
652
+ * apart without diffing the tree (`InspectedWriteDestination` in `types.ts`).
653
+ */
654
+ async setInspectionField(path: string, value: unknown): Promise<InspectedFieldWrite> {
655
+ const data = await this.command<InspectedFieldWrite>({
656
+ type: 'set-inspection-field',
657
+ path,
658
+ value,
659
+ });
660
+ return { subject: data.subject, write: data.write };
661
+ }
662
+
663
+ /**
664
+ * REMOVE one editable path's authored override — the other half of the write
665
+ * door, and the only one that can express byte-ABSENCE.
666
+ *
667
+ * {@link setInspectionField} writes a VALUE, so reverting a property an
668
+ * authoring gesture ADDED puts the default back EXPLICITLY and leaves the
669
+ * source one attribute heavier than it started. This drops the property, so
670
+ * whatever governs it in its absence takes over — the same `io.remove` the
671
+ * Inspector's revert arrow calls, the same persistence pipe, the same awaited
672
+ * `{ destination, persisted }` ack.
673
+ *
674
+ * Rejects with `code: 'REMOVAL_UNAVAILABLE'` when the field does not declare
675
+ * itself removable or the lane implements no removal door. That refusal is a
676
+ * MISSING SEAM, not a failed removal, and it is coded rather than phrased
677
+ * precisely so a caller can grade the two differently.
678
+ */
679
+ async removeInspectionField(path: string): Promise<InspectedFieldWrite> {
680
+ const data = await this.command<InspectedFieldWrite>({
681
+ type: 'remove-inspection-field',
682
+ path,
683
+ });
684
+ return { subject: data.subject, write: data.write };
685
+ }
686
+
687
+ /**
688
+ * OPEN one piece of the adapter's SCENE TABLE by id — a scene, a prefab, or
689
+ * a story state, because the table makes them siblings (they differ only in
690
+ * instance site). The ids are exactly what `getState().adapter.scenes.entries`
691
+ * reports, so the table is both the menu and the address space. It opens
692
+ * the entry's document in Play as in Edit.
693
+ *
694
+ * Rejects with a coded reason rather than prose: `SCENE_NOT_FOUND` (and it
695
+ * names the ids that DO exist), `SCENE_NOT_OPENABLE` carrying the reason an
696
+ * entry owns no document (the adapter's own, when it declared one),
697
+ * `SCENE_TABLE_UNAVAILABLE` before the adapter has loaded, and
698
+ * `SCENE_DOCUMENT_NOT_MOUNTED` when the host has no document for a piece the
699
+ * table says is openable — a host gap, not a table statement.
700
+ */
701
+ async open(id: string): Promise<OpenedDocument> {
702
+ return this.command<OpenedDocument>({ type: 'open', id });
703
+ }
704
+
705
+ /**
706
+ * Undo / redo one project transaction — the same queue the keyboard shortcut
707
+ * drives. `moved` is false when there was nothing left in that direction.
708
+ */
709
+ async undo(): Promise<HistoryStep> {
710
+ return this.command<HistoryStep>({ type: 'undo' });
711
+ }
712
+
713
+ async redo(): Promise<HistoryStep> {
714
+ return this.command<HistoryStep>({ type: 'redo' });
715
+ }
716
+
717
+ /** Read the editor's actual current durable projection. */
718
+ async currentView(): Promise<EditorView> {
719
+ const data = await this.command<{ view: EditorView }>({ type: 'current-view' });
720
+ return data.view;
721
+ }
722
+
723
+ /** Photograph the editor PAGE itself — every panel as the person sees it, at
724
+ * `scale` output pixels per CSS pixel (default `devicePixelRatio`), which is
725
+ * what a 1 px border or a glyph edge is judged through. */
726
+ async captureEditorChrome(options?: EditorChromeCaptureOptions): Promise<EditorChromeCapture> {
727
+ return this.command<EditorChromeCapture>({
728
+ type: 'capture-editor-chrome',
729
+ ...(options?.scale === undefined ? {} : { scale: options.scale }),
730
+ ...(options?.region === undefined ? {} : { region: options.region }),
731
+ ...(options?.name === undefined ? {} : { name: options.name }),
732
+ });
733
+ }
734
+
735
+ /** Capture the active document's render, excluding editor chrome. A number
736
+ * requests a square; `{width, height}` requests that shape, bounded by
737
+ * {@link CaptureDimensions}. With a view, present and capture it in one request
738
+ * so discovery cannot retarget the capture between client calls. */
739
+ async captureActiveDocument(
740
+ size?: CaptureDimensions,
741
+ view?: EditorView,
742
+ ): Promise<ActiveDocumentCapture> {
743
+ return this.command<ActiveDocumentCapture>({
744
+ type: 'capture-active-document',
745
+ ...(view ? { view } : {}),
746
+ ...(typeof size === 'number' ? { size } : {}),
747
+ ...(typeof size === 'object' && size !== null
748
+ ? { width: size.width, height: size.height }
749
+ : {}),
750
+ });
751
+ }
752
+
753
+ /**
754
+ * Read or drive the ACTIVE center document's own DOM — the scoped
755
+ * editor-chrome door, and the read/gesture half of the same subject
756
+ * {@link captureActiveDocument} photographs. NOT play-mode gated, and NOT
757
+ * page automation: a target outside the active document's container is
758
+ * refused by name. Design and scope contract:
759
+ * `packages/sdk/src/editor-document-probe.ts`.
760
+ */
761
+ async documentProbe(step: DocumentProbeStep): Promise<DocumentProbeResult> {
762
+ return this.command<DocumentProbeResult>({ type: 'document-probe', step });
763
+ }
764
+
765
+ /**
766
+ * A verb a package contributes: the Code-OSS command `volter.<type>` its page half registers,
767
+ * waited on as long as its declaration says (`contributes.commands`' `volter.timeoutMs`, which
768
+ * the relay reads from the same manifest), so a package's long verb (a modeling chunk, minutes
769
+ * under Wasm) has its budget in one place. A verb no package declares is refused by name
770
+ * before anything is sent.
771
+ */
772
+ async contributedVerb<T extends object = Record<string, unknown>>(
773
+ type: string,
774
+ fields: Record<string, unknown> = {},
775
+ ): Promise<T> {
776
+ const id = verbCommandId(type);
777
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/commands/${encodeURIComponent(id)}`);
778
+ const declared = (await this.readJson(res)) as { timeoutMs: number; error?: string };
779
+ if (!res.ok) throw new Error(declared.error ?? `No package declares the command "${id}" (${res.status}).`);
780
+ // Past the relay's own budget, so the relay's answer (its result or its timeout) is what arrives.
781
+ return this.command<T>({ type, ...fields }, { deadlineMs: declared.timeoutMs + 30_000 });
782
+ }
783
+
784
+ /**
785
+ * Run a wire-carried step against the ACTIVE document's published context
786
+ * (`packages/editor-core/src/document-context-registry.ts`) — the REPL door over
787
+ * an open document. `src` is JavaScript source evaluated in the page; references to the caller's
788
+ * closures are unavailable.
789
+ */
790
+ async documentScript<T = unknown>(src: string): Promise<T> {
791
+ const outcome = await this.command<{ result: T }>({ type: 'document-script', src });
792
+ return outcome.result;
793
+ }
794
+
795
+ // --- Display (set semantics) ---
796
+
797
+ async setGrid(enabled: boolean): Promise<void> {
798
+ await this.command({ type: 'set-grid', enabled });
799
+ }
800
+
801
+ async setHelpers(enabled: boolean): Promise<void> {
802
+ await this.command({ type: 'set-helpers', enabled });
803
+ }
804
+
805
+ async setStats(enabled: boolean): Promise<void> {
806
+ await this.command({ type: 'set-stats', enabled });
807
+ }
808
+
809
+ async setShadingMode(mode: ShadingMode): Promise<void> {
810
+ await this.command({ type: 'set-shading-mode', mode });
811
+ }
812
+
813
+ async setHelperType(helperType: keyof HelperVisibility, enabled: boolean): Promise<void> {
814
+ await this.command({ type: 'set-helper-type', helperType, enabled });
815
+ }
816
+
817
+ // --- Transform tools (set semantics) ---
818
+
819
+ async setTransformMode(mode: TransformMode): Promise<void> {
820
+ await this.command({ type: 'set-transform-mode', mode });
821
+ }
822
+
823
+ async setTransformSpace(space: TransformSpace): Promise<void> {
824
+ await this.command({ type: 'set-transform-space', space });
825
+ }
826
+
827
+ async setSnap(enabled: boolean): Promise<void> {
828
+ await this.command({ type: 'set-snap', enabled });
829
+ }
830
+
831
+ // --- Project management ---
832
+
833
+ /** A new project from one of the running product's templates or examples (its `/__editor/templates`), or its default
834
+ * where neither is named. */
835
+ async createProject(
836
+ name: string,
837
+ location: string,
838
+ from: { readonly template?: string; readonly example?: string } = {},
839
+ ): Promise<ProjectInfo> {
840
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/create-project`, {
841
+ method: 'POST',
842
+ headers: { 'Content-Type': 'application/json' },
843
+ body: JSON.stringify({ name, location, ...from }),
844
+ });
845
+ const data = (await this.readJson(res)) as {
846
+ ok?: boolean;
847
+ error?: string;
848
+ path?: string;
849
+ config?: ProjectInfo['config'];
850
+ };
851
+ if (!res.ok) {
852
+ throw new Error(data.error ?? `Create project failed: ${res.status}`);
853
+ }
854
+ return { path: data.path as string, config: data.config as ProjectInfo['config'] };
855
+ }
856
+
857
+ async openProject(path: string): Promise<void> {
858
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/open-project`, {
859
+ method: 'POST',
860
+ headers: { 'Content-Type': 'application/json' },
861
+ body: JSON.stringify({ path }),
862
+ });
863
+ const body = (await this.readJson(res)) as { error?: string };
864
+ if (!res.ok) {
865
+ throw new Error(body.error ?? `Open project failed: ${res.status}`);
866
+ }
867
+ }
868
+
869
+ async getProject(): Promise<ProjectInfo | null> {
870
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/project`);
871
+ const data = (await this.readJson(res)) as { project: ProjectInfo | null; error?: string };
872
+ if (!res.ok) throw new Error(data.error ?? `Failed to get project: ${res.status}`);
873
+ return data.project;
874
+ }
875
+
876
+ async listRecentProjects(): Promise<RecentProject[]> {
877
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/recent-projects`);
878
+ const data = (await this.readJson(res)) as { projects: RecentProject[]; error?: string };
879
+ if (!res.ok) throw new Error(data.error ?? `Failed to list projects: ${res.status}`);
880
+ return data.projects;
881
+ }
882
+
883
+ // --- State ---
884
+
885
+ /**
886
+ * The document table the host resolved — every scene, prefab, page, model,
887
+ * shot, take … the project's finders produced (`getState().adapter.scenes`
888
+ * is the same projection). A command, so it answers wherever the control
889
+ * channel reaches, not only where `/__editor/state` is served.
890
+ */
891
+ async documentTable(): Promise<DocumentTableProjection> {
892
+ return this.command<DocumentTableProjection>({ type: 'document-table' });
893
+ }
894
+
895
+
896
+ async reloadPage(): Promise<void> {
897
+ const timeoutMs = 60_000;
898
+ const epochs = async () => {
899
+ const { tabs } = await this.getState().catch(() => ({ tabs: [] }));
900
+ return new Map(tabs.map((tab) => [tab.tabId8, tab] as const));
901
+ };
902
+ const pause = () => new Promise((settle) => setTimeout(settle, 250));
903
+ const before = await epochs();
904
+ const deadline = Date.now() + timeoutMs;
905
+ await this.command({ type: 'page-reload' });
906
+ let reloaded: string[] = [];
907
+ while (reloaded.length === 0) {
908
+ if (Date.now() > deadline) throw new Error('The reload was ordered and no tab of this session reported a new page load.');
909
+ await pause();
910
+ const now = await epochs();
911
+ reloaded = [...now].filter(([tabId, tab]) => tab.epochCount > (before.get(tabId)?.epochCount ?? 0)).map(([tabId]) => tabId);
912
+ }
913
+ for (;;) {
914
+ if (Date.now() > deadline) throw new Error('A new page loaded, but its command listener did not become ready.');
915
+ const now = await epochs();
916
+ if (reloaded.some((tabId) => {
917
+ const listener = now.get(tabId)?.commandListener;
918
+ return typeof listener !== 'string' || listener === 'ready';
919
+ })) break;
920
+ await pause();
921
+ }
922
+ for (;;) {
923
+ try {
924
+ await this.command({ type: 'current-view' });
925
+ return;
926
+ } catch (error) {
927
+ if (Date.now() > deadline) throw error;
928
+ await pause();
929
+ }
930
+ }
931
+ }
932
+
933
+ async getState(): Promise<EditorState> {
934
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/state`);
935
+ const state = (await this.readJson(res)) as EditorState;
936
+ if (!res.ok) throw new Error(`Failed to get editor state: ${res.status} ${res.statusText}`);
937
+ return state;
938
+ }
939
+
940
+ /**
941
+ * The complete unresolved console set the session is holding right now.
942
+ *
943
+ * The console endpoint returns named conditions; command envelopes carry
944
+ * only their counts. The request has a bounded header deadline.
945
+ */
946
+ async getUnresolvedConsole(opts?: { all?: boolean }): Promise<unknown> {
947
+ const res = await this.httpFetch(
948
+ `${this.baseUrl}/__editor/console${opts?.all === true ? '?all=1' : ''}`,
949
+ { signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS) },
950
+ );
951
+ // Status first: a 404's empty body would otherwise die inside readJson
952
+ // with a parse error that hides the one fact the caller classifies on
953
+ // (does this server SERVE the console route at all?).
954
+ if (!res.ok) {
955
+ throw new Error(`Failed to read unresolved console: ${res.status}`);
956
+ }
957
+ return await this.readJson(res);
958
+ }
959
+
960
+ /** Acknowledge one named console condition. */
961
+ async acknowledgeConsole(input: {
962
+ readonly id: string;
963
+ readonly reason: string;
964
+ readonly by: string;
965
+ }): Promise<unknown> {
966
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/console/ack`, {
967
+ method: 'POST',
968
+ headers: { 'Content-Type': 'application/json' },
969
+ body: JSON.stringify(input),
970
+ signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS * 4),
971
+ });
972
+ const body = await this.readJson(res);
973
+ return body;
974
+ }
975
+
976
+ /**
977
+ * `fetch` for this client's plain routes, with the ONE thing Node's `fetch`
978
+ * will not do: name why it failed.
979
+ *
980
+ * `readJson` below already owns "the server answered the wrong thing"; this
981
+ * owns "nothing answered at all", naming the real code Node buries on
982
+ * `.cause`. No retry
983
+ * here — these routes create projects, run tools and acknowledge console
984
+ * conditions, so repeating one is the caller's decision. The relayed
985
+ * `command` path above has its own opt-in retry for the read-only captures.
986
+ */
987
+ private async httpFetch(
988
+ url: string,
989
+ init?: RequestInit,
990
+ ): Promise<Response> {
991
+ try {
992
+ return this.fetchOverride ? await this.fetchOverride(url, init) : await dispatchFetch(url, init);
993
+ } catch (error) {
994
+ if ((error as { name?: string } | null)?.name === 'TimeoutError') throw error;
995
+ const { code, detail } = describeFetchFailure(error);
996
+ throw new EditorCommandError(
997
+ `${init?.method ?? 'GET'} ${url} never reached the editor: ${code}` +
998
+ `${detail ? ` (${detail})` : ''}. Nothing answered on that port — check the terminal ` +
999
+ 'that started this editor session and confirm the port this client resolved.',
1000
+ code,
1001
+ );
1002
+ }
1003
+ }
1004
+
1005
+ /** Require a JSON response from the editor server. */
1006
+ private async readJson(res: Response): Promise<unknown> {
1007
+ // The client's JSON response routes funnel through here, so this is
1008
+ // where "did the editor server answer?" is asked — the same question, and
1009
+ // the same JSON-content-type rule, that
1010
+ // `packages/sdk/src/editor-server-response.ts` owns on the browser side.
1011
+ // It is asked again rather than imported because THIS package is published
1012
+ // and depends on neither `@volter/project` nor the editor bundle (see
1013
+ // `DEFAULT_URL` above for that policy).
1014
+ //
1015
+ // Not theoretical here: `baseUrl` is whatever `--url`/`VOLTER_EDITOR_URL`
1016
+ // says, so the CLI can be pointed at a static host, which answers
1017
+ // `200 text/html` for a route nothing serves, and `res.json()` would die as
1018
+ // `Unexpected token '<'`, naming neither the URL nor the cause.
1019
+ const contentType = res.headers.get('content-type') ?? '';
1020
+ if (!contentType.includes('application/json')) {
1021
+ throw new Error(
1022
+ `The editor at ${this.baseUrl} answered with its page fallback ` +
1023
+ `(${contentType || 'no content-type'}) rather than JSON, so no editor server handled ` +
1024
+ 'the request. Check that this URL is a running editor session.',
1025
+ );
1026
+ }
1027
+ return await res.json();
1028
+ }
1029
+
1030
+ /**
1031
+ * Whether an editor browser tab is connected to the server *right now*.
1032
+ * Unlike {@link getState}, this reflects live SSE connections, not cached
1033
+ * state — use it to check whether commands will actually reach an editor.
1034
+ */
1035
+ async isConnected(): Promise<boolean> {
1036
+ const state = await this.getState();
1037
+ return state.connected === true;
1038
+ }
1039
+
1040
+ async waitForState(
1041
+ predicate: (s: EditorState) => boolean,
1042
+ timeoutMs = 10_000,
1043
+ ): Promise<EditorState> {
1044
+ const start = Date.now();
1045
+ while (Date.now() - start < timeoutMs) {
1046
+ const state = await this.getState();
1047
+ if (predicate(state)) return state;
1048
+ await new Promise((r) => setTimeout(r, 100));
1049
+ }
1050
+ throw new Error(`waitForState timed out after ${timeoutMs}ms`);
1051
+ }
1052
+ }