@vgai/editor-sdk 0.5.42 → 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.42",
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.42"
27
+ "@vgai/sdk": "0.5.44"
28
28
  },
29
29
  "peerDependencies": {
30
30
  "@vgai/engine": "*",
package/src/client.ts CHANGED
@@ -10,8 +10,13 @@ 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,
@@ -33,6 +38,9 @@ import type {
33
38
  ShadingMode,
34
39
  StoryCaptureOptions,
35
40
  StoryVariantCapture,
41
+ StructureOp,
42
+ StructureOpOptions,
43
+ StructureOpResult,
36
44
  TransformMode,
37
45
  TransformSpace,
38
46
  Vec3Value,
@@ -115,6 +123,59 @@ const COMMAND_DEADLINE_MS = 150_000;
115
123
  */
116
124
  const CONSOLE_DRAIN_TIMEOUT_MS = 1_500;
117
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
+
118
179
  /** Context accompanying one {@link EditorClient} response observation. */
119
180
  export interface EditorEnvelopeObservation {
120
181
  /** True only when this body came from the console-ledger endpoint and its
@@ -206,6 +267,9 @@ export class EditorClient {
206
267
  * The observer must not throw; anything it raises is swallowed, because a
207
268
  * reporting hook may never break the command it is reporting on.
208
269
  */
270
+ private readonly transport:
271
+ | ((body: Record<string, unknown>) => Promise<Record<string, unknown>>)
272
+ | null;
209
273
  private readonly onEnvelope:
210
274
  | ((body: unknown, observation: EditorEnvelopeObservation) => void)
211
275
  | null;
@@ -213,6 +277,14 @@ export class EditorClient {
213
277
  constructor(opts?: {
214
278
  url?: string;
215
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>>;
216
288
  }) {
217
289
  if (opts !== undefined && (typeof opts !== 'object' || opts === null || Array.isArray(opts))) {
218
290
  throw new TypeError(
@@ -220,7 +292,7 @@ export class EditorClient {
220
292
  );
221
293
  }
222
294
  const unknownOptions = Object.keys(opts ?? {}).filter(
223
- (key) => key !== 'url' && key !== 'onEnvelope',
295
+ (key) => key !== 'url' && key !== 'onEnvelope' && key !== 'transport',
224
296
  );
225
297
  if (unknownOptions.length > 0) {
226
298
  throw new Error(
@@ -229,6 +301,7 @@ export class EditorClient {
229
301
  }
230
302
  this.baseUrl = (opts?.url ?? DEFAULT_URL).replace(/\/$/, '');
231
303
  this.onEnvelope = opts?.onEnvelope ?? null;
304
+ this.transport = opts?.transport ?? null;
232
305
  this.game = {
233
306
  state: async (name: string): Promise<unknown> => {
234
307
  // `keys` narrows the relay to the one provider asked for, so a panel
@@ -246,28 +319,82 @@ export class EditorClient {
246
319
  };
247
320
  }
248
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
+ */
249
330
  private async command<T extends object = Record<string, never>>(
250
331
  body: Record<string, unknown>,
332
+ options?: { retryTransport?: boolean },
251
333
  ): Promise<T> {
252
- let res: Response;
253
- try {
254
- res = await fetch(`${this.baseUrl}/__editor/command`, {
255
- method: 'POST',
256
- headers: { 'Content-Type': 'application/json' },
257
- body: JSON.stringify(body),
258
- signal: AbortSignal.timeout(COMMAND_DEADLINE_MS),
259
- });
260
- } catch (error) {
261
- // Only the deadline is reshaped; a connection refused / DNS failure
262
- // still surfaces as itself, because those name their own cause.
263
- if ((error as { name?: string } | null)?.name !== 'TimeoutError') throw error;
264
- throw new EditorCommandError(
265
- `The editor at ${this.baseUrl} never answered "${String(body['type'] ?? 'command')}" ` +
266
- `within ${Math.round(COMMAND_DEADLINE_MS / 1000)}s — past every server-side budget, so ` +
267
- 'the server itself is not answering. Check the terminal running `vgai edit`.',
268
- undefined,
269
- true,
270
- );
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
+ }
271
398
  }
272
399
  const data = (await this.readJson(res)) as { ok: boolean; error?: string; code?: string } & T;
273
400
  if (!data.ok) {
@@ -376,6 +503,40 @@ export class EditorClient {
376
503
  await this.command({ type: 'view-preset', preset });
377
504
  }
378
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
+
379
540
  async setCamera(position: Vec3Value, target: Vec3Value, fov?: number): Promise<void> {
380
541
  await this.command({
381
542
  type: 'set-camera',
@@ -438,6 +599,11 @@ export class EditorClient {
438
599
  // re-derives (or softens) it.
439
600
  ...(flatness && typeof flatness.dominantFraction === 'number' ? { flatness } : {}),
440
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
+ : {}),
441
607
  };
442
608
  }
443
609
 
@@ -476,14 +642,22 @@ export class EditorClient {
476
642
  source: AssetPreviewSource,
477
643
  options: AssetPreviewOptions = {},
478
644
  ): Promise<AssetPreviewCapture> {
479
- const data = await this.command<AssetPreviewCapture>({
480
- type: 'capture-asset-preview',
481
- ...source,
482
- ...options,
483
- });
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
+ );
484
656
  return {
485
657
  width: data.width,
486
658
  height: data.height,
659
+ // Absent from editors that predate orientation reporting.
660
+ ...(data.orientation ? { orientation: data.orientation } : {}),
487
661
  views: data.views,
488
662
  contactSheet: data.contactSheet,
489
663
  };
@@ -504,12 +678,15 @@ export class EditorClient {
504
678
  definition: AssetPreviewShotSetDefinition,
505
679
  options: AssetPreviewOptions = {},
506
680
  ): Promise<LabeledShotSetCapture> {
507
- const data = await this.command<LabeledShotSetCapture>({
508
- type: 'capture-asset-preview',
509
- ...source,
510
- ...options,
511
- shotSet: definition,
512
- });
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
+ );
513
690
  return {
514
691
  width: data.width,
515
692
  height: data.height,
@@ -598,6 +775,13 @@ export class EditorClient {
598
775
  await this.command({ type: 'open-asset-tab', path, kind });
599
776
  }
600
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
+
601
785
  /** Generate a project-owned native Rapier ragdoll from a rigged model asset. */
602
786
  async generateRagdoll(assetPath: string): Promise<RagdollGenerationResult> {
603
787
  return this.command<RagdollGenerationResult>({ type: 'generate-ragdoll', assetPath });
@@ -615,6 +799,14 @@ export class EditorClient {
615
799
  await this.command({ type: 'toggle-console' });
616
800
  }
617
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
+
618
810
  async showBuild(): Promise<void> {
619
811
  await this.command({ type: 'show-build' });
620
812
  }
@@ -655,6 +847,26 @@ export class EditorClient {
655
847
  return data.subject;
656
848
  }
657
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
+
658
870
  /**
659
871
  * The HIERARCHY PANEL's actual rendered row tree, as data.
660
872
  *
@@ -773,11 +985,33 @@ export class EditorClient {
773
985
  return data.view;
774
986
  }
775
987
 
776
- /** Capture the active center document exactly as presented to the user. */
777
- 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> {
778
1008
  return this.command<ActiveDocumentCapture>({
779
1009
  type: 'capture-active-document',
780
- ...(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
+ : {}),
781
1015
  });
782
1016
  }
783
1017
 
@@ -793,6 +1027,17 @@ export class EditorClient {
793
1027
  return this.command<DocumentProbeResult>({ type: 'document-probe', step });
794
1028
  }
795
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
+
796
1041
  // --- Display (set semantics) ---
797
1042
 
798
1043
  async setGrid(enabled: boolean): Promise<void> {
@@ -842,7 +1087,7 @@ export class EditorClient {
842
1087
  template: ProjectTemplate = 'default',
843
1088
  exampleId?: string,
844
1089
  ): Promise<ProjectInfo> {
845
- const res = await fetch(`${this.baseUrl}/__editor/create-project`, {
1090
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/create-project`, {
846
1091
  method: 'POST',
847
1092
  headers: { 'Content-Type': 'application/json' },
848
1093
  body: JSON.stringify({ name, location, template, ...(exampleId ? { exampleId } : {}) }),
@@ -860,7 +1105,7 @@ export class EditorClient {
860
1105
  }
861
1106
 
862
1107
  async openProject(path: string): Promise<void> {
863
- const res = await fetch(`${this.baseUrl}/__editor/open-project`, {
1108
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/open-project`, {
864
1109
  method: 'POST',
865
1110
  headers: { 'Content-Type': 'application/json' },
866
1111
  body: JSON.stringify({ path }),
@@ -872,14 +1117,14 @@ export class EditorClient {
872
1117
  }
873
1118
 
874
1119
  async getProject(): Promise<ProjectInfo | null> {
875
- const res = await fetch(`${this.baseUrl}/__editor/project`);
1120
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/project`);
876
1121
  const data = (await this.readJson(res)) as { project: ProjectInfo | null; error?: string };
877
1122
  if (!res.ok) throw new Error(data.error ?? `Failed to get project: ${res.status}`);
878
1123
  return data.project;
879
1124
  }
880
1125
 
881
1126
  async listRecentProjects(): Promise<RecentProject[]> {
882
- const res = await fetch(`${this.baseUrl}/__editor/recent-projects`);
1127
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/recent-projects`);
883
1128
  const data = (await this.readJson(res)) as { projects: RecentProject[]; error?: string };
884
1129
  if (!res.ok) throw new Error(data.error ?? `Failed to list projects: ${res.status}`);
885
1130
  return data.projects;
@@ -891,7 +1136,7 @@ export class EditorClient {
891
1136
  * The editor server loads callable metadata in Node; modules never enter the
892
1137
  * editor browser merely because they were listed. */
893
1138
  async listProjectTools(): Promise<ProjectToolCatalog> {
894
- const res = await fetch(`${this.baseUrl}/__editor/project-tools`);
1139
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/project-tools`);
895
1140
  const body = await this.readJson(res);
896
1141
  if (!res.ok) throw new Error(`Failed to list project tools: ${res.status}`);
897
1142
  return body as ProjectToolCatalog;
@@ -904,7 +1149,7 @@ export class EditorClient {
904
1149
  input: unknown = {},
905
1150
  options: { confirm?: boolean; instance?: string } = {},
906
1151
  ): Promise<ProjectToolOutcome> {
907
- const res = await fetch(`${this.baseUrl}/__editor/project-tools/run`, {
1152
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/project-tools/run`, {
908
1153
  method: 'POST',
909
1154
  headers: { 'Content-Type': 'application/json' },
910
1155
  body: JSON.stringify({
@@ -929,7 +1174,7 @@ export class EditorClient {
929
1174
  /** Read the one project-local generation job ledger. Provider-native
930
1175
  * request/result shapes remain on their registered operations. */
931
1176
  async listGenerationJobs(): Promise<GenerationJobsDocument> {
932
- const res = await fetch(`${this.baseUrl}/__editor/generations`);
1177
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/generations`);
933
1178
  const body = await this.readJson(res);
934
1179
  if (!res.ok) throw new Error(`Failed to list generation jobs: ${res.status}`);
935
1180
  return body as GenerationJobsDocument;
@@ -938,9 +1183,12 @@ export class EditorClient {
938
1183
  /** Forget operational job state. Accepted provenance and project assets
939
1184
  * are deliberately unaffected. */
940
1185
  async forgetGenerationJob(id: string): Promise<boolean> {
941
- const res = await fetch(`${this.baseUrl}/__editor/generations/${encodeURIComponent(id)}`, {
942
- method: 'DELETE',
943
- });
1186
+ const res = await this.httpFetch(
1187
+ `${this.baseUrl}/__editor/generations/${encodeURIComponent(id)}`,
1188
+ {
1189
+ method: 'DELETE',
1190
+ },
1191
+ );
944
1192
  const body = (await this.readJson(res)) as { removed?: boolean; error?: string };
945
1193
  if (!res.ok) throw new Error(body.error ?? `Failed to forget generation job: ${res.status}`);
946
1194
  return body.removed === true;
@@ -951,7 +1199,7 @@ export class EditorClient {
951
1199
  async getLogEntries(): Promise<
952
1200
  Array<{ t: number; level: string; msg: string; source?: string }>
953
1201
  > {
954
- const res = await fetch(`${this.baseUrl}/__editor/log-entries`);
1202
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/log-entries`);
955
1203
  const data = (await this.readJson(res)) as {
956
1204
  entries: Array<{ t: number; level: string; msg: string; source?: string }>;
957
1205
  };
@@ -962,7 +1210,7 @@ export class EditorClient {
962
1210
  // --- State ---
963
1211
 
964
1212
  async getState(): Promise<EditorState> {
965
- const res = await fetch(`${this.baseUrl}/__editor/state`);
1213
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/state`);
966
1214
  const state = (await this.readJson(res)) as EditorState;
967
1215
  if (!res.ok) throw new Error(`Failed to get editor state: ${res.status} ${res.statusText}`);
968
1216
  return state;
@@ -980,7 +1228,7 @@ export class EditorClient {
980
1228
  * a thrown error the caller treats as "nothing learned", never a hang.
981
1229
  */
982
1230
  async getUnresolvedConsole(opts?: { all?: boolean }): Promise<unknown> {
983
- const res = await fetch(
1231
+ const res = await this.httpFetch(
984
1232
  `${this.baseUrl}/__editor/console${opts?.all === true ? '?all=1' : ''}`,
985
1233
  { signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS) },
986
1234
  );
@@ -1000,7 +1248,7 @@ export class EditorClient {
1000
1248
  readonly reason: string;
1001
1249
  readonly by: string;
1002
1250
  }): Promise<unknown> {
1003
- const res = await fetch(`${this.baseUrl}/__editor/console/ack`, {
1251
+ const res = await this.httpFetch(`${this.baseUrl}/__editor/console/ack`, {
1004
1252
  method: 'POST',
1005
1253
  headers: { 'Content-Type': 'application/json' },
1006
1254
  body: JSON.stringify(input),
@@ -1010,6 +1258,33 @@ export class EditorClient {
1010
1258
  return body;
1011
1259
  }
1012
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
+
1013
1288
  /** Parse one JSON body and hand it to {@link onEnvelope}.
1014
1289
  *
1015
1290
  * A command/state envelope carries current counts but not the named set. If
@@ -1048,7 +1323,7 @@ export class EditorClient {
1048
1323
  'unresolvedConsole' in body
1049
1324
  ) {
1050
1325
  try {
1051
- const consoleRes = await fetch(`${this.baseUrl}/__editor/console`, {
1326
+ const consoleRes = await this.httpFetch(`${this.baseUrl}/__editor/console`, {
1052
1327
  signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS),
1053
1328
  });
1054
1329
  if (consoleRes.ok) {