@vgai/editor-sdk 0.5.41 → 0.5.44

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.
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@vgai/editor-sdk",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.5.41",
5
+ "version": "0.5.44",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "@types/three": "^0.180.0",
27
- "@vgai/sdk": "0.5.41"
27
+ "@vgai/sdk": "0.5.44"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "@vgai/engine": "*",
package/src/client.ts CHANGED
@@ -10,12 +10,18 @@ import type {
10
10
  AssetPreviewOptions,
11
11
  AssetPreviewShotSetDefinition,
12
12
  AssetPreviewSource,
13
+ CaptureDimensions,
14
+ DocumentCameraPose,
15
+ DocumentLookOutcome,
16
+ EditorChromeCapture,
13
17
  EditorState,
14
18
  EditorView,
19
+ EditorWorkspaceName,
15
20
  GameCapture,
16
21
  GameplayRecordingCapture,
17
22
  GameplayRecordingOptions,
18
23
  GameplayRecordingStarted,
24
+ GameplayRecordingTimeline,
19
25
  HelperVisibility,
20
26
  InspectedFieldWrite,
21
27
  InspectedHierarchy,
@@ -32,6 +38,9 @@ import type {
32
38
  ShadingMode,
33
39
  StoryCaptureOptions,
34
40
  StoryVariantCapture,
41
+ StructureOp,
42
+ StructureOpOptions,
43
+ StructureOpResult,
35
44
  TransformMode,
36
45
  TransformSpace,
37
46
  Vec3Value,
@@ -114,6 +123,59 @@ const COMMAND_DEADLINE_MS = 150_000;
114
123
  */
115
124
  const CONSOLE_DRAIN_TIMEOUT_MS = 1_500;
116
125
 
126
+ /**
127
+ * Node's `fetch` collapses EVERY network-layer failure into one two-word
128
+ * `TypeError: fetch failed`. The real reason — `ECONNREFUSED`, `ECONNRESET`,
129
+ * `EPIPE`, a DNS miss — lives only on `error.cause` (sometimes two links down,
130
+ * or inside an `AggregateError`), and nothing prints it unless something walks
131
+ * the chain. That is the whole reason `project.bake.preview` was observed
132
+ * failing with a bare "fetch failed" and no way to tell a dead editor from a
133
+ * momentary one (WORK.md, cold barrel 2026-08-29): the code below used to
134
+ * rethrow the `TypeError` untouched, on the belief — stated in a comment right
135
+ * where it happened — that "a connection refused / DNS failure still surfaces
136
+ * as itself". It does not. This walks the chain so the message can say which.
137
+ */
138
+ function describeFetchFailure(error: unknown): { code: string; detail: string } {
139
+ const messages: string[] = [];
140
+ let current: unknown = error;
141
+ for (let depth = 0; depth < 8; depth++) {
142
+ if (!(current instanceof Error)) break;
143
+ if (current.message) messages.push(current.message);
144
+ const code = (current as { code?: unknown }).code;
145
+ if (typeof code === 'string' && code !== '') {
146
+ return { code, detail: messages.join(' <- ') };
147
+ }
148
+ const aggregate = (current as { errors?: unknown }).errors;
149
+ if (Array.isArray(aggregate) && aggregate.length > 0) {
150
+ const inner = describeFetchFailure(aggregate[0]);
151
+ return { code: inner.code, detail: [...messages, inner.detail].join(' <- ') };
152
+ }
153
+ current = (current as { cause?: unknown }).cause;
154
+ }
155
+ return { code: 'UNKNOWN', detail: messages.join(' <- ') || String(error) };
156
+ }
157
+
158
+ /**
159
+ * Transport failures where a second attempt is worth making: the connection
160
+ * itself failed or died, rather than the editor answering something unwelcome.
161
+ * A dev server that is restarting (any watched source edit restarts it) is
162
+ * unreachable for a fraction of a second and reachable again after — which is
163
+ * exactly the "fails, then succeeds unchanged" shape that was reported.
164
+ */
165
+ const RETRYABLE_TRANSPORT_CODES = new Set([
166
+ 'ECONNREFUSED',
167
+ 'ECONNRESET',
168
+ 'EPIPE',
169
+ 'ETIMEDOUT',
170
+ 'EHOSTUNREACH',
171
+ 'UND_ERR_SOCKET',
172
+ 'UND_ERR_CONNECT_TIMEOUT',
173
+ ]);
174
+
175
+ /** Gap before the one automatic retry — long enough for a dev-server restart's
176
+ * listen socket to come back, short enough to stay invisible. */
177
+ const TRANSPORT_RETRY_DELAY_MS = 400;
178
+
117
179
  /** Context accompanying one {@link EditorClient} response observation. */
118
180
  export interface EditorEnvelopeObservation {
119
181
  /** True only when this body came from the console-ledger endpoint and its
@@ -205,6 +267,9 @@ export class EditorClient {
205
267
  * The observer must not throw; anything it raises is swallowed, because a
206
268
  * reporting hook may never break the command it is reporting on.
207
269
  */
270
+ private readonly transport:
271
+ | ((body: Record<string, unknown>) => Promise<Record<string, unknown>>)
272
+ | null;
208
273
  private readonly onEnvelope:
209
274
  | ((body: unknown, observation: EditorEnvelopeObservation) => void)
210
275
  | null;
@@ -212,6 +277,14 @@ export class EditorClient {
212
277
  constructor(opts?: {
213
278
  url?: string;
214
279
  onEnvelope?: (body: unknown, observation: EditorEnvelopeObservation) => void;
280
+ /**
281
+ * An IN-PAGE command channel, for a client that lives inside the editor
282
+ * page itself on a tier with no editor server (the hosted build's
283
+ * contribution client). Given, every command goes through it instead of
284
+ * `POST /__editor/command`, and answers in the route's own body shape
285
+ * (`{ ok: true, ...data }` / `{ ok: false, error, code? }`).
286
+ */
287
+ transport?: (body: Record<string, unknown>) => Promise<Record<string, unknown>>;
215
288
  }) {
216
289
  if (opts !== undefined && (typeof opts !== 'object' || opts === null || Array.isArray(opts))) {
217
290
  throw new TypeError(
@@ -219,7 +292,7 @@ export class EditorClient {
219
292
  );
220
293
  }
221
294
  const unknownOptions = Object.keys(opts ?? {}).filter(
222
- (key) => key !== 'url' && key !== 'onEnvelope',
295
+ (key) => key !== 'url' && key !== 'onEnvelope' && key !== 'transport',
223
296
  );
224
297
  if (unknownOptions.length > 0) {
225
298
  throw new Error(
@@ -228,6 +301,7 @@ export class EditorClient {
228
301
  }
229
302
  this.baseUrl = (opts?.url ?? DEFAULT_URL).replace(/\/$/, '');
230
303
  this.onEnvelope = opts?.onEnvelope ?? null;
304
+ this.transport = opts?.transport ?? null;
231
305
  this.game = {
232
306
  state: async (name: string): Promise<unknown> => {
233
307
  // `keys` narrows the relay to the one provider asked for, so a panel
@@ -245,28 +319,82 @@ export class EditorClient {
245
319
  };
246
320
  }
247
321
 
322
+ /**
323
+ * `retryTransport` opts a command into ONE automatic retry after a transport
324
+ * failure (see {@link RETRYABLE_TRANSPORT_CODES}). It is deliberately
325
+ * OPT-IN and off by default: a socket that died after the request was written
326
+ * cannot prove the editor did not already run the command, so a blanket retry
327
+ * would risk playing/stopping/writing twice. Read-only relays — the captures —
328
+ * have no such hazard and turn it on.
329
+ */
248
330
  private async command<T extends object = Record<string, never>>(
249
331
  body: Record<string, unknown>,
332
+ options?: { retryTransport?: boolean },
250
333
  ): Promise<T> {
251
- let res: Response;
252
- try {
253
- res = await fetch(`${this.baseUrl}/__editor/command`, {
254
- method: 'POST',
255
- headers: { 'Content-Type': 'application/json' },
256
- body: JSON.stringify(body),
257
- signal: AbortSignal.timeout(COMMAND_DEADLINE_MS),
258
- });
259
- } catch (error) {
260
- // Only the deadline is reshaped; a connection refused / DNS failure
261
- // still surfaces as itself, because those name their own cause.
262
- if ((error as { name?: string } | null)?.name !== 'TimeoutError') throw error;
263
- throw new EditorCommandError(
264
- `The editor at ${this.baseUrl} never answered "${String(body['type'] ?? 'command')}" ` +
265
- `within ${Math.round(COMMAND_DEADLINE_MS / 1000)}s — past every server-side budget, so ` +
266
- 'the server itself is not answering. Check the terminal running `vgai edit`.',
267
- undefined,
268
- true,
269
- );
334
+ const type = String(body['type'] ?? 'command');
335
+ if (this.transport) {
336
+ const answered = (await this.transport(body)) as {
337
+ ok: boolean;
338
+ error?: string;
339
+ code?: string;
340
+ } & T;
341
+ if (!answered.ok) {
342
+ throw new EditorCommandError(
343
+ answered.error ?? `Editor command "${type}" failed`,
344
+ answered.code,
345
+ );
346
+ }
347
+ return answered;
348
+ }
349
+ const url = `${this.baseUrl}/__editor/command`;
350
+ const request = JSON.stringify(body);
351
+ let res: Response | undefined;
352
+ let retried = false;
353
+ for (;;) {
354
+ try {
355
+ res = await fetch(url, {
356
+ method: 'POST',
357
+ headers: { 'Content-Type': 'application/json' },
358
+ body: request,
359
+ signal: AbortSignal.timeout(COMMAND_DEADLINE_MS),
360
+ });
361
+ break;
362
+ } catch (error) {
363
+ // Only the deadline is reshaped into a "no answer" verdict; everything
364
+ // else is a TRANSPORT failure, and Node hides its reason behind a bare
365
+ // `fetch failed` (see `describeFetchFailure`).
366
+ if ((error as { name?: string } | null)?.name === 'TimeoutError') {
367
+ throw new EditorCommandError(
368
+ `The editor at ${this.baseUrl} never answered "${type}" ` +
369
+ `within ${Math.round(COMMAND_DEADLINE_MS / 1000)}s — past every server-side budget, so ` +
370
+ 'the server itself is not answering. Check the terminal running `vgai edit`.',
371
+ undefined,
372
+ true,
373
+ );
374
+ }
375
+ const { code, detail } = describeFetchFailure(error);
376
+ if (options?.retryTransport === true && !retried && RETRYABLE_TRANSPORT_CODES.has(code)) {
377
+ retried = true;
378
+ await new Promise((resolve) => setTimeout(resolve, TRANSPORT_RETRY_DELAY_MS));
379
+ continue;
380
+ }
381
+ throw new EditorCommandError(
382
+ `POST ${url} ("${type}") never reached the editor: ${code}${detail ? ` (${detail})` : ''}.` +
383
+ (retried
384
+ ? ` Retried once after ${TRANSPORT_RETRY_DELAY_MS}ms; it failed the same way.`
385
+ : '') +
386
+ (options?.retryTransport === true
387
+ ? ''
388
+ : ' Not retried automatically: this command can change editor state, and a socket that' +
389
+ ' died after the request was written cannot prove the editor did not already run it.') +
390
+ ' A transport failure means the port stopped answering, not that the editor refused —' +
391
+ ' the dev server restarts on any watched source edit, and it shuts itself down after an' +
392
+ ' idle window. Check the terminal running `vgai edit`, and `vgai sessions` for the port' +
393
+ ' this client resolved.',
394
+ code,
395
+ false,
396
+ );
397
+ }
270
398
  }
271
399
  const data = (await this.readJson(res)) as { ok: boolean; error?: string; code?: string } & T;
272
400
  if (!data.ok) {
@@ -375,6 +503,40 @@ export class EditorClient {
375
503
  await this.command({ type: 'view-preset', preset });
376
504
  }
377
505
 
506
+ /**
507
+ * LOOK AROUND THE OPEN MODEL, visibly. Swings the active Object3D
508
+ * document's camera — the one on the human's screen — by `azimuth`/
509
+ * `elevation` radians, animated over `duration` seconds, and resolves when
510
+ * the move ends. A human drag during the move cancels it where it stands
511
+ * (`cancelledBy: 'human'`); the promise still resolves.
512
+ */
513
+ async orbitDocument(options: {
514
+ azimuth?: number;
515
+ elevation?: number;
516
+ duration?: number;
517
+ }): Promise<DocumentLookOutcome> {
518
+ return this.command<DocumentLookOutcome>({ type: 'document-orbit', ...options });
519
+ }
520
+
521
+ /** A slow full revolution around the open document's subject, at a constant rate. */
522
+ async turntableDocument(options?: {
523
+ seconds?: number;
524
+ revolutions?: number;
525
+ }): Promise<DocumentLookOutcome> {
526
+ return this.command<DocumentLookOutcome>({ type: 'document-turntable', ...options });
527
+ }
528
+
529
+ /**
530
+ * Frame the open document's subject (its selection if it has one). `fit`
531
+ * scales the fitted distance: 1 is the toolbar Frame button's tight fit.
532
+ */
533
+ async frameDocument(fit?: number): Promise<DocumentCameraPose> {
534
+ return this.command<DocumentCameraPose>({
535
+ type: 'document-frame',
536
+ ...(fit === undefined ? {} : { fit }),
537
+ });
538
+ }
539
+
378
540
  async setCamera(position: Vec3Value, target: Vec3Value, fov?: number): Promise<void> {
379
541
  await this.command({
380
542
  type: 'set-camera',
@@ -437,6 +599,11 @@ export class EditorClient {
437
599
  // re-derives (or softens) it.
438
600
  ...(flatness && typeof flatness.dominantFraction === 'number' ? { flatness } : {}),
439
601
  ...(data.loopRecoveryFrame === true ? { loopRecoveryFrame: true } : {}),
602
+ // Same pass-through rule: the recorded-run notice is written where the
603
+ // pixels are, so nothing here re-derives or softens it.
604
+ ...(data.recording && typeof data.recording.notice === 'string'
605
+ ? { recording: data.recording }
606
+ : {}),
440
607
  };
441
608
  }
442
609
 
@@ -457,6 +624,15 @@ export class EditorClient {
457
624
  return this.command<GameplayRecordingCapture>({ type: 'bridge-recording-stop' });
458
625
  }
459
626
 
627
+ /** Read the active capture's monotonic media position. This is the only clock
628
+ * suitable for selecting intervals inside the finalized recording. */
629
+ async getGameplayRecordingTimeline(): Promise<GameplayRecordingTimeline> {
630
+ const timeline = await this.command<GameplayRecordingTimeline>({
631
+ type: 'bridge-recording-timeline',
632
+ });
633
+ return { startedAt: timeline.startedAt, elapsedMs: timeline.elapsedMs };
634
+ }
635
+
460
636
  /**
461
637
  * Capture an isolated, deterministic four-view preview through the editor's
462
638
  * native Asset Lab. The SDK delegates rendering to the editor; it never
@@ -466,14 +642,22 @@ export class EditorClient {
466
642
  source: AssetPreviewSource,
467
643
  options: AssetPreviewOptions = {},
468
644
  ): Promise<AssetPreviewCapture> {
469
- const data = await this.command<AssetPreviewCapture>({
470
- type: 'capture-asset-preview',
471
- ...source,
472
- ...options,
473
- });
645
+ const data = await this.command<AssetPreviewCapture>(
646
+ {
647
+ type: 'capture-asset-preview',
648
+ ...source,
649
+ ...options,
650
+ },
651
+ // Photographing changes nothing, and this is the relay `vgai screenshot
652
+ // <module>` / `project.bake.preview` rides — the lane where a momentary
653
+ // transport failure cost a cold agent three probe modules.
654
+ { retryTransport: true },
655
+ );
474
656
  return {
475
657
  width: data.width,
476
658
  height: data.height,
659
+ // Absent from editors that predate orientation reporting.
660
+ ...(data.orientation ? { orientation: data.orientation } : {}),
477
661
  views: data.views,
478
662
  contactSheet: data.contactSheet,
479
663
  };
@@ -494,12 +678,15 @@ export class EditorClient {
494
678
  definition: AssetPreviewShotSetDefinition,
495
679
  options: AssetPreviewOptions = {},
496
680
  ): Promise<LabeledShotSetCapture> {
497
- const data = await this.command<LabeledShotSetCapture>({
498
- type: 'capture-asset-preview',
499
- ...source,
500
- ...options,
501
- shotSet: definition,
502
- });
681
+ const data = await this.command<LabeledShotSetCapture>(
682
+ {
683
+ type: 'capture-asset-preview',
684
+ ...source,
685
+ ...options,
686
+ shotSet: definition,
687
+ },
688
+ { retryTransport: true },
689
+ );
503
690
  return {
504
691
  width: data.width,
505
692
  height: data.height,
@@ -588,6 +775,13 @@ export class EditorClient {
588
775
  await this.command({ type: 'open-asset-tab', path, kind });
589
776
  }
590
777
 
778
+ /** SELECT a project asset — the other half of the browser's
779
+ * selection-vs-open contract (single click selects and fills the
780
+ * Inspector; double click opens a document). */
781
+ async selectAsset(path: string): Promise<void> {
782
+ await this.command({ type: 'select-asset', path });
783
+ }
784
+
591
785
  /** Generate a project-owned native Rapier ragdoll from a rigged model asset. */
592
786
  async generateRagdoll(assetPath: string): Promise<RagdollGenerationResult> {
593
787
  return this.command<RagdollGenerationResult>({ type: 'generate-ragdoll', assetPath });
@@ -605,6 +799,14 @@ export class EditorClient {
605
799
  await this.command({ type: 'toggle-console' });
606
800
  }
607
801
 
802
+ /** Switch the editor's NAMED WORKSPACE — the task-named layout memory
803
+ * (`game`/`model`/`sculpt`/`texture`/`animate`/`look`). Resolves once the
804
+ * dock has finished rebuilding, so a following capture photographs the
805
+ * arrangement that was asked for. */
806
+ async setWorkspace(workspace: EditorWorkspaceName): Promise<void> {
807
+ await this.command({ type: 'set-workspace', workspace });
808
+ }
809
+
608
810
  async showBuild(): Promise<void> {
609
811
  await this.command({ type: 'show-build' });
610
812
  }
@@ -645,6 +847,26 @@ export class EditorClient {
645
847
  return data.subject;
646
848
  }
647
849
 
850
+ /**
851
+ * One STRUCTURE op on the authored tree — the hierarchy context menu's own
852
+ * verbs, on the same helpers, for a caller with no pointer to right-click
853
+ * with. `id`/`ids` default to the current selection.
854
+ */
855
+ async structureOp(op: StructureOp, options: StructureOpOptions = {}): Promise<StructureOpResult> {
856
+ return this.command<StructureOpResult>({ type: 'structure-op', op, ...options });
857
+ }
858
+
859
+ /** "Extract Component…" — the hierarchy row's action, as a command. Answers
860
+ * the action's own sentence, which NAMES the files it created. */
861
+ async extractComponent(options: { id?: string; name?: string } = {}): Promise<{ hint: string }> {
862
+ return this.command<{ hint: string }>({ type: 'extract-component', ...options });
863
+ }
864
+
865
+ /** "Fork Component…" — extract's twin: one new file, one callsite retargeted. */
866
+ async forkComponent(options: { id?: string } = {}): Promise<{ hint: string }> {
867
+ return this.command<{ hint: string }>({ type: 'fork-component', ...options });
868
+ }
869
+
648
870
  /**
649
871
  * The HIERARCHY PANEL's actual rendered row tree, as data.
650
872
  *
@@ -672,6 +894,11 @@ export class EditorClient {
672
894
  return data.hierarchy;
673
895
  }
674
896
 
897
+ /** Run the Hierarchy panel's own Expand All action. */
898
+ async expandHierarchyAll(): Promise<void> {
899
+ await this.command<Record<string, never>>({ type: 'expand-hierarchy-all' });
900
+ }
901
+
675
902
  /**
676
903
  * Write one editable path through the active Inspector's own IO.
677
904
  *
@@ -758,11 +985,33 @@ export class EditorClient {
758
985
  return data.view;
759
986
  }
760
987
 
761
- /** Capture the active center document exactly as presented to the user. */
762
- async captureActiveDocument(size?: number): Promise<ActiveDocumentCapture> {
988
+ /**
989
+ * Capture the active center document exactly as presented to the user.
990
+ *
991
+ * A number is a SQUARE of that size (the default shape); `{width, height}`
992
+ * asks for a shaped frame — a video-aspect look that needs no crop. Both are
993
+ * bounded by the relay budget; see {@link CaptureDimensions}.
994
+ */
995
+ /** Photograph the editor PAGE itself — every panel, as the person sees it.
996
+ * The sighted door for chrome: skins, workspaces and contributed panels are
997
+ * judged through this, never through DOM probes. */
998
+ async captureEditorChrome(): Promise<EditorChromeCapture> {
999
+ return this.command<EditorChromeCapture>({ type: 'capture-editor-chrome' });
1000
+ }
1001
+
1002
+ /** With a view, present and capture it in one request so document discovery
1003
+ * cannot retarget the capture between two client calls. */
1004
+ async captureActiveDocument(
1005
+ size?: CaptureDimensions,
1006
+ view?: EditorView,
1007
+ ): Promise<ActiveDocumentCapture> {
763
1008
  return this.command<ActiveDocumentCapture>({
764
1009
  type: 'capture-active-document',
765
- ...(size === undefined ? {} : { size }),
1010
+ ...(view ? { view } : {}),
1011
+ ...(typeof size === 'number' ? { size } : {}),
1012
+ ...(typeof size === 'object' && size !== null
1013
+ ? { width: size.width, height: size.height }
1014
+ : {}),
766
1015
  });
767
1016
  }
768
1017
 
@@ -778,6 +1027,17 @@ export class EditorClient {
778
1027
  return this.command<DocumentProbeResult>({ type: 'document-probe', step });
779
1028
  }
780
1029
 
1030
+ /**
1031
+ * Run a wire-carried step against the ACTIVE document's published context
1032
+ * (`packages/editor/src/document-context-registry.ts`) — the REPL door over
1033
+ * an open document, in Edit mode. `src` is the step's own `toString()`;
1034
+ * same serialization contract as `page-script` (no closures survive).
1035
+ */
1036
+ async documentScript<T = unknown>(src: string): Promise<T> {
1037
+ const outcome = await this.command<{ result: T }>({ type: 'document-script', src });
1038
+ return outcome.result;
1039
+ }
1040
+
781
1041
  // --- Display (set semantics) ---
782
1042
 
783
1043
  async setGrid(enabled: boolean): Promise<void> {
@@ -827,7 +1087,7 @@ export class EditorClient {
827
1087
  template: ProjectTemplate = 'default',
828
1088
  exampleId?: string,
829
1089
  ): Promise<ProjectInfo> {
830
- const res = await fetch(`${this.baseUrl}/__editor/create-project`, {
1090
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/create-project`, {
831
1091
  method: 'POST',
832
1092
  headers: { 'Content-Type': 'application/json' },
833
1093
  body: JSON.stringify({ name, location, template, ...(exampleId ? { exampleId } : {}) }),
@@ -845,7 +1105,7 @@ export class EditorClient {
845
1105
  }
846
1106
 
847
1107
  async openProject(path: string): Promise<void> {
848
- const res = await fetch(`${this.baseUrl}/__editor/open-project`, {
1108
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/open-project`, {
849
1109
  method: 'POST',
850
1110
  headers: { 'Content-Type': 'application/json' },
851
1111
  body: JSON.stringify({ path }),
@@ -857,14 +1117,14 @@ export class EditorClient {
857
1117
  }
858
1118
 
859
1119
  async getProject(): Promise<ProjectInfo | null> {
860
- const res = await fetch(`${this.baseUrl}/__editor/project`);
1120
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/project`);
861
1121
  const data = (await this.readJson(res)) as { project: ProjectInfo | null; error?: string };
862
1122
  if (!res.ok) throw new Error(data.error ?? `Failed to get project: ${res.status}`);
863
1123
  return data.project;
864
1124
  }
865
1125
 
866
1126
  async listRecentProjects(): Promise<RecentProject[]> {
867
- const res = await fetch(`${this.baseUrl}/__editor/recent-projects`);
1127
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/recent-projects`);
868
1128
  const data = (await this.readJson(res)) as { projects: RecentProject[]; error?: string };
869
1129
  if (!res.ok) throw new Error(data.error ?? `Failed to list projects: ${res.status}`);
870
1130
  return data.projects;
@@ -876,7 +1136,7 @@ export class EditorClient {
876
1136
  * The editor server loads callable metadata in Node; modules never enter the
877
1137
  * editor browser merely because they were listed. */
878
1138
  async listProjectTools(): Promise<ProjectToolCatalog> {
879
- const res = await fetch(`${this.baseUrl}/__editor/project-tools`);
1139
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/project-tools`);
880
1140
  const body = await this.readJson(res);
881
1141
  if (!res.ok) throw new Error(`Failed to list project tools: ${res.status}`);
882
1142
  return body as ProjectToolCatalog;
@@ -889,7 +1149,7 @@ export class EditorClient {
889
1149
  input: unknown = {},
890
1150
  options: { confirm?: boolean; instance?: string } = {},
891
1151
  ): Promise<ProjectToolOutcome> {
892
- const res = await fetch(`${this.baseUrl}/__editor/project-tools/run`, {
1152
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/project-tools/run`, {
893
1153
  method: 'POST',
894
1154
  headers: { 'Content-Type': 'application/json' },
895
1155
  body: JSON.stringify({
@@ -914,7 +1174,7 @@ export class EditorClient {
914
1174
  /** Read the one project-local generation job ledger. Provider-native
915
1175
  * request/result shapes remain on their registered operations. */
916
1176
  async listGenerationJobs(): Promise<GenerationJobsDocument> {
917
- const res = await fetch(`${this.baseUrl}/__editor/generations`);
1177
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/generations`);
918
1178
  const body = await this.readJson(res);
919
1179
  if (!res.ok) throw new Error(`Failed to list generation jobs: ${res.status}`);
920
1180
  return body as GenerationJobsDocument;
@@ -923,9 +1183,12 @@ export class EditorClient {
923
1183
  /** Forget operational job state. Accepted provenance and project assets
924
1184
  * are deliberately unaffected. */
925
1185
  async forgetGenerationJob(id: string): Promise<boolean> {
926
- const res = await fetch(`${this.baseUrl}/__editor/generations/${encodeURIComponent(id)}`, {
927
- method: 'DELETE',
928
- });
1186
+ const res = await this.httpFetch(
1187
+ `${this.baseUrl}/__editor/generations/${encodeURIComponent(id)}`,
1188
+ {
1189
+ method: 'DELETE',
1190
+ },
1191
+ );
929
1192
  const body = (await this.readJson(res)) as { removed?: boolean; error?: string };
930
1193
  if (!res.ok) throw new Error(body.error ?? `Failed to forget generation job: ${res.status}`);
931
1194
  return body.removed === true;
@@ -936,7 +1199,7 @@ export class EditorClient {
936
1199
  async getLogEntries(): Promise<
937
1200
  Array<{ t: number; level: string; msg: string; source?: string }>
938
1201
  > {
939
- const res = await fetch(`${this.baseUrl}/__editor/log-entries`);
1202
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/log-entries`);
940
1203
  const data = (await this.readJson(res)) as {
941
1204
  entries: Array<{ t: number; level: string; msg: string; source?: string }>;
942
1205
  };
@@ -947,7 +1210,7 @@ export class EditorClient {
947
1210
  // --- State ---
948
1211
 
949
1212
  async getState(): Promise<EditorState> {
950
- const res = await fetch(`${this.baseUrl}/__editor/state`);
1213
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/state`);
951
1214
  const state = (await this.readJson(res)) as EditorState;
952
1215
  if (!res.ok) throw new Error(`Failed to get editor state: ${res.status} ${res.statusText}`);
953
1216
  return state;
@@ -965,7 +1228,7 @@ export class EditorClient {
965
1228
  * a thrown error the caller treats as "nothing learned", never a hang.
966
1229
  */
967
1230
  async getUnresolvedConsole(opts?: { all?: boolean }): Promise<unknown> {
968
- const res = await fetch(
1231
+ const res = await this.httpFetch(
969
1232
  `${this.baseUrl}/__editor/console${opts?.all === true ? '?all=1' : ''}`,
970
1233
  { signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS) },
971
1234
  );
@@ -985,7 +1248,7 @@ export class EditorClient {
985
1248
  readonly reason: string;
986
1249
  readonly by: string;
987
1250
  }): Promise<unknown> {
988
- const res = await fetch(`${this.baseUrl}/__editor/console/ack`, {
1251
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/console/ack`, {
989
1252
  method: 'POST',
990
1253
  headers: { 'Content-Type': 'application/json' },
991
1254
  body: JSON.stringify(input),
@@ -995,6 +1258,33 @@ export class EditorClient {
995
1258
  return body;
996
1259
  }
997
1260
 
1261
+ /**
1262
+ * `fetch` for this client's plain routes, with the ONE thing Node's `fetch`
1263
+ * will not do: name why it failed.
1264
+ *
1265
+ * `readJson` below already owns "the server answered the wrong thing"; this
1266
+ * owns "nothing answered at all", which used to reach the caller as the bare
1267
+ * `TypeError: fetch failed` with the real code buried on `.cause`. No retry
1268
+ * here — these routes create projects, run tools and acknowledge console
1269
+ * conditions, so repeating one is the caller's decision. The relayed
1270
+ * `command` path above has its own opt-in retry for the read-only captures.
1271
+ */
1272
+ private async httpFetch(url: string, init?: RequestInit): Promise<Response> {
1273
+ try {
1274
+ return await fetch(url, init);
1275
+ } catch (error) {
1276
+ if ((error as { name?: string } | null)?.name === 'TimeoutError') throw error;
1277
+ const { code, detail } = describeFetchFailure(error);
1278
+ throw new EditorCommandError(
1279
+ `${init?.method ?? 'GET'} ${url} never reached the editor: ${code}` +
1280
+ `${detail ? ` (${detail})` : ''}. Nothing answered on that port — check the terminal ` +
1281
+ 'running `vgai edit`, and `vgai sessions` for the port this client resolved.',
1282
+ code,
1283
+ false,
1284
+ );
1285
+ }
1286
+ }
1287
+
998
1288
  /** Parse one JSON body and hand it to {@link onEnvelope}.
999
1289
  *
1000
1290
  * A command/state envelope carries current counts but not the named set. If
@@ -1033,7 +1323,7 @@ export class EditorClient {
1033
1323
  'unresolvedConsole' in body
1034
1324
  ) {
1035
1325
  try {
1036
- const consoleRes = await fetch(`${this.baseUrl}/__editor/console`, {
1326
+ const consoleRes = await this.httpFetch(`${this.baseUrl}/__editor/console`, {
1037
1327
  signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS),
1038
1328
  });
1039
1329
  if (consoleRes.ok) {