@vgai/editor-sdk 0.5.15 → 0.5.17

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.15",
5
+ "version": "0.5.17",
6
6
  "type": "module",
7
7
  "repository": {
8
8
  "type": "git",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@types/three": "^0.180.0",
26
- "@vgai/sdk": "0.5.15"
26
+ "@vgai/sdk": "0.5.17"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "@vgai/engine": "*",
package/src/client.ts CHANGED
@@ -26,6 +26,7 @@ import type {
26
26
  ProjectTemplate,
27
27
  ProjectToolCatalog,
28
28
  ProjectToolOutcome,
29
+ RagdollGenerationResult,
29
30
  RecentProject,
30
31
  ShadingMode,
31
32
  StoryCaptureOptions,
@@ -103,6 +104,23 @@ export class EditorCommandError extends Error {
103
104
  */
104
105
  const COMMAND_DEADLINE_MS = 150_000;
105
106
 
107
+ /**
108
+ * Ceiling on {@link EditorClient.getUnresolvedConsole}. The CLI drains this
109
+ * on every verb, including ones that never wait for a command envelope, so
110
+ * a silent hang here would become a silent hang on `vgai examples`. The
111
+ * server route is a plain in-process GET; 1.5s is already longer than it
112
+ * should ever take.
113
+ */
114
+ const CONSOLE_DRAIN_TIMEOUT_MS = 1_500;
115
+
116
+ /** Context accompanying one {@link EditorClient} response observation. */
117
+ export interface EditorEnvelopeObservation {
118
+ /** True only when this body came from the console-ledger endpoint and its
119
+ * `entries` field is therefore the complete named console set. Command
120
+ * payloads may also own an unrelated `entries` field. */
121
+ readonly unresolvedConsoleComplete: boolean;
122
+ }
123
+
106
124
  /**
107
125
  * The GAME DEBUG PLANE, as a contribution's client sees it.
108
126
  *
@@ -130,6 +148,27 @@ export interface HistoryStep {
130
148
  readonly redoLabel: string | null;
131
149
  }
132
150
 
151
+ /** What `open()` acknowledges: the workspace document the scene-table entry
152
+ * resolved to, and the title its tab now carries — the game's own word for
153
+ * that composition, not a filename. */
154
+ export interface OpenedDocument {
155
+ readonly documentId: string;
156
+ readonly title: string;
157
+ /**
158
+ * The GAME's own answer, present only when opening navigated a running game
159
+ * (a scene the adapter declares reachable through the game's scenes
160
+ * contract, or a native swap-slot remount): what was asked for, and which
161
+ * scene the game reports it is in once its own navigation settled. `current`
162
+ * can differ from `requested` — that is the game's reading, not a host claim.
163
+ */
164
+ readonly scene?: { readonly requested: string; readonly current: string | null };
165
+ /**
166
+ * Present when opening restarted play at a native swap-slot key rather than
167
+ * navigating a live contract — the slot is a module-level const.
168
+ */
169
+ readonly restart?: true;
170
+ }
171
+
133
172
  export interface GameDebugDoor {
134
173
  /**
135
174
  * Read ONE registered state provider by name (`'bot.tester'`). `undefined`
@@ -165,9 +204,14 @@ export class EditorClient {
165
204
  * The observer must not throw; anything it raises is swallowed, because a
166
205
  * reporting hook may never break the command it is reporting on.
167
206
  */
168
- private readonly onEnvelope: ((body: unknown) => void) | null;
169
-
170
- constructor(opts?: { url?: string; onEnvelope?: (body: unknown) => void }) {
207
+ private readonly onEnvelope:
208
+ | ((body: unknown, observation: EditorEnvelopeObservation) => void)
209
+ | null;
210
+
211
+ constructor(opts?: {
212
+ url?: string;
213
+ onEnvelope?: (body: unknown, observation: EditorEnvelopeObservation) => void;
214
+ }) {
171
215
  if (opts !== undefined && (typeof opts !== 'object' || opts === null || Array.isArray(opts))) {
172
216
  throw new TypeError(
173
217
  'EditorClient options must be an object. Use new EditorClient({ url: "http://127.0.0.1:20173" }), not new EditorClient("...").',
@@ -223,8 +267,7 @@ export class EditorClient {
223
267
  true,
224
268
  );
225
269
  }
226
- const data = (await res.json()) as { ok: boolean; error?: string; code?: string } & T;
227
- this.observe(data);
270
+ const data = (await this.readJson(res)) as { ok: boolean; error?: string; code?: string } & T;
228
271
  if (!data.ok) {
229
272
  throw new EditorCommandError(
230
273
  data.error ?? `Editor command failed: ${res.status}`,
@@ -361,21 +404,11 @@ export class EditorClient {
361
404
  * `vgai eval` could recover these frames while `vgai screenshot` could not.
362
405
  * Off by default: a caller who does not ask must never be handed a frame
363
406
  * that only exists because the capture drove the game.
364
- *
365
- * `opts.devLayers` puts the game's DEV LAYERS (`dev: true` roots — its own
366
- * in-game dev GUI) INTO the frame. Also off by default, for the mirror-image
367
- * reason: a panel frame at the moment of a failure is strong evidence, and a
368
- * "does the game look right" frame with the panel painted over it is
369
- * worthless — so the contamination only happens when someone asks for it.
370
407
  */
371
- async captureGame(opts?: {
372
- refreshStarvedFrame?: boolean;
373
- devLayers?: boolean;
374
- }): Promise<GameCapture> {
408
+ async captureGame(opts?: { refreshStarvedFrame?: boolean }): Promise<GameCapture> {
375
409
  const data = await this.command<GameCapture>({
376
410
  type: 'bridge-screenshot',
377
411
  ...(opts?.refreshStarvedFrame === true ? { refreshStarvedFrame: true } : {}),
378
- ...(opts?.devLayers === true ? { devLayers: true } : {}),
379
412
  });
380
413
  const layers = data.layers;
381
414
  const flatness = data.flatness;
@@ -403,7 +436,6 @@ export class EditorClient {
403
436
  return this.command<GameplayRecordingStarted>({
404
437
  type: 'bridge-recording-start',
405
438
  ...(options.fps !== undefined ? { fps: options.fps } : {}),
406
- ...(options.devLayers === true ? { devLayers: true } : {}),
407
439
  });
408
440
  }
409
441
 
@@ -543,6 +575,11 @@ export class EditorClient {
543
575
  await this.command({ type: 'open-asset-tab', path, kind });
544
576
  }
545
577
 
578
+ /** Generate a project-owned native Rapier ragdoll from a rigged model asset. */
579
+ async generateRagdoll(assetPath: string): Promise<RagdollGenerationResult> {
580
+ return this.command<RagdollGenerationResult>({ type: 'generate-ragdoll', assetPath });
581
+ }
582
+
546
583
  async closeAsset(key: string): Promise<void> {
547
584
  await this.command({ type: 'close-asset-tab', key });
548
585
  }
@@ -586,6 +623,15 @@ export class EditorClient {
586
623
  return data.subject;
587
624
  }
588
625
 
626
+ /** Run one verb exposed by the active Inspector subject, by its id. */
627
+ async runInspectionAction(actionId: string): Promise<InspectedInspection> {
628
+ const data = await this.command<{ subject: InspectedInspection }>({
629
+ type: 'run-inspection-action',
630
+ actionId,
631
+ });
632
+ return data.subject;
633
+ }
634
+
589
635
  /**
590
636
  * The HIERARCHY PANEL's actual rendered row tree, as data.
591
637
  *
@@ -630,6 +676,57 @@ export class EditorClient {
630
676
  return { subject: data.subject, write: data.write };
631
677
  }
632
678
 
679
+ /**
680
+ * REMOVE one editable path's authored override — the other half of the write
681
+ * door, and the only one that can express byte-ABSENCE.
682
+ *
683
+ * {@link setInspectionField} writes a VALUE, so reverting a property an
684
+ * authoring gesture ADDED puts the default back EXPLICITLY and leaves the
685
+ * source one attribute heavier than it started. This drops the property, so
686
+ * whatever governs it in its absence takes over — the same `io.remove` the
687
+ * Inspector's revert arrow calls, the same persistence pipe, the same awaited
688
+ * `{ destination, persisted }` ack.
689
+ *
690
+ * Rejects with `code: 'REMOVAL_UNAVAILABLE'` when the field does not declare
691
+ * itself removable or the lane implements no removal door. That refusal is a
692
+ * MISSING SEAM, not a failed removal, and it is coded rather than phrased
693
+ * precisely so a caller can grade the two differently.
694
+ */
695
+ async removeInspectionField(path: string): Promise<InspectedFieldWrite> {
696
+ const data = await this.command<InspectedFieldWrite>({
697
+ type: 'remove-inspection-field',
698
+ path,
699
+ });
700
+ return { subject: data.subject, write: data.write };
701
+ }
702
+
703
+ /**
704
+ * OPEN one piece of the adapter's SCENE TABLE by id — a scene, a prefab, or
705
+ * a story state, because the table makes them siblings (they differ only in
706
+ * instance site). The ids are exactly what `getState().adapter.scenes.entries`
707
+ * reports, so the table is both the menu and the address space.
708
+ *
709
+ * With a game LIVE in the session, opening a scene the adapter declares
710
+ * reachable through that game's own scenes contract NAVIGATES it — the same
711
+ * switch the editor's own scene picker makes — and the answer carries the
712
+ * game's own reading (`scene`).
713
+ *
714
+ * Rejects with a coded reason rather than prose: `SCENE_NOT_FOUND` (and it
715
+ * names the ids that DO exist), `SCENE_NOT_OPENABLE` carrying the adapter's
716
+ * own declared reason for a scene it says nothing can reach,
717
+ * `SCENE_NAVIGATION_NOT_RUNNING` for a live-only scene with no game running,
718
+ * `SCENE_CONTRACT_UNAVAILABLE` / `SCENE_NOT_IN_CONTRACT` (naming the ids the
719
+ * game itself publishes) / `SCENE_SWITCH_FAILED` when the running game's own
720
+ * navigation cannot take it, `SCENE_NOT_OPENABLE_LIVE` when this session has
721
+ * no remount for a native swap-slot scene,
722
+ * `SCENE_TABLE_UNAVAILABLE` before the adapter has loaded, and
723
+ * `SCENE_DOCUMENT_NOT_MOUNTED` when the host has no document for a piece the
724
+ * table says is openable — a host gap, not a table statement.
725
+ */
726
+ async open(id: string): Promise<OpenedDocument> {
727
+ return this.command<OpenedDocument>({ type: 'open', id });
728
+ }
729
+
633
730
  /**
634
731
  * Undo / redo one project transaction — the same queue the keyboard shortcut
635
732
  * drives. `moved` is false when there was nothing left in that direction.
@@ -728,12 +825,16 @@ export class EditorClient {
728
825
  headers: { 'Content-Type': 'application/json' },
729
826
  body: JSON.stringify({ name, location, template, ...(exampleId ? { exampleId } : {}) }),
730
827
  });
828
+ const data = (await this.readJson(res)) as {
829
+ ok?: boolean;
830
+ error?: string;
831
+ path?: string;
832
+ config?: ProjectInfo['config'];
833
+ };
731
834
  if (!res.ok) {
732
- const body = (await res.json()) as { error?: string };
733
- throw new Error(body.error ?? `Create project failed: ${res.status}`);
835
+ throw new Error(data.error ?? `Create project failed: ${res.status}`);
734
836
  }
735
- const data = (await res.json()) as { ok: boolean; path: string; config: ProjectInfo['config'] };
736
- return { path: data.path, config: data.config };
837
+ return { path: data.path as string, config: data.config as ProjectInfo['config'] };
737
838
  }
738
839
 
739
840
  async openProject(path: string): Promise<void> {
@@ -742,23 +843,23 @@ export class EditorClient {
742
843
  headers: { 'Content-Type': 'application/json' },
743
844
  body: JSON.stringify({ path }),
744
845
  });
846
+ const body = (await this.readJson(res)) as { error?: string };
745
847
  if (!res.ok) {
746
- const body = (await res.json()) as { error?: string };
747
848
  throw new Error(body.error ?? `Open project failed: ${res.status}`);
748
849
  }
749
850
  }
750
851
 
751
852
  async getProject(): Promise<ProjectInfo | null> {
752
853
  const res = await fetch(`${this.baseUrl}/__editor/project`);
753
- if (!res.ok) throw new Error(`Failed to get project: ${res.status}`);
754
- const data = (await res.json()) as { project: ProjectInfo | null };
854
+ const data = (await this.readJson(res)) as { project: ProjectInfo | null; error?: string };
855
+ if (!res.ok) throw new Error(data.error ?? `Failed to get project: ${res.status}`);
755
856
  return data.project;
756
857
  }
757
858
 
758
859
  async listRecentProjects(): Promise<RecentProject[]> {
759
860
  const res = await fetch(`${this.baseUrl}/__editor/recent-projects`);
760
- if (!res.ok) throw new Error(`Failed to list projects: ${res.status}`);
761
- const data = (await res.json()) as { projects: RecentProject[] };
861
+ const data = (await this.readJson(res)) as { projects: RecentProject[]; error?: string };
862
+ if (!res.ok) throw new Error(data.error ?? `Failed to list projects: ${res.status}`);
762
863
  return data.projects;
763
864
  }
764
865
 
@@ -769,8 +870,9 @@ export class EditorClient {
769
870
  * editor browser merely because they were listed. */
770
871
  async listProjectTools(): Promise<ProjectToolCatalog> {
771
872
  const res = await fetch(`${this.baseUrl}/__editor/project-tools`);
873
+ const body = await this.readJson(res);
772
874
  if (!res.ok) throw new Error(`Failed to list project tools: ${res.status}`);
773
- return (await res.json()) as ProjectToolCatalog;
875
+ return body as ProjectToolCatalog;
774
876
  }
775
877
 
776
878
  /** Execute one Node-hosted project tool through the shared validated
@@ -793,7 +895,7 @@ export class EditorClient {
793
895
  ...(options.instance !== undefined ? { instance: options.instance } : {}),
794
896
  }),
795
897
  });
796
- const body = (await res.json()) as ProjectToolOutcome;
898
+ const body = (await this.readJson(res)) as ProjectToolOutcome;
797
899
  if (!body || typeof body !== 'object' || typeof body.ok !== 'boolean') {
798
900
  throw new Error(`Project tool returned an invalid response (${res.status}).`);
799
901
  }
@@ -806,8 +908,9 @@ export class EditorClient {
806
908
  * request/result shapes remain on their registered operations. */
807
909
  async listGenerationJobs(): Promise<GenerationJobsDocument> {
808
910
  const res = await fetch(`${this.baseUrl}/__editor/generations`);
911
+ const body = await this.readJson(res);
809
912
  if (!res.ok) throw new Error(`Failed to list generation jobs: ${res.status}`);
810
- return (await res.json()) as GenerationJobsDocument;
913
+ return body as GenerationJobsDocument;
811
914
  }
812
915
 
813
916
  /** Forget operational job state. Accepted provenance and project assets
@@ -816,8 +919,9 @@ export class EditorClient {
816
919
  const res = await fetch(`${this.baseUrl}/__editor/generations/${encodeURIComponent(id)}`, {
817
920
  method: 'DELETE',
818
921
  });
819
- if (!res.ok) throw new Error(`Failed to forget generation job: ${res.status}`);
820
- return ((await res.json()) as { removed: boolean }).removed;
922
+ const body = (await this.readJson(res)) as { removed?: boolean; error?: string };
923
+ if (!res.ok) throw new Error(body.error ?? `Failed to forget generation job: ${res.status}`);
924
+ return body.removed === true;
821
925
  }
822
926
 
823
927
  // --- Logs ---
@@ -826,10 +930,10 @@ export class EditorClient {
826
930
  Array<{ t: number; level: string; msg: string; source?: string }>
827
931
  > {
828
932
  const res = await fetch(`${this.baseUrl}/__editor/log-entries`);
829
- if (!res.ok) return [];
830
- const data = (await res.json()) as {
933
+ const data = (await this.readJson(res)) as {
831
934
  entries: Array<{ t: number; level: string; msg: string; source?: string }>;
832
935
  };
936
+ if (!res.ok) return [];
833
937
  return data.entries;
834
938
  }
835
939
 
@@ -837,17 +941,88 @@ export class EditorClient {
837
941
 
838
942
  async getState(): Promise<EditorState> {
839
943
  const res = await fetch(`${this.baseUrl}/__editor/state`);
944
+ const state = (await this.readJson(res)) as EditorState;
840
945
  if (!res.ok) throw new Error(`Failed to get editor state: ${res.status} ${res.statusText}`);
841
- const state = (await res.json()) as EditorState;
842
- this.observe(state);
843
946
  return state;
844
947
  }
845
948
 
949
+ /**
950
+ * The complete unresolved console set the session is holding right now.
951
+ *
952
+ * Command envelopes only carry COUNTS (`unresolvedConsole` on
953
+ * `commandResponseFor`). The named conditions live on GET `/__editor/console`.
954
+ * This is the method that turns "a command that exits before an envelope
955
+ * arrives" into a real reading: the CLI calls it at start and at exit
956
+ * through the same {@link onEnvelope} observer every other response uses.
957
+ * A session that does not answer within {@link CONSOLE_DRAIN_TIMEOUT_MS} is
958
+ * a thrown error the caller treats as "nothing learned", never a hang.
959
+ */
960
+ async getUnresolvedConsole(opts?: { all?: boolean }): Promise<unknown> {
961
+ const res = await fetch(
962
+ `${this.baseUrl}/__editor/console${opts?.all === true ? '?all=1' : ''}`,
963
+ { signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS) },
964
+ );
965
+ const body = await this.readJson(res, true);
966
+ if (!res.ok) {
967
+ throw new Error(`Failed to read unresolved console: ${res.status}`);
968
+ }
969
+ return body;
970
+ }
971
+
972
+ /** Acknowledge one named console condition. The response is observed and
973
+ * hydrated through the same path as every other client response. */
974
+ async acknowledgeConsole(input: {
975
+ readonly id: string;
976
+ readonly reason: string;
977
+ readonly by: string;
978
+ }): Promise<unknown> {
979
+ const res = await fetch(`${this.baseUrl}/__editor/console/ack`, {
980
+ method: 'POST',
981
+ headers: { 'Content-Type': 'application/json' },
982
+ body: JSON.stringify(input),
983
+ signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS * 4),
984
+ });
985
+ const body = await this.readJson(res);
986
+ return body;
987
+ }
988
+
989
+ /** Parse one JSON body and hand it to {@link onEnvelope}.
990
+ *
991
+ * A command/state envelope carries current counts but not the named set. If
992
+ * an observer is installed, do the bounded console GET before resolving the
993
+ * original request. That makes a subsequent `process.exit()` safe: the
994
+ * observer has already received every condition and occurrence count. */
995
+ private async readJson(res: Response, consoleComplete = false): Promise<unknown> {
996
+ const body: unknown = await res.json();
997
+ this.observe(body, { unresolvedConsoleComplete: consoleComplete });
998
+ if (
999
+ this.onEnvelope !== null &&
1000
+ !consoleComplete &&
1001
+ body !== null &&
1002
+ typeof body === 'object' &&
1003
+ 'unresolvedConsole' in body
1004
+ ) {
1005
+ try {
1006
+ const consoleRes = await fetch(`${this.baseUrl}/__editor/console`, {
1007
+ signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS),
1008
+ });
1009
+ if (consoleRes.ok) {
1010
+ const consoleBody: unknown = await consoleRes.json();
1011
+ this.observe(consoleBody, { unresolvedConsoleComplete: true });
1012
+ }
1013
+ } catch {
1014
+ // The original response remains authoritative. A reporting follow-up
1015
+ // may degrade to its count-only envelope, never break the command.
1016
+ }
1017
+ }
1018
+ return body;
1019
+ }
1020
+
846
1021
  /** Hand one response body to {@link onEnvelope}, never letting it throw. */
847
- private observe(body: unknown): void {
1022
+ private observe(body: unknown, observation: EditorEnvelopeObservation): void {
848
1023
  if (this.onEnvelope === null) return;
849
1024
  try {
850
- this.onEnvelope(body);
1025
+ this.onEnvelope(body, observation);
851
1026
  } catch {
852
1027
  // A reporting hook may never break the command it is reporting on.
853
1028
  }
@@ -40,6 +40,13 @@ export interface ToolContributionNode {
40
40
  */
41
41
  export interface ToolObject3DPreviewSource {
42
42
  readonly root: Object3D;
43
+ /**
44
+ * Optional authored roots for the document hierarchy when `root` is a
45
+ * presentation/lifetime container rather than authored content itself.
46
+ * Framing, ticking and disposal still own `root`; this list only scopes the
47
+ * shared Hierarchy. Omitted means `root`, preserving the native tree.
48
+ */
49
+ readonly hierarchyRoots?: readonly Object3D[];
43
50
  readonly animations?: readonly AnimationClip[];
44
51
  /**
45
52
  * The source's OWN tick, and the host drives it EXACTLY ONCE per mount — for
@@ -314,6 +321,28 @@ export interface ToolContributionSurfaces {
314
321
  readonly Object3DAuthoring: import('react').ComponentType<ToolObject3DAuthoringProps>;
315
322
  }
316
323
 
324
+ /**
325
+ * The live game a contribution is looking at, or `null` when nothing is
326
+ * playing.
327
+ *
328
+ * A dev-GUI contribution's whole subject is the RUNNING game — its stats,
329
+ * cheats, tuning handles and event stream all hang off the live `Game`, and
330
+ * there is no other door to it from a contribution (the editor's own state is
331
+ * not the game's). It is `unknown` deliberately: `@vgai/engine`'s `Game` is
332
+ * the project's dependency, not this package's, so a contribution narrows it
333
+ * with its own import rather than making every consumer of this SDK carry the
334
+ * engine's types.
335
+ *
336
+ * `instanceId` is the mount id the editor's runtime instruments are pointed
337
+ * at (the Inspect selector). With several seats live that is the ONLY thing
338
+ * distinguishing two mounts of the same project, so a contribution that
339
+ * caches per-game state keys it on this and re-reads when it changes.
340
+ */
341
+ export interface ToolContributionPlay {
342
+ readonly game: unknown;
343
+ readonly instanceId: string;
344
+ }
345
+
317
346
  export interface ToolContributionProps {
318
347
  /** The exact registered callable this contribution presents. */
319
348
  readonly tool: ProjectToolCatalogEntry;
@@ -325,13 +354,42 @@ export interface ToolContributionProps {
325
354
  readonly surfaces: ToolContributionSurfaces;
326
355
  /** Sanitized product-account projection. Never contains an access token. */
327
356
  readonly account: GenerationAccountProjection;
357
+ /** The inspected play instance, or `null` while nothing is playing. */
358
+ readonly play: ToolContributionPlay | null;
328
359
  /** Present when mounted as a workspace document contribution. */
329
360
  readonly documentId?: string;
330
361
  /** Whether that workspace document is the active center subject. */
331
362
  readonly active?: boolean;
332
363
  }
333
364
 
334
- export interface ToolInspectorContributionProps extends ToolContributionProps {
365
+ /**
366
+ * A `workspace.utility`'s props — the shared shape, except that `tool` may be
367
+ * absent.
368
+ *
369
+ * It is its own type rather than a relaxation of {@link ToolContributionProps}
370
+ * because only the two PRESENTING points earn the relaxation (this one and
371
+ * {@link ToolInspectorContributionProps}): a record-reading drawer panel (a
372
+ * log, a run timeline) presents what HAPPENED rather than one callable's
373
+ * output, so there is no honest name to put there and the loader stopped
374
+ * demanding a false one. Widening the shared props instead would hand every
375
+ * RUNNING contribution a `tool` it must now null-check while its own point
376
+ * still guarantees one.
377
+ */
378
+ export interface ToolUtilityContributionProps extends Omit<ToolContributionProps, 'tool'> {
379
+ /** The callable this utility declared, when it declared one at all. */
380
+ readonly tool?: ProjectToolCatalogEntry;
381
+ }
382
+
383
+ /**
384
+ * A `selection.inspector`'s props. `tool` is OPTIONAL here for the same reason
385
+ * it is on a utility: a section may present state the editor already has —
386
+ * readings, verbs a game registered as debug commands — and drive no single
387
+ * registered callable at all. A section that DOES commit through one still
388
+ * declares it and still gets the resolved entry.
389
+ */
390
+ export interface ToolInspectorContributionProps extends Omit<ToolContributionProps, 'tool'> {
391
+ /** The callable this section declared, when it declared one at all. */
392
+ readonly tool?: ProjectToolCatalogEntry;
335
393
  readonly node: ToolContributionNode | null;
336
394
  readonly nodeId: string | null;
337
395
  }
@@ -373,9 +431,31 @@ export type ToolGenerationResultContributionMatch = (
373
431
  result: unknown,
374
432
  ) => boolean;
375
433
 
434
+ /**
435
+ * What the inspector is showing AROUND the node a `selection.inspector`
436
+ * contribution is being matched against.
437
+ *
438
+ * It exists for one question a `node`/`adapter` pair cannot answer: with
439
+ * NOTHING selected, `node` is `null` on every surface alike — an open Asset
440
+ * Lab document's empty state and the play surface's Game subject are the same
441
+ * two arguments. A contribution that matches `node === null` therefore matched
442
+ * BOTH, and the empty-state subject of whatever document happened to be open
443
+ * grew sections belonging to another surface entirely.
444
+ *
445
+ * `nullSubjectId` is the id of the empty-state subject actually being composed
446
+ * (`inspection/null-subject.ts`), so a contribution scopes itself POSITIVELY —
447
+ * `ctx.nullSubjectId === 'game'` — rather than by guessing from the adapter.
448
+ * It is `null` whenever a node IS selected, which is the honest answer: there
449
+ * is no empty-state subject in that composition.
450
+ */
451
+ export interface ToolInspectorContributionMatchContext {
452
+ readonly nullSubjectId: string | null;
453
+ }
454
+
376
455
  /** Optional named export required by `selection.inspector` contributions. */
377
456
  export type ToolInspectorContributionMatch = (
378
457
  node: ToolContributionNode | null,
379
458
  /** Adapter-native API. Import its concrete type when a contribution needs it. */
380
459
  adapter: unknown,
460
+ context: ToolInspectorContributionMatchContext,
381
461
  ) => boolean;
@@ -11,7 +11,6 @@ const ASSET_KINDS = new Set<AssetKind>([
11
11
  'animation',
12
12
  'json',
13
13
  'prefab',
14
- 'material',
15
14
  'source',
16
15
  ]);
17
16
  const CAMERAS = new Set<ViewPreset | 'isometric'>([
package/src/index.ts CHANGED
@@ -4,7 +4,12 @@ export type {
4
4
  GenerationJobStatus,
5
5
  GenerationJobsDocument,
6
6
  } from '@vgai/sdk/generations';
7
- export type { GameDebugDoor, HistoryStep } from './client.js';
7
+ export type {
8
+ EditorEnvelopeObservation,
9
+ GameDebugDoor,
10
+ HistoryStep,
11
+ OpenedDocument,
12
+ } from './client.js';
8
13
  export { EditorClient, EditorCommandError } from './client.js';
9
14
  export type {
10
15
  ToolAssetInspectorContributionMatch,
@@ -73,6 +78,7 @@ export type {
73
78
  ProjectToolCatalogEntry,
74
79
  ProjectToolContribution,
75
80
  ProjectToolOutcome,
81
+ RagdollGenerationResult,
76
82
  RecentProject,
77
83
  ShadingMode,
78
84
  ShotSetPoseRotation,
package/src/types.ts CHANGED
@@ -9,15 +9,7 @@
9
9
  */
10
10
  export type ViewportTab = 'edit' | 'play';
11
11
 
12
- export type AssetKind =
13
- | 'model'
14
- | 'image'
15
- | 'audio'
16
- | 'animation'
17
- | 'json'
18
- | 'prefab'
19
- | 'material'
20
- | 'source';
12
+ export type AssetKind = 'model' | 'image' | 'audio' | 'animation' | 'json' | 'prefab' | 'source';
21
13
 
22
14
  export interface HelperVisibility {
23
15
  bounds: boolean;
@@ -58,6 +50,16 @@ export interface ViewportCapture {
58
50
  mimeType: 'image/png';
59
51
  }
60
52
 
53
+ /** Result of the editor's native Asset Lab ragdoll-generation operation. */
54
+ export interface RagdollGenerationResult {
55
+ capability: 'added' | 'present';
56
+ componentPath: string;
57
+ storyPath: string;
58
+ documentId: string;
59
+ bodies: number;
60
+ joints: number;
61
+ }
62
+
61
63
  /**
62
64
  * Unit 4 (live-front-door wave) — the RUNNING GAME's pixels, as captured by
63
65
  * the `bridge-screenshot` relay op (`command-listener.ts`'s
@@ -102,9 +104,6 @@ export interface GameCapture {
102
104
  export interface GameplayRecordingOptions {
103
105
  /** Requested real-time capture cadence. Defaults to 30; range 1–60. */
104
106
  fps?: number;
105
- /** Include `dev: true` game layers. Off by default so ordinary evidence is
106
- * the player-visible game, not its debugging overlay. */
107
- devLayers?: boolean;
108
107
  }
109
108
 
110
109
  /** Facts fixed when a gameplay recording starts. */
@@ -383,6 +382,8 @@ export interface EditorState {
383
382
  activeViewportTab: ViewportTab;
384
383
  /** The actual active center document, including tool/source documents. */
385
384
  activeDocumentId?: string | null;
385
+ /** Every center document the editor currently has open, by stable registry id. */
386
+ openDocumentIds?: string[];
386
387
  activeTabKey: string;
387
388
  showGrid: boolean;
388
389
  showHelpers: boolean;
@@ -599,9 +600,13 @@ export interface EditorState {
599
600
  projectName?: string | null;
600
601
  /**
601
602
  * The open project's ADAPTER, resolved (ARCHITECTURE-CORE §The editor
602
- * protocol). `source: 'module'` means the project's own `vgai.adapter.ts`
603
- * supplied the binding table; `'native'` means it declared none and got
604
- * `nativeAdapter()` the declared native default, not a silent fallback.
603
+ * protocol). `source` names WHOSE declaration is running: `'project'` = the
604
+ * project's own `vgai.adapter.ts` supplied the binding table (and it always
605
+ * outranks the registry); `'registry'` = the HOST's in-tree ingest registry
606
+ * supplied it, matched on this project's ingest root id, with `modulePath`
607
+ * naming the repo file — a binding the project did not ship, stated rather
608
+ * than inferred; `'native'` = it declared none and got `nativeAdapter()` —
609
+ * the declared native default, not a silent fallback.
605
610
  *
606
611
  * `null`/absent means NOBODY HAS LOOKED YET (no project open, or the load
607
612
  * has not finished), which is deliberately distinct from a loaded adapter
@@ -614,13 +619,11 @@ export interface EditorState {
614
619
  * contract: everything in it has already been through JSON.
615
620
  */
616
621
  adapter?: {
617
- source: 'module' | 'native';
622
+ source: 'project' | 'registry' | 'native';
618
623
  modulePath: string | null;
619
624
  regions: {
620
625
  id: string;
621
626
  surface: string;
622
- /** true = this region grades the game's own dev layer, not shipped content. */
623
- dev: boolean;
624
627
  projector: string;
625
628
  dialect: string | null;
626
629
  anchors: string[];
@@ -655,11 +658,11 @@ export type TransformSpace = 'world' | 'local';
655
658
  * view. One runtime list owns both URL parsing and the public id type. */
656
659
  export const EDITOR_VIEW_WORKSPACE_DOCUMENT_IDS = [
657
660
  'workspace:scene',
661
+ 'workspace:canvas-scene',
658
662
  'workspace:game',
659
663
  'workspace:3d-components',
660
664
  'workspace:2d-components',
661
665
  'workspace:ui-components',
662
- 'workspace:dev',
663
666
  'workspace:build-profiles',
664
667
  'account',
665
668
  'project-tools',
@@ -790,14 +793,17 @@ export interface InspectedField {
790
793
  /** The value shown is the declared default — the document does not carry it. */
791
794
  defaulted?: boolean;
792
795
  readonly?: boolean;
796
+ /** The same reason shown by the Inspector and returned by a refused write. */
797
+ readonlyReason?: string;
793
798
  resettable?: boolean;
794
799
  revertsTo?: string;
795
800
  group?: string;
796
801
  options?: readonly unknown[];
797
802
  }
798
803
 
799
- /** A section's content. An opaque body is a NAMED OPAQUE — the editor renders
800
- * it with a React component the wire deliberately does not describe. The two
804
+ /** A section's content. Custom RENDERING remains opaque — the wire never
805
+ * introspects React while any ordinary descriptor channel that chrome owns
806
+ * remains visible as `fields`, including its write-refusal reasons. The two
801
807
  * opaque kinds are distinguished because "this subject has a live preview"
802
808
  * is a real fact about it: `custom` is a contributed block, `preview` is the
803
809
  * subject's own square view of itself.
@@ -809,7 +815,13 @@ export interface InspectedField {
809
815
  * quaternion behind them is not on this wire). */
810
816
  export type InspectedSectionBody =
811
817
  | { kind: 'fields'; fields: readonly InspectedField[] }
812
- | { kind: 'custom'; id: string; title: string; data?: Record<string, unknown> }
818
+ | {
819
+ kind: 'custom';
820
+ id: string;
821
+ title: string;
822
+ data?: Record<string, unknown>;
823
+ fields?: readonly InspectedField[];
824
+ }
813
825
  | { kind: 'preview'; id: string; title: string };
814
826
 
815
827
  export interface InspectedSection {