@vgai/editor-sdk 0.5.14 → 0.5.16

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.14",
5
+ "version": "0.5.16",
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.14"
26
+ "@vgai/sdk": "0.5.16"
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}`,
@@ -543,6 +586,11 @@ export class EditorClient {
543
586
  await this.command({ type: 'open-asset-tab', path, kind });
544
587
  }
545
588
 
589
+ /** Generate a project-owned native Rapier ragdoll from a rigged model asset. */
590
+ async generateRagdoll(assetPath: string): Promise<RagdollGenerationResult> {
591
+ return this.command<RagdollGenerationResult>({ type: 'generate-ragdoll', assetPath });
592
+ }
593
+
546
594
  async closeAsset(key: string): Promise<void> {
547
595
  await this.command({ type: 'close-asset-tab', key });
548
596
  }
@@ -586,6 +634,15 @@ export class EditorClient {
586
634
  return data.subject;
587
635
  }
588
636
 
637
+ /** Run one verb exposed by the active Inspector subject, by its id. */
638
+ async runInspectionAction(actionId: string): Promise<InspectedInspection> {
639
+ const data = await this.command<{ subject: InspectedInspection }>({
640
+ type: 'run-inspection-action',
641
+ actionId,
642
+ });
643
+ return data.subject;
644
+ }
645
+
589
646
  /**
590
647
  * The HIERARCHY PANEL's actual rendered row tree, as data.
591
648
  *
@@ -630,6 +687,57 @@ export class EditorClient {
630
687
  return { subject: data.subject, write: data.write };
631
688
  }
632
689
 
690
+ /**
691
+ * REMOVE one editable path's authored override — the other half of the write
692
+ * door, and the only one that can express byte-ABSENCE.
693
+ *
694
+ * {@link setInspectionField} writes a VALUE, so reverting a property an
695
+ * authoring gesture ADDED puts the default back EXPLICITLY and leaves the
696
+ * source one attribute heavier than it started. This drops the property, so
697
+ * whatever governs it in its absence takes over — the same `io.remove` the
698
+ * Inspector's revert arrow calls, the same persistence pipe, the same awaited
699
+ * `{ destination, persisted }` ack.
700
+ *
701
+ * Rejects with `code: 'REMOVAL_UNAVAILABLE'` when the field does not declare
702
+ * itself removable or the lane implements no removal door. That refusal is a
703
+ * MISSING SEAM, not a failed removal, and it is coded rather than phrased
704
+ * precisely so a caller can grade the two differently.
705
+ */
706
+ async removeInspectionField(path: string): Promise<InspectedFieldWrite> {
707
+ const data = await this.command<InspectedFieldWrite>({
708
+ type: 'remove-inspection-field',
709
+ path,
710
+ });
711
+ return { subject: data.subject, write: data.write };
712
+ }
713
+
714
+ /**
715
+ * OPEN one piece of the adapter's SCENE TABLE by id — a scene, a prefab, or
716
+ * a story state, because the table makes them siblings (they differ only in
717
+ * instance site). The ids are exactly what `getState().adapter.scenes.entries`
718
+ * reports, so the table is both the menu and the address space.
719
+ *
720
+ * With a game LIVE in the session, opening a scene the adapter declares
721
+ * reachable through that game's own scenes contract NAVIGATES it — the same
722
+ * switch the editor's own scene picker makes — and the answer carries the
723
+ * game's own reading (`scene`).
724
+ *
725
+ * Rejects with a coded reason rather than prose: `SCENE_NOT_FOUND` (and it
726
+ * names the ids that DO exist), `SCENE_NOT_OPENABLE` carrying the adapter's
727
+ * own declared reason for a scene it says nothing can reach,
728
+ * `SCENE_NAVIGATION_NOT_RUNNING` for a live-only scene with no game running,
729
+ * `SCENE_CONTRACT_UNAVAILABLE` / `SCENE_NOT_IN_CONTRACT` (naming the ids the
730
+ * game itself publishes) / `SCENE_SWITCH_FAILED` when the running game's own
731
+ * navigation cannot take it, `SCENE_NOT_OPENABLE_LIVE` when this session has
732
+ * no remount for a native swap-slot scene,
733
+ * `SCENE_TABLE_UNAVAILABLE` before the adapter has loaded, and
734
+ * `SCENE_DOCUMENT_NOT_MOUNTED` when the host has no document for a piece the
735
+ * table says is openable — a host gap, not a table statement.
736
+ */
737
+ async open(id: string): Promise<OpenedDocument> {
738
+ return this.command<OpenedDocument>({ type: 'open', id });
739
+ }
740
+
633
741
  /**
634
742
  * Undo / redo one project transaction — the same queue the keyboard shortcut
635
743
  * drives. `moved` is false when there was nothing left in that direction.
@@ -728,12 +836,16 @@ export class EditorClient {
728
836
  headers: { 'Content-Type': 'application/json' },
729
837
  body: JSON.stringify({ name, location, template, ...(exampleId ? { exampleId } : {}) }),
730
838
  });
839
+ const data = (await this.readJson(res)) as {
840
+ ok?: boolean;
841
+ error?: string;
842
+ path?: string;
843
+ config?: ProjectInfo['config'];
844
+ };
731
845
  if (!res.ok) {
732
- const body = (await res.json()) as { error?: string };
733
- throw new Error(body.error ?? `Create project failed: ${res.status}`);
846
+ throw new Error(data.error ?? `Create project failed: ${res.status}`);
734
847
  }
735
- const data = (await res.json()) as { ok: boolean; path: string; config: ProjectInfo['config'] };
736
- return { path: data.path, config: data.config };
848
+ return { path: data.path as string, config: data.config as ProjectInfo['config'] };
737
849
  }
738
850
 
739
851
  async openProject(path: string): Promise<void> {
@@ -742,23 +854,23 @@ export class EditorClient {
742
854
  headers: { 'Content-Type': 'application/json' },
743
855
  body: JSON.stringify({ path }),
744
856
  });
857
+ const body = (await this.readJson(res)) as { error?: string };
745
858
  if (!res.ok) {
746
- const body = (await res.json()) as { error?: string };
747
859
  throw new Error(body.error ?? `Open project failed: ${res.status}`);
748
860
  }
749
861
  }
750
862
 
751
863
  async getProject(): Promise<ProjectInfo | null> {
752
864
  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 };
865
+ const data = (await this.readJson(res)) as { project: ProjectInfo | null; error?: string };
866
+ if (!res.ok) throw new Error(data.error ?? `Failed to get project: ${res.status}`);
755
867
  return data.project;
756
868
  }
757
869
 
758
870
  async listRecentProjects(): Promise<RecentProject[]> {
759
871
  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[] };
872
+ const data = (await this.readJson(res)) as { projects: RecentProject[]; error?: string };
873
+ if (!res.ok) throw new Error(data.error ?? `Failed to list projects: ${res.status}`);
762
874
  return data.projects;
763
875
  }
764
876
 
@@ -769,8 +881,9 @@ export class EditorClient {
769
881
  * editor browser merely because they were listed. */
770
882
  async listProjectTools(): Promise<ProjectToolCatalog> {
771
883
  const res = await fetch(`${this.baseUrl}/__editor/project-tools`);
884
+ const body = await this.readJson(res);
772
885
  if (!res.ok) throw new Error(`Failed to list project tools: ${res.status}`);
773
- return (await res.json()) as ProjectToolCatalog;
886
+ return body as ProjectToolCatalog;
774
887
  }
775
888
 
776
889
  /** Execute one Node-hosted project tool through the shared validated
@@ -793,7 +906,7 @@ export class EditorClient {
793
906
  ...(options.instance !== undefined ? { instance: options.instance } : {}),
794
907
  }),
795
908
  });
796
- const body = (await res.json()) as ProjectToolOutcome;
909
+ const body = (await this.readJson(res)) as ProjectToolOutcome;
797
910
  if (!body || typeof body !== 'object' || typeof body.ok !== 'boolean') {
798
911
  throw new Error(`Project tool returned an invalid response (${res.status}).`);
799
912
  }
@@ -806,8 +919,9 @@ export class EditorClient {
806
919
  * request/result shapes remain on their registered operations. */
807
920
  async listGenerationJobs(): Promise<GenerationJobsDocument> {
808
921
  const res = await fetch(`${this.baseUrl}/__editor/generations`);
922
+ const body = await this.readJson(res);
809
923
  if (!res.ok) throw new Error(`Failed to list generation jobs: ${res.status}`);
810
- return (await res.json()) as GenerationJobsDocument;
924
+ return body as GenerationJobsDocument;
811
925
  }
812
926
 
813
927
  /** Forget operational job state. Accepted provenance and project assets
@@ -816,8 +930,9 @@ export class EditorClient {
816
930
  const res = await fetch(`${this.baseUrl}/__editor/generations/${encodeURIComponent(id)}`, {
817
931
  method: 'DELETE',
818
932
  });
819
- if (!res.ok) throw new Error(`Failed to forget generation job: ${res.status}`);
820
- return ((await res.json()) as { removed: boolean }).removed;
933
+ const body = (await this.readJson(res)) as { removed?: boolean; error?: string };
934
+ if (!res.ok) throw new Error(body.error ?? `Failed to forget generation job: ${res.status}`);
935
+ return body.removed === true;
821
936
  }
822
937
 
823
938
  // --- Logs ---
@@ -826,10 +941,10 @@ export class EditorClient {
826
941
  Array<{ t: number; level: string; msg: string; source?: string }>
827
942
  > {
828
943
  const res = await fetch(`${this.baseUrl}/__editor/log-entries`);
829
- if (!res.ok) return [];
830
- const data = (await res.json()) as {
944
+ const data = (await this.readJson(res)) as {
831
945
  entries: Array<{ t: number; level: string; msg: string; source?: string }>;
832
946
  };
947
+ if (!res.ok) return [];
833
948
  return data.entries;
834
949
  }
835
950
 
@@ -837,17 +952,88 @@ export class EditorClient {
837
952
 
838
953
  async getState(): Promise<EditorState> {
839
954
  const res = await fetch(`${this.baseUrl}/__editor/state`);
955
+ const state = (await this.readJson(res)) as EditorState;
840
956
  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
957
  return state;
844
958
  }
845
959
 
960
+ /**
961
+ * The complete unresolved console set the session is holding right now.
962
+ *
963
+ * Command envelopes only carry COUNTS (`unresolvedConsole` on
964
+ * `commandResponseFor`). The named conditions live on GET `/__editor/console`.
965
+ * This is the method that turns "a command that exits before an envelope
966
+ * arrives" into a real reading: the CLI calls it at start and at exit
967
+ * through the same {@link onEnvelope} observer every other response uses.
968
+ * A session that does not answer within {@link CONSOLE_DRAIN_TIMEOUT_MS} is
969
+ * a thrown error the caller treats as "nothing learned", never a hang.
970
+ */
971
+ async getUnresolvedConsole(opts?: { all?: boolean }): Promise<unknown> {
972
+ const res = await fetch(
973
+ `${this.baseUrl}/__editor/console${opts?.all === true ? '?all=1' : ''}`,
974
+ { signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS) },
975
+ );
976
+ const body = await this.readJson(res, true);
977
+ if (!res.ok) {
978
+ throw new Error(`Failed to read unresolved console: ${res.status}`);
979
+ }
980
+ return body;
981
+ }
982
+
983
+ /** Acknowledge one named console condition. The response is observed and
984
+ * hydrated through the same path as every other client response. */
985
+ async acknowledgeConsole(input: {
986
+ readonly id: string;
987
+ readonly reason: string;
988
+ readonly by: string;
989
+ }): Promise<unknown> {
990
+ const res = await fetch(`${this.baseUrl}/__editor/console/ack`, {
991
+ method: 'POST',
992
+ headers: { 'Content-Type': 'application/json' },
993
+ body: JSON.stringify(input),
994
+ signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS * 4),
995
+ });
996
+ const body = await this.readJson(res);
997
+ return body;
998
+ }
999
+
1000
+ /** Parse one JSON body and hand it to {@link onEnvelope}.
1001
+ *
1002
+ * A command/state envelope carries current counts but not the named set. If
1003
+ * an observer is installed, do the bounded console GET before resolving the
1004
+ * original request. That makes a subsequent `process.exit()` safe: the
1005
+ * observer has already received every condition and occurrence count. */
1006
+ private async readJson(res: Response, consoleComplete = false): Promise<unknown> {
1007
+ const body: unknown = await res.json();
1008
+ this.observe(body, { unresolvedConsoleComplete: consoleComplete });
1009
+ if (
1010
+ this.onEnvelope !== null &&
1011
+ !consoleComplete &&
1012
+ body !== null &&
1013
+ typeof body === 'object' &&
1014
+ 'unresolvedConsole' in body
1015
+ ) {
1016
+ try {
1017
+ const consoleRes = await fetch(`${this.baseUrl}/__editor/console`, {
1018
+ signal: AbortSignal.timeout(CONSOLE_DRAIN_TIMEOUT_MS),
1019
+ });
1020
+ if (consoleRes.ok) {
1021
+ const consoleBody: unknown = await consoleRes.json();
1022
+ this.observe(consoleBody, { unresolvedConsoleComplete: true });
1023
+ }
1024
+ } catch {
1025
+ // The original response remains authoritative. A reporting follow-up
1026
+ // may degrade to its count-only envelope, never break the command.
1027
+ }
1028
+ }
1029
+ return body;
1030
+ }
1031
+
846
1032
  /** Hand one response body to {@link onEnvelope}, never letting it throw. */
847
- private observe(body: unknown): void {
1033
+ private observe(body: unknown, observation: EditorEnvelopeObservation): void {
848
1034
  if (this.onEnvelope === null) return;
849
1035
  try {
850
- this.onEnvelope(body);
1036
+ this.onEnvelope(body, observation);
851
1037
  } catch {
852
1038
  // A reporting hook may never break the command it is reporting on.
853
1039
  }
@@ -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
@@ -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
@@ -599,9 +601,13 @@ export interface EditorState {
599
601
  projectName?: string | null;
600
602
  /**
601
603
  * 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.
604
+ * protocol). `source` names WHOSE declaration is running: `'project'` = the
605
+ * project's own `vgai.adapter.ts` supplied the binding table (and it always
606
+ * outranks the registry); `'registry'` = the HOST's in-tree ingest registry
607
+ * supplied it, matched on this project's ingest root id, with `modulePath`
608
+ * naming the repo file — a binding the project did not ship, stated rather
609
+ * than inferred; `'native'` = it declared none and got `nativeAdapter()` —
610
+ * the declared native default, not a silent fallback.
605
611
  *
606
612
  * `null`/absent means NOBODY HAS LOOKED YET (no project open, or the load
607
613
  * has not finished), which is deliberately distinct from a loaded adapter
@@ -614,7 +620,7 @@ export interface EditorState {
614
620
  * contract: everything in it has already been through JSON.
615
621
  */
616
622
  adapter?: {
617
- source: 'module' | 'native';
623
+ source: 'project' | 'registry' | 'native';
618
624
  modulePath: string | null;
619
625
  regions: {
620
626
  id: string;
@@ -790,14 +796,17 @@ export interface InspectedField {
790
796
  /** The value shown is the declared default — the document does not carry it. */
791
797
  defaulted?: boolean;
792
798
  readonly?: boolean;
799
+ /** The same reason shown by the Inspector and returned by a refused write. */
800
+ readonlyReason?: string;
793
801
  resettable?: boolean;
794
802
  revertsTo?: string;
795
803
  group?: string;
796
804
  options?: readonly unknown[];
797
805
  }
798
806
 
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
807
+ /** A section's content. Custom RENDERING remains opaque — the wire never
808
+ * introspects React while any ordinary descriptor channel that chrome owns
809
+ * remains visible as `fields`, including its write-refusal reasons. The two
801
810
  * opaque kinds are distinguished because "this subject has a live preview"
802
811
  * is a real fact about it: `custom` is a contributed block, `preview` is the
803
812
  * subject's own square view of itself.
@@ -809,7 +818,13 @@ export interface InspectedField {
809
818
  * quaternion behind them is not on this wire). */
810
819
  export type InspectedSectionBody =
811
820
  | { kind: 'fields'; fields: readonly InspectedField[] }
812
- | { kind: 'custom'; id: string; title: string; data?: Record<string, unknown> }
821
+ | {
822
+ kind: 'custom';
823
+ id: string;
824
+ title: string;
825
+ data?: Record<string, unknown>;
826
+ fields?: readonly InspectedField[];
827
+ }
813
828
  | { kind: 'preview'; id: string; title: string };
814
829
 
815
830
  export interface InspectedSection {