@volter/editor-sdk 0.5.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/README.md +19 -0
  4. package/package.json +90 -0
  5. package/src/account.ts +210 -0
  6. package/src/chrome.ts +83 -0
  7. package/src/client.ts +1547 -0
  8. package/src/commands.ts +66 -0
  9. package/src/contributions.ts +985 -0
  10. package/src/document-probe.ts +237 -0
  11. package/src/editor-view.ts +220 -0
  12. package/src/extension.ts +40 -0
  13. package/src/generations.ts +178 -0
  14. package/src/host.ts +1167 -0
  15. package/src/http-transport.browser.ts +14 -0
  16. package/src/http-transport.node.ts +19 -0
  17. package/src/index.ts +128 -0
  18. package/src/layout-arrangements.ts +5 -0
  19. package/src/layouts.tsx +108 -0
  20. package/src/looks.ts +14 -0
  21. package/src/project/output-roots.ts +73 -0
  22. package/src/project/tab-census.ts +149 -0
  23. package/src/project-tool-catalog.ts +96 -0
  24. package/src/selection.tsx +108 -0
  25. package/src/services.ts +18 -0
  26. package/src/session/build-report.ts +19 -0
  27. package/src/session/collaboration-types.ts +262 -0
  28. package/src/session/command-table.ts +333 -0
  29. package/src/session/discovery.ts +90 -0
  30. package/src/session/editor-brand.ts +73 -0
  31. package/src/session/editor-compatibility.ts +248 -0
  32. package/src/session/editor-control-lifecycle.ts +68 -0
  33. package/src/session/editor-control-protocol.ts +5 -0
  34. package/src/session/entrypoint-selection-readers.ts +66 -0
  35. package/src/session/entrypoint-selection-source.ts +120 -0
  36. package/src/session/game-css-scope.ts +30 -0
  37. package/src/session/product-create.ts +24 -0
  38. package/src/session/product-locator.ts +389 -0
  39. package/src/session/project-module-url.ts +245 -0
  40. package/src/session/registry-format.ts +203 -0
  41. package/src/session/relative-path-guard.ts +56 -0
  42. package/src/session/source-glob.ts +15 -0
  43. package/src/session/tool-contribution-convention.ts +116 -0
  44. package/src/session/workbench-locator.ts +650 -0
  45. package/src/session.ts +41 -0
  46. package/src/share.ts +160 -0
  47. package/src/tools/errors.ts +91 -0
  48. package/src/tools/provider-execution.ts +70 -0
  49. package/src/tools/registry.ts +341 -0
  50. package/src/tools/types.ts +159 -0
  51. package/src/transport.ts +97 -0
  52. package/src/types.ts +1581 -0
  53. package/src/views.ts +164 -0
  54. package/src/widgets/design-system.ts +93 -0
  55. package/src/widgets/editor-appearance.ts +149 -0
  56. package/src/widgets/editor-material.ts +83 -0
  57. package/src/widgets/icon-set-registry.ts +105 -0
  58. package/src/widgets/index.ts +71 -0
  59. package/src/widgets/inspector-widgets/AlignmentGrid.tsx +182 -0
  60. package/src/widgets/inspector-widgets/AssetSlotPicker.tsx +123 -0
  61. package/src/widgets/inspector-widgets/BorderEditor.tsx +309 -0
  62. package/src/widgets/inspector-widgets/ColorPicker.tsx +549 -0
  63. package/src/widgets/inspector-widgets/CurveEditor.tsx +359 -0
  64. package/src/widgets/inspector-widgets/FilterEditor.tsx +108 -0
  65. package/src/widgets/inspector-widgets/FontPicker.tsx +191 -0
  66. package/src/widgets/inspector-widgets/GradientEditor.tsx +623 -0
  67. package/src/widgets/inspector-widgets/ScrubbableInput.tsx +180 -0
  68. package/src/widgets/inspector-widgets/ShadowEditor.tsx +319 -0
  69. package/src/widgets/inspector-widgets/color-utils.ts +201 -0
  70. package/src/widgets/inspector-widgets/curve-utils.ts +212 -0
  71. package/src/widgets/inspector-widgets/index.ts +24 -0
  72. package/src/widgets/inspector-widgets/shared.tsx +140 -0
  73. package/src/widgets/interactive-edit-scope.ts +33 -0
  74. package/src/widgets/patterns/Dialog.tsx +129 -0
  75. package/src/widgets/patterns/Fields.tsx +44 -0
  76. package/src/widgets/patterns/List.tsx +25 -0
  77. package/src/widgets/patterns/StateSurface.tsx +40 -0
  78. package/src/widgets/patterns/Surfaces.tsx +122 -0
  79. package/src/widgets/patterns/Tabs.tsx +80 -0
  80. package/src/widgets/patterns/Toolbar.tsx +72 -0
  81. package/src/widgets/patterns/Tree.tsx +72 -0
  82. package/src/widgets/primitives/AnchoredMenu.tsx +260 -0
  83. package/src/widgets/primitives/Button.tsx +62 -0
  84. package/src/widgets/primitives/ColorInput.tsx +78 -0
  85. package/src/widgets/primitives/DraftTextInput.tsx +63 -0
  86. package/src/widgets/primitives/EditorIcon.tsx +157 -0
  87. package/src/widgets/primitives/FormControls.tsx +88 -0
  88. package/src/widgets/primitives/HoverPreview.tsx +96 -0
  89. package/src/widgets/primitives/JsonInput.tsx +113 -0
  90. package/src/widgets/primitives/Layout.tsx +100 -0
  91. package/src/widgets/primitives/Menu.tsx +140 -0
  92. package/src/widgets/primitives/NumberInput.tsx +169 -0
  93. package/src/widgets/primitives/Panel.tsx +80 -0
  94. package/src/widgets/primitives/SectionHeader.tsx +77 -0
  95. package/src/widgets/primitives/Text.tsx +54 -0
  96. package/src/widgets/primitives/ThemeRootPortal.tsx +52 -0
  97. package/src/widgets/primitives/Tooltip.tsx +204 -0
  98. package/src/widgets/primitives/Vec3Input.tsx +70 -0
  99. package/src/widgets/primitives/banner-tones.ts +32 -0
  100. package/src/widgets/primitives/clamp-to-viewport.ts +44 -0
  101. package/src/widgets/primitives/editor-icons.ts +245 -0
  102. package/src/widgets/primitives/panel-header-styles.ts +42 -0
  103. package/src/widgets/theme.ts +2633 -0
  104. package/src/widgets/z-index.ts +25 -0
package/src/host.ts ADDED
@@ -0,0 +1,1167 @@
1
+ /**
2
+ * THE HOST DOOR — what a PACKAGE's contribution may read of the running
3
+ * editor (ARCHITECTURE-CORE §The workbench, "direction": a package imports
4
+ * only other packages' exports, never the host's internals).
5
+ *
6
+ * The editor registers this at boot as MODULE STATE, the same door shape as
7
+ * `registerProjectModuleLoader` in `contributions.ts`: the SDK is one
8
+ * identity in the editor's program (Vite dedupes it), so a contribution
9
+ * served from a package sees the editor's registration. The surface is
10
+ * deliberately small and grows one member per contribution that needs it;
11
+ * a member nobody reads is cut.
12
+ *
13
+ * Reading it outside a host — in a test, or a package's own tooling — throws
14
+ * by name rather than answering with an empty session: an inspector that
15
+ * silently reads "no adapter" is a measurement nobody made.
16
+ */
17
+
18
+ import type {
19
+ AudioAdapter,
20
+ MountedRoot,
21
+ NetworkingAdapter,
22
+ StoriesProvider,
23
+ SystemAdapters,
24
+ } from '@volter/editor-project/adapter';
25
+ import type { ComponentType } from 'react';
26
+ import { useSyncExternalStore } from 'react';
27
+ import type * as THREE from 'three';
28
+ import type { StageTransportHandle } from './transport';
29
+
30
+ /**
31
+ * THE STORY RUNTIME'S DOORWAY — the URL a package dynamic-imports to reach
32
+ * Storybook's `composeStories`/`setProjectAnnotations` and React DOM's
33
+ * `createRoot`/`flushSync` FROM THE PROJECT'S OWN MODULE GRAPH, rather than
34
+ * from the shell's copy.
35
+ *
36
+ * It is here because it is the host's statement about what it serves, and a
37
+ * package may not reach into the host's build tier to read it: the address
38
+ * lived in `packages/editor/vite-plugin-module-doorways.ts`, which serves it,
39
+ * and the story runtime imported it back out through a specifier that stepped
40
+ * out of the editor's `src/` entirely. The plugin still OWNS the
41
+ * doorway — what it serves, and why the mount would otherwise get a second
42
+ * React (its doc comment carries the measured failure) — and now spells the
43
+ * address by importing this constant, so there is one spelling and the package
44
+ * reads it through the published door like any other host fact.
45
+ */
46
+ export const STORY_RUNTIME_PATH = '/__vgai-story-runtime';
47
+ /** Project-owned React/Three namespace shared by preview consumers and the server. */
48
+ export const R3F_RUNTIME_PATH = '/__vgai-r3f-runtime';
49
+
50
+ /** The live session's system adapters, as the editor inspects them: the
51
+ * instance under inspection when several run, the solo one otherwise. */
52
+ export interface EditorHostSystems {
53
+ /** Every adapter the inspected session registered, as one object. */
54
+ inspected(): SystemAdapters;
55
+ /** Fires when the inspected instance or its adapters change. */
56
+ subscribe(listener: () => void): () => void;
57
+ inspectedNetworking(): NetworkingAdapter | null;
58
+ /** Fires when the inspected networking adapter appears, changes or leaves. */
59
+ subscribeNetworking(listener: () => void): () => void;
60
+ inspectedAudio(): AudioAdapter | null;
61
+ /** Fires when the inspected audio adapter appears, changes or leaves. */
62
+ subscribeAudio(listener: () => void): () => void;
63
+ /** Monotonic counter behind {@link subscribeAudio}, for `useSyncExternalStore`. */
64
+ audioVersion(): number;
65
+ }
66
+
67
+ /** The editor's ONE shared availability heartbeat (250ms, refcounted): the
68
+ * signal for out-of-band changes a live session makes without notifying a
69
+ * store — an adapter's connection state, a reader appearing. */
70
+ export interface EditorHostAvailability {
71
+ subscribe(listener: () => void): () => void;
72
+ version(): number;
73
+ }
74
+
75
+ export interface EditorHostWorkspace {
76
+ /** Reveal a bottom-drawer utility by its registered id (`tool:<id>` for a
77
+ * contributed one). */
78
+ showUtility(id: string): void;
79
+ /** Open a contributed `workspace.document` by its contribution id
80
+ * (`my-tool.document`); false when no such document is registered. */
81
+ openContributedDocument(id: string): boolean;
82
+ /**
83
+ * Open a document by ADDRESS — `{ kind, …the kind's own fields }` — through
84
+ * the host's document-open registry. The SDK names NO document kind: the
85
+ * caller spells the address its own package registered (or that another
86
+ * package in the build did), the host routes it, and a kind with no
87
+ * registered opener answers `false` instead of pretending.
88
+ *
89
+ * It is the async form on purpose. A kind may need to SETTLE — re-read the
90
+ * ledger it opens documents out of — before it can answer, which is exactly
91
+ * what a package that just WROTE the subject needs (a generated ragdoll's
92
+ * Setup story, addressed `{ kind: 'story', modulePath, storyName }` the
93
+ * moment the file exists).
94
+ */
95
+ open(address: { readonly kind: string } & Record<string, unknown>): Promise<boolean>;
96
+ readonly liveDocument: EditorHostLiveDocument;
97
+ }
98
+
99
+ export interface LiveDocumentContentProps {
100
+ readonly documentId: string;
101
+ /** Whether the document is the active center tab. */
102
+ readonly active: boolean;
103
+ }
104
+
105
+ /** What a lane renders inside the live document: the panel a runtime mounts
106
+ * into, and its document-local toolbar. */
107
+ export interface LiveDocumentContent {
108
+ readonly Content: ComponentType<LiveDocumentContentProps>;
109
+ readonly Toolbar?: ComponentType<LiveDocumentContentProps>;
110
+ }
111
+
112
+ /**
113
+ * THE LIVE DOCUMENT — the host's one center document for running content
114
+ * (id `workspace:game`, title `Game`). It exists exactly as long as a
115
+ * runtime does: a lane `acquire`s it before mounting (open + activate, then
116
+ * wait for the panel's container to commit) and the host closes it on the
117
+ * playing → stopped edge, handing focus back. The panel's element is the
118
+ * live CONTAINER every lane mounts into; the content that draws the panel
119
+ * is registered by the package that runs things.
120
+ */
121
+ export interface EditorHostLiveDocument {
122
+ readonly id: string;
123
+ register(content: LiveDocumentContent): () => void;
124
+ /** Resolves false when the panel did not commit in time (report it as
125
+ * the lane's own start failure — never mount into nothing). */
126
+ acquire(timeoutMs?: number): Promise<boolean>;
127
+ release(): void;
128
+ open(): boolean;
129
+ container(): HTMLElement | null;
130
+ /** The panel's attach/detach halves — detach is keyed by the element so a
131
+ * stale panel's cleanup never empties a newer panel's slot. */
132
+ setContainer(el: HTMLElement): void;
133
+ releaseContainer(el: HTMLElement): void;
134
+ }
135
+
136
+ /** The open documents, as a contribution may read them. */
137
+ export interface EditorHostDocuments {
138
+ activeId(): string | null;
139
+ /** The active document's kind (`'workspace'`, `'tool-contribution'`, …)
140
+ * and title, or null with none open. */
141
+ active(): { readonly id: string; readonly kind: string; readonly title: string } | null;
142
+ subscribe(listener: () => void): () => void;
143
+ version(): number;
144
+ /**
145
+ * THE DOCUMENT'S OWN PUBLISHED CONTEXT — the one object a document hands the
146
+ * host to be driven through (`editor.document.run(ctx => …)`, the REPL
147
+ * door), or `undefined` when that document published none.
148
+ *
149
+ * NEW (2026-09-19) because the document a package DRIVES is not always the
150
+ * document it opened: `@vgai/blender` presents every Blender frame into the
151
+ * Model document, which `@vgai/blender` contributes and publishes — so the two
152
+ * packages meet at this registry and neither may reach the host's
153
+ * `@editor/document-context-registry` to find it. The value is `unknown` on
154
+ * purpose: what a document publishes is an agreement between the package
155
+ * that renders it and the package that drives it, and the host is not a
156
+ * party to it — the caller narrows, and refuses by name when the shape is
157
+ * not the one it needs.
158
+ */
159
+ context(documentId: string): unknown;
160
+ /**
161
+ * The same read, where the caller can WAIT. Opening a document activates its
162
+ * tab before an async model import can publish a context, so a driver that
163
+ * read once would mistake a loading document for an unsupported one.
164
+ * Resolves `undefined` when the window elapses.
165
+ */
166
+ waitForContext(documentId: string, timeoutMs?: number): Promise<unknown>;
167
+ /**
168
+ * THE PUBLISHED CONTEXT HAS MOVED — said by the package that DRIVES this
169
+ * document, which is not always the package that published it.
170
+ *
171
+ * NEW (2026-09-19, WORK.md §Blender in the tab is Blender, "Inspection
172
+ * parity", I1) because a context object is a LIVE HANDLE: `@vgai/blender`
173
+ * reads the engine through its RNA door, and that answer decides which
174
+ * Properties tabs exist for the selected datablock — an armature has a Bone
175
+ * tab, a cube does not. Nothing in the host's own stores moves when the
176
+ * engine answers, so the inspector had no reason to re-compose and the rail
177
+ * stayed at whatever the first render could see. Calling this re-derives the
178
+ * whole inspection, matches included.
179
+ *
180
+ * It is a notification, not a publication: the context object itself is
181
+ * unchanged, and a document that published none is a no-op.
182
+ */
183
+ contextChanged(documentId: string): void;
184
+ }
185
+
186
+ /**
187
+ * The project's editor-local state document (`.vgai/editor-state.json`):
188
+ * per-project preferences a contribution keeps — pins, collapsed sections —
189
+ * that are neither the game's data nor a person's global settings. One
190
+ * section per contribution, named by it.
191
+ */
192
+ export interface EditorHostProjectLocalState {
193
+ ready(): boolean;
194
+ read<T>(section: string): T | undefined;
195
+ write(section: string, value: unknown): void;
196
+ /** The open project's root path, or null on a tier with none. */
197
+ projectRootPath(): string | null;
198
+ }
199
+
200
+ /**
201
+ * A LANE that mounts something in the tab — Play, an ingested game, a module
202
+ * world — as the host sees it. A package registers its lane and the host asks
203
+ * only these questions; it never names the lane.
204
+ */
205
+ export interface LiveSession {
206
+ readonly id: string;
207
+ /** `stop` order among lanes, low first (Play before ingest). */
208
+ readonly priority?: number;
209
+ /** A real game owns a canvas right now. */
210
+ mounted(): boolean;
211
+ /** Somebody asked the host to RUN content; a held mount is not playing. */
212
+ playing(): boolean;
213
+ /** Idempotent; a no-op when the lane runs nothing. */
214
+ stop(): void;
215
+ /** The element holding a live instance (the primary when `id` is omitted). */
216
+ instanceContainer(id?: string): HTMLElement | null;
217
+ /** When this lane's most recent run began / ended (ms epoch); the host
218
+ * fences per-run diagnostics on the newest window across lanes. */
219
+ startedAt?(): number | null;
220
+ endedAt?(): number | null;
221
+ /** Why the running content is stale (a source edit the run cannot absorb),
222
+ * or null while it is fresh; `restart` is the lane's own re-entry. */
223
+ restartRequired?(): string | null;
224
+ restart?(): void;
225
+ /** Re-mount with an authored selection while running; absent when the lane
226
+ * cannot. */
227
+ remount?(args: LiveRemountArgs): Promise<{ ok: true } | { ok: false; error: string }>;
228
+ /** The canvas this lane's own render pass draws, when its pixels can only
229
+ * be read from inside that pass (no `preserveDrawingBuffer`); the host's
230
+ * frame capture asks `snapshotFrame` for it. */
231
+ frameCanvas?(): HTMLCanvasElement | null;
232
+ snapshotFrame?(): Promise<CanvasImageSource | null>;
233
+ /** Why authoring is OFF for this lane's content (an ingested native-React
234
+ * game has no scene graph to introspect), or null when it is on; the
235
+ * inspector prints it in place of its sections. */
236
+ authoringRefusal?(): string | null;
237
+ /** A relayed command this lane answers ITSELF, ahead of every handler —
238
+ * an ingested game owns its mount, so `play`/`stop`/`pause`/`resume`/
239
+ * `step` drive its own loop rather than boot a first-party session over
240
+ * it. Null declines; the host then dispatches as usual. */
241
+ command?(cmd: LiveCommand): LiveCommandResult | null;
242
+ /** The scene entries the running content navigates (a contract game's
243
+ * scene table), for `open-scene` on a running lane. */
244
+ scenes?(): LiveSceneTable | null;
245
+ /** The native surface the running content draws on, when the lane knows
246
+ * it (an ingested game's declared surface); the host's coverage grades
247
+ * a root against it. */
248
+ surface?(): 'three' | 'canvas' | 'dom' | null;
249
+ /** The lane's OWN coverage of the running content's contracts (an
250
+ * ingested game's), shown by the inspector on the live document and
251
+ * standing in for the host's native-system grading while it runs. */
252
+ coverage?(): LiveCoverageReport | null;
253
+ }
254
+
255
+ /**
256
+ * What the running content answered about a switch it ACCEPTED — read off its
257
+ * own current scene once the switch settled. `error` is the content's own
258
+ * failure (a refused asset load, a throw), reported rather than swallowed; the
259
+ * scene it is actually in is still reported beside it, because that is the
260
+ * question the caller has next.
261
+ */
262
+ export interface LiveSceneSwitchSettled {
263
+ readonly requested: string;
264
+ readonly current: string | null;
265
+ readonly error?: string;
266
+ }
267
+
268
+ /**
269
+ * A scene switch, split at the seam where the answer stops being immediate:
270
+ * MEMBERSHIP is decided synchronously against the content's own scene list,
271
+ * and only an accepted switch has a `settled` promise to await.
272
+ *
273
+ * The split is what lets the caller act on the refusal without waiting, and —
274
+ * for the host's held-mount repaint — start drawing frames the instant a
275
+ * switch is accepted rather than after it lands.
276
+ */
277
+ export type LiveSceneSwitch =
278
+ | { readonly ok: true; readonly settled: Promise<LiveSceneSwitchSettled> }
279
+ | { readonly ok: false; readonly error: string; readonly known: readonly string[] };
280
+
281
+ /**
282
+ * A running game's own scene table: its stories, plus the switch the host
283
+ * awaits to open one.
284
+ *
285
+ * `goToScene`'s RESULT is stated here rather than left `unknown` for the
286
+ * editor to narrow (as it was until 2026-09-18). The narrowing lived in a lane
287
+ * module — the ingest lane's scenes projection — so the host's `open` verb had
288
+ * to import that lane by name to know what a switch answers, which is how the
289
+ * ingest contract reached the host's live registry. A contract states its own
290
+ * result; a lane implements it.
291
+ */
292
+ export interface LiveSceneTable extends StoriesProvider {
293
+ goToScene(sceneId: string): LiveSceneSwitch;
294
+ }
295
+
296
+ /** One seam's verdict in a lane's own coverage report — the doctrine's row
297
+ * (ARCHITECTURE-CORE §Adapters never fabricate first-party data: a gap
298
+ * names the mechanism that fills it). */
299
+ export interface LiveCoverageRow {
300
+ readonly seam: string;
301
+ readonly status: 'ok' | 'gap' | 'na' | 'info';
302
+ readonly detail: string;
303
+ readonly missing?: string;
304
+ readonly fix?: string;
305
+ readonly attestedBy?: 'game' | 'host';
306
+ }
307
+
308
+ export interface LiveCoverageReport {
309
+ readonly summary: {
310
+ readonly worldId: string;
311
+ readonly rows: number;
312
+ readonly gaps: number;
313
+ readonly ok: number;
314
+ readonly na: number;
315
+ readonly info: number;
316
+ };
317
+ readonly rows: readonly LiveCoverageRow[];
318
+ }
319
+
320
+ export interface LiveCommand {
321
+ readonly type: string;
322
+ readonly [key: string]: unknown;
323
+ }
324
+
325
+ export interface LiveCommandResult {
326
+ readonly ok: boolean;
327
+ readonly error?: string;
328
+ readonly data?: Record<string, unknown>;
329
+ }
330
+
331
+ export interface LiveRunWindow {
332
+ readonly startedAt: number;
333
+ readonly endedAt: number | null;
334
+ }
335
+
336
+ export interface EditorHostLive {
337
+ register(session: LiveSession): () => void;
338
+ mounted(): boolean;
339
+ playing(): boolean;
340
+ /** The newest run across lanes, or null before any ran. */
341
+ runWindow(): LiveRunWindow | null;
342
+ restartRequired(): string | null;
343
+ /** Re-enter whichever lane reports a restart is required (or is running). */
344
+ restart(): void;
345
+ /** Fires on registration and whenever a lane says its state moved
346
+ * (`notifyChanged`). */
347
+ subscribe(listener: () => void): () => void;
348
+ version(): number;
349
+ /** A lane's own state moved (restart-required, run window). */
350
+ notifyChanged(): void;
351
+ /** The mounted lane whose render pass owns `canvas`, asked for its pixels. */
352
+ snapshotFrame(canvas: HTMLCanvasElement): Promise<CanvasImageSource | null> | null;
353
+ frameCanvas(): HTMLCanvasElement | null;
354
+ authoringRefusal(): string | null;
355
+ /** The first mounted lane's own answer to `cmd`, or null when none claims it. */
356
+ dispatch(cmd: LiveCommand): LiveCommandResult | null;
357
+ /** The running lane's scene entries, or null. */
358
+ scenes(): LiveSceneTable | null;
359
+ surface(): 'three' | 'canvas' | 'dom' | null;
360
+ coverage(): LiveCoverageReport | null;
361
+ }
362
+
363
+ /** A live instance's roots, presented as the authored viewport's subject. */
364
+ export interface ViewportPresentation {
365
+ /** The root whose native subject the viewport shows. */
366
+ readonly worldId: string;
367
+ /** Restores the viewport's prior subject; idempotent. */
368
+ dispose(): void;
369
+ }
370
+
371
+ /** The authored viewport's camera rig, while a viewport is mounted. */
372
+ export interface ViewportRig {
373
+ readonly camera: THREE.PerspectiveCamera;
374
+ readonly orbit: { readonly target: THREE.Vector3; enabled: boolean; update(): void };
375
+ /** The editor's own scene (helpers live on its editor layer). */
376
+ readonly scene: THREE.Scene;
377
+ }
378
+
379
+ /**
380
+ * ONE MOUNTED 3D STAGE, named by the document it draws. Every 3D document
381
+ * mounts a stage of its own (ARCHITECTURE-CORE §One stage); this is the door
382
+ * to a particular one, where {@link EditorHostViewport}'s singular members
383
+ * are the door to whichever is primary.
384
+ */
385
+ export interface EditorHostStage {
386
+ /** The workspace document this stage draws. */
387
+ readonly documentId: string;
388
+ rig(): ViewportRig;
389
+ /** As {@link EditorHostViewport.setHelper}, on this stage alone. */
390
+ setHelper(kind: string, object: THREE.Object3D | null): void;
391
+ /** Runs after THIS stage's own per-frame update; the return unsubscribes. */
392
+ onFrame(fn: (dtSeconds: number) => void): () => void;
393
+ }
394
+
395
+ /**
396
+ * THE AUTHORED VIEWPORT, as a lane that mounts something reaches it: the rig
397
+ * a camera flight drives, the frame loop it rides, and the one verb that
398
+ * swaps the viewport's subject for a live instance's roots (Play's adoption
399
+ * of the running scene). Which native surface the viewport renders is the
400
+ * host's; a lane that finds no rig (a headless tab, a project with no Three
401
+ * root) mounts without one.
402
+ *
403
+ * THE SINGULAR MEMBERS ARE THE PRIMARY STAGE — the stage presenting live
404
+ * roots if one is, else the FOCUSED stage, else the first bound. A camera
405
+ * flight and Play's adoption want exactly that one, which is why they read
406
+ * here and never enumerate.
407
+ *
408
+ * {@link stages} is EVERY mounted 3D stage. A helper is an `Object3D` and an
409
+ * `Object3D` has one parent, so there is no "set it once for all stages": a
410
+ * helper wanted on every stage is set on each stage through `stages()`, and
411
+ * `onStages` is how a lane keeps up with stages that mount and unmount after
412
+ * it made that pass.
413
+ */
414
+ /** A mounted adapter surface offered to the viewport. Execution, input and
415
+ * ticking stay with the caller; presentation requires only identity and mount.
416
+ * A game's richer root instance can satisfy this interface directly.
417
+ */
418
+ export interface ViewportRoot {
419
+ readonly id: string;
420
+ readonly mounted: MountedRoot;
421
+ }
422
+
423
+ export interface EditorHostViewport {
424
+ rig(): ViewportRig | null;
425
+ /** Null when the viewport has no subject among `roots` (no Three root). */
426
+ presentRoots(roots: readonly ViewportRoot[]): ViewportPresentation | null;
427
+ /** Runs after the viewport's own per-frame update; the return unsubscribes. */
428
+ onFrame(fn: (dtSeconds: number) => void): () => void;
429
+ /**
430
+ * Show an editor-only helper object of `kind` in the authored viewport (a
431
+ * baked navmesh's debug mesh), replacing the previous one of that kind;
432
+ * null clears it. Visibility follows the host's Helpers menu: a kind the
433
+ * menu lists (`navmesh`) toggles on its own, any other follows the master
434
+ * toggle. Kept across viewport remounts.
435
+ */
436
+ setHelper(kind: string, object: THREE.Object3D | null): void;
437
+ /** Every mounted 3D stage, in bind order. */
438
+ stages(): readonly EditorHostStage[];
439
+ /** Fires whenever a stage mounts or unmounts; the return unsubscribes. */
440
+ onStages(fn: (stages: readonly EditorHostStage[]) => void): () => void;
441
+ readonly transition: EditorHostLiveTransition;
442
+ }
443
+
444
+ /**
445
+ * THE LIVE TRANSITION — the host's hand-off from authoring chrome to a
446
+ * running lane: under the immersive presentation the dock dissolves, the
447
+ * authored viewport's camera flies to the authored game camera, and when the
448
+ * lane reports ready the Scene document cross-fades into the live one. The
449
+ * host decides whether the presentation is immersive and resolves the flight
450
+ * target itself; a lane only says when it starts, when it is ready, and when
451
+ * it ends. Every path out of a run must reach `end()`.
452
+ */
453
+ export interface EditorHostLiveTransition {
454
+ /** Call BEFORE flipping the session to playing: the flight target is read
455
+ * from the authored scene, which Play's own adoption then replaces. */
456
+ begin(): void;
457
+ /** The lane finished its async boot; `getLiveCamera` is the render camera
458
+ * the flight converges on so the cross-fade is pixel-continuous. */
459
+ ready(getLiveCamera?: () => THREE.Object3D | null): void;
460
+ end(): void;
461
+ /** Once the entry settles (cross-fade done, or torn down early) — at once
462
+ * when none is in flight. One-shot; re-check session state inside. */
463
+ onSettled(fn: () => void): void;
464
+ phase(): 'idle' | 'entering' | 'holding' | 'crossfade' | 'playing';
465
+ }
466
+
467
+ /** A lane's answer to the host's "re-mount with this selection" (an authored
468
+ * scene entry opened while the lane runs). */
469
+ export interface LiveRemountArgs {
470
+ readonly selection: string;
471
+ readonly key: string;
472
+ readonly regionId: string;
473
+ }
474
+
475
+ /**
476
+ * THE LIVE OBJECTS BY NODE ID — the world node IS the entity (CLAUDE.md), so
477
+ * a contribution inspecting a live behavior (an XState machine on an
478
+ * object's userData) resolves the hierarchy's node id to the object itself.
479
+ * The map is the authored viewport's in Edit and the adopted live scene's
480
+ * in Play; a reader must not hold an object across `subscribe` firings.
481
+ */
482
+ export interface EditorHostHierarchy {
483
+ object(id: string): THREE.Object3D | null;
484
+ objects(): ReadonlyMap<string, THREE.Object3D>;
485
+ /** Fires on any shell-store change (membership included). */
486
+ subscribe(listener: () => void): () => void;
487
+ version(): number;
488
+ }
489
+
490
+ /** The editor's own session state a contribution may read. */
491
+ export interface EditorHostSession {
492
+ /**
493
+ * Contribute fields to the editor's state report (`vgai status`, the SDK's
494
+ * `editor.state`): the collect runs on every report and its keys are
495
+ * spread in. A lane reports what only it knows — its loop's time scale and
496
+ * liveness, its seed — where the host reports the session. Returns the
497
+ * unregister.
498
+ *
499
+ * EVERY registered collect runs on EVERY report, including the interaction
500
+ * path's reusing one — so a facet whose derivation is expensive declares its
501
+ * `reusableKeys` and reads them back off the `reuse` snapshot it is handed.
502
+ * That is what let the COVERAGE REPORT families (`rootCoverage`,
503
+ * `systemCoverage`, `projectCoverage`, `authoringCoverage`) stop being host
504
+ * fields: their 76ms-to-1.3s derivation is exactly what
505
+ * `command-listener.ts`'s `REUSABLE_DERIVED_FACETS` exists to keep off a
506
+ * store notification, and the host's own docblock says the choice is the
507
+ * CALLER's — "only the caller knows whether it is on a user's critical
508
+ * path" — so no facet-side cache could have been the same answer. The
509
+ * declared keys join that set: the host strips them from an interaction
510
+ * PATCH by name (`currentStatePatch`), and the deferred full collect that
511
+ * always follows makes them current again.
512
+ */
513
+ reportFacet(
514
+ collect: (reuse: Record<string, unknown> | null) => Record<string, unknown>,
515
+ options?: { readonly reusableKeys?: readonly string[] },
516
+ ): () => void;
517
+ /**
518
+ * THE SESSION'S PERIODIC SAMPLE — the host's own five-second vitals tick
519
+ * (`coverage/session-vitals.ts`), which a lane may hang a periodic
520
+ * derivation of its own on. Returns the unsubscribe.
521
+ *
522
+ * A lane that wants "every few seconds, look at the session and say
523
+ * something" subscribes HERE rather than starting a second interval: the
524
+ * vitals sampler already owns the cadence, already runs on every realm, and
525
+ * a package-owned timer beside it would sample the same session at a
526
+ * different instant and report two answers for one moment. Listeners run
527
+ * before the host's own reveal failsafe and invariant report, which is the
528
+ * order the coverage union held when it was a host call on this tick.
529
+ */
530
+ onSample(fn: () => void): () => void;
531
+ /** Every relayed command, by type, as it is dispatched — the signal an
532
+ * idle watchdog reads ("an agent still driving through `vgai eval` is
533
+ * not idle"). Returns the unsubscribe. */
534
+ onCommandDispatched(fn: (type: string) => void): () => void;
535
+ playState(): 'stopped' | 'playing' | 'paused';
536
+ /** `'ephemeral'` while Play holds edits that will not persist; null otherwise. */
537
+ playEditRegime(): 'ephemeral' | null;
538
+ /** Fires on any shell-store change; select what you read. */
539
+ subscribe(listener: () => void): () => void;
540
+ version(): number;
541
+ /**
542
+ * Whether a PROJECT SESSION is open at all — the editor has a project and the
543
+ * shell that edits it, so a document can be opened and something can be
544
+ * presented into it.
545
+ *
546
+ * NEW (2026-09-19). `@vgai/blender` refuses every verb but its own status
547
+ * read without one, so that a call arriving at a session-less page answers at
548
+ * once instead of booting a Blender worker (gigabytes) into a page with
549
+ * nowhere to show it. It took the same answer from the host's
550
+ * `@editor/shell-store-door`; this is the question, without the store.
551
+ */
552
+ open(): boolean;
553
+ /**
554
+ * THIS PAGE'S SESSION ENDED — the tombstone every end goes through, graceful
555
+ * (`vgai close`) or not (the server died, another session took the port).
556
+ * Returns the unsubscribe.
557
+ *
558
+ * NEW (2026-09-19), and it is a RELEASE hook: a page told `tab-close` keeps
559
+ * running (Chrome refuses `window.close()` for a tab a person opened), so a
560
+ * package holding something the page cannot pay for holds it forever. The
561
+ * measurement that bought it: two orphaned editor tabs held 13 GB and 7 GB of
562
+ * resident Blender worker between them and put the box into a swap storm.
563
+ * A lane that owns a worker, a socket or a device ends it here.
564
+ */
565
+ onEnded(fn: () => void): () => void;
566
+ /**
567
+ * PUBLISH (or retract, with null) THIS LANE'S OUT-OF-PROCESS WORKER METER, so
568
+ * the tab census carries it out on the heartbeat — the one channel that still
569
+ * beats through a blocked main thread, which is why `reportFacet` above
570
+ * cannot answer this: a wedged tab is exactly the tab whose state report
571
+ * never arrives.
572
+ *
573
+ * A READ rather than a snapshot: an outstanding call's age has to be computed
574
+ * at the instant it is reported, and the package is the side that has the
575
+ * clock (a blocked worker cannot report on itself, and the side that POSTED
576
+ * the call still knows when it did).
577
+ *
578
+ * The HOST owns the rest of the measurement — the main thread's own long
579
+ * tasks, and which of them overlapped the call — and starts measuring when a
580
+ * meter arrives. That half was `@editor/blender-tab-metrics`, a host module
581
+ * the package used to start and publish into; the page's stalls were never
582
+ * the package's to observe. ONE meter rides the census, so a second lane
583
+ * publishing here replaces the first.
584
+ */
585
+ reportWorkerCallMeter(read: (() => EditorHostWorkerCallMetrics) | null): void;
586
+ }
587
+
588
+ /**
589
+ * A LANE'S WORKER CALLS, as numbers — what the tab census carries so that
590
+ * `vgai status` can say a tab stopped answering and why.
591
+ *
592
+ * Times are milliseconds on `performance.now()`; counters are monotonic since
593
+ * the lane's runtime was constructed. MEASUREMENT ONLY: nothing here cancels,
594
+ * kills or budgets a call.
595
+ */
596
+ export interface EditorHostWorkerCallMetrics {
597
+ /** Age of the OLDEST outstanding call, or null when the worker is idle — the
598
+ * only field with a number during a wedge. */
599
+ readonly inFlightMs: number | null;
600
+ /** Duration of the newest completed call; null before the first one. */
601
+ readonly lastCallMs: number | null;
602
+ /** The longest call yet, counting an outstanding one at its current age. */
603
+ readonly maxCallMs: number | null;
604
+ /** Calls past 5s, and past 30s, since the runtime was constructed. */
605
+ readonly callsOver5s: number;
606
+ readonly callsOver30s: number;
607
+ /** The newest call's window (`end` null while it is outstanding); the host
608
+ * intersects its own long tasks with it. */
609
+ readonly lastCallWindow: { readonly start: number; readonly end: number | null } | null;
610
+ /** The lane's own out-of-process memory in MB (a wasm module's linear
611
+ * memory), or null when it has none to report. */
612
+ readonly wasmMemoryMB: number | null;
613
+ }
614
+
615
+ /** The open project's declared SHAPE, as a contribution may gate on it. */
616
+ export interface EditorHostProject {
617
+ /** Whether the project declares at least one root that plays. */
618
+ mounts(): boolean;
619
+ /**
620
+ * The engine version the OPEN project is pinned to
621
+ * (`vgai.project.json`'s `engine.version`), or null when no project is
622
+ * open — the same one source `ProjectHeader.tsx` renders, so a package's
623
+ * version readout can never disagree with the host's.
624
+ *
625
+ * A primitive rather than the project object on purpose: `ActiveProject`
626
+ * is a host internal, and the SDK's door grows one member per
627
+ * contribution that needs it (`@vgai/blender`'s `workspace.status` version
628
+ * item is the reader). Paired with {@link subscribe}, this is the whole
629
+ * "current project + change" the door owes a contribution — and it is
630
+ * stable enough for `useSyncExternalStore` without a snapshot cache.
631
+ */
632
+ engineVersion(): string | null;
633
+ subscribe(listener: () => void): () => void;
634
+ /**
635
+ * Runs once the open project's authoring surfaces are READY — its scene
636
+ * loaded and the edit-mode composite installed — the moment a lane may
637
+ * auto-launch what the manifest declares (an ingest root). Listeners run
638
+ * in registration order, each awaited; a project opened later fires it
639
+ * again. Returns the unsubscribe.
640
+ */
641
+ onReady(fn: () => void | Promise<void>): () => void;
642
+ }
643
+
644
+ export interface EditorHostNotification {
645
+ /** A stable id replaces an earlier notification with the same id. */
646
+ readonly id?: string;
647
+ readonly tone: 'info' | 'warning' | 'error';
648
+ /** One line, bold — what happened. */
649
+ readonly title: string;
650
+ /** The rest, plain — what it means, what to do. */
651
+ readonly detail?: string;
652
+ readonly actions?: readonly {
653
+ readonly label: string;
654
+ readonly run: () => void;
655
+ readonly primary?: boolean;
656
+ }[];
657
+ }
658
+
659
+ /** The editor's console — the session-held set `vgai console` prints. A
660
+ * contribution's diagnostics go here, never to `console.*`, so they reach
661
+ * every door whether or not anyone looks at the tab. */
662
+ export interface EditorHostConsole {
663
+ log(message: string, source: string): void;
664
+ warn(message: string, source: string): void;
665
+ error(message: string, source: string): void;
666
+ }
667
+
668
+ /**
669
+ * THE SETTINGS DOOR — one dotted `vgai.*` key at a time, with the LAYER each
670
+ * value came from, and the one write that lands where it wins.
671
+ *
672
+ * ARCHITECTURE-CORE §The core is Code-OSS: *"the settings layers and settings
673
+ * UI → the configuration service (the ADAPTER layer between user and
674
+ * workspace … is the one addition)"*. The same two-owner shape as
675
+ * {@link EditorHostKeyboard}, {@link EditorHostHistory} and
676
+ * {@link EditorHostFiles}: `'host'` is standalone `vgai edit`, where
677
+ * `settings-store.ts`'s three layers ARE the settings; `'frame'` is the
678
+ * Code-OSS frame, where `IConfigurationService` is.
679
+ *
680
+ * ## The keys are `vgai.*`, and the prefix is part of the key
681
+ *
682
+ * `vgai.appearance.palette`, `vgai.keymap`, `vgai.devicePreview.preset` — the
683
+ * flat dotted names `@volter/editor-project/settings/keys` derives from the settings
684
+ * schema, which is also what the fork's `contributes.configuration` is
685
+ * generated from. One spelling in this door, in `.vscode/settings.json`, in
686
+ * VS Code's Settings editor and in what `vgai eval` prints, because the moment
687
+ * there are two a reader has to know which side of which seam they are on to
688
+ * know which to type.
689
+ *
690
+ * ## The adapter layer, and why `inspect` names it
691
+ *
692
+ * "Project over ADAPTER over user" (§Adapters and contributions are code) is
693
+ * the one thing the configuration service does not already have, and under the
694
+ * frame it is the service's own MEMORY target — the top layer — written by the
695
+ * fork when the project's adapter loads and cleared the moment `inspect` shows
696
+ * a workspace or folder value for that key. So `inspect(key)` answers with the
697
+ * FOUR layers a person can act on, and a caller that wants to know whether a
698
+ * gesture will stick asks it rather than guessing from the effective value.
699
+ */
700
+ export interface EditorHostSettings {
701
+ /**
702
+ * Install VS Code's configuration service as the settings. The frame calls
703
+ * this once its own services exist, which is AFTER the editor mounts (a
704
+ * `ServicesAccessor` is valid only for the synchronous part of an
705
+ * invocation). Until it does, the door falls back to the editor's own layers
706
+ * rather than refusing: the editor paints in that window, and a palette read
707
+ * there is a real read with nowhere else to go.
708
+ */
709
+ setProvider(provider: EditorHostSettingsProvider): void;
710
+ /** The EFFECTIVE value — project over adapter over user over default — or
711
+ * `undefined` when no layer carries it. */
712
+ get(key: string): unknown;
713
+ /**
714
+ * Write one key.
715
+ *
716
+ * With no `target`, the write goes WHERE IT WINS: the project when this
717
+ * project's adapter or its own settings already declare the key, the user
718
+ * layer otherwise. That is the whole of `updatePreferenceSettings`'s rule,
719
+ * and it is here rather than in each caller because writing `appearance` to
720
+ * the user layer in a project whose adapter declares a style is a gesture
721
+ * that silently does nothing.
722
+ */
723
+ set(key: string, value: unknown, target?: EditorHostSettingsTarget): void;
724
+ /** Every layer's own value for this key, plus the effective one. A layer
725
+ * that is silent about the key answers `undefined` — never the value from
726
+ * the layer under it. */
727
+ inspect(key: string): EditorHostSettingsInspection;
728
+ subscribe(listener: () => void): () => void;
729
+ }
730
+
731
+ /** The two layers a person's gesture can land in. The adapter layer is the
732
+ * project's own CODE and the default layer is the build's, so neither is a
733
+ * write target. */
734
+ export type EditorHostSettingsTarget = 'user' | 'project';
735
+
736
+ export interface EditorHostSettingsInspection {
737
+ /** The built-in value, when the layer that declares the key carries one.
738
+ * The standalone layers carry none, so this is `undefined` there. */
739
+ readonly default: unknown;
740
+ /** `~/.vgai/settings.json` standalone; the USER target under the frame. */
741
+ readonly user: unknown;
742
+ /** What `vgai.adapter.ts` DECLARES (`editor: { style, keymap }`); the
743
+ * MEMORY target under the frame. */
744
+ readonly adapter: unknown;
745
+ /** `<project>/.vgai/settings.json` standalone; the WORKSPACE (and folder)
746
+ * target under the frame. */
747
+ readonly project: unknown;
748
+ /** Project over adapter over user over default. */
749
+ readonly effective: unknown;
750
+ }
751
+
752
+ /**
753
+ * THE FRAME'S HALF — what the Code-OSS bridge installs, backed by
754
+ * `IConfigurationService`. Keys are the same `vgai.*` names the door takes.
755
+ *
756
+ * There is no `owner`/`setOwner` here and no optional member: unlike
757
+ * {@link EditorHostFileProvider}, a configuration service can answer every one
758
+ * of these for every key, so a member the frame "cannot answer" would be a
759
+ * defect rather than a shape.
760
+ */
761
+ export interface EditorHostSettingsProvider {
762
+ get(key: string): unknown;
763
+ inspect(key: string): EditorHostSettingsInspection;
764
+ set(key: string, value: unknown, target: EditorHostSettingsTarget): void;
765
+ /** Fires when any `vgai.*` value changes in any layer. Returns the
766
+ * unsubscribe. */
767
+ subscribe(listener: () => void): () => void;
768
+ }
769
+
770
+ /**
771
+ * THE KEYBOARD DOOR — who owns the keyboard, and the chord-independent table
772
+ * of what the editor's keyboard actions DO.
773
+ *
774
+ * ARCHITECTURE-CORE §The core is Code-OSS rule 3: *"Keyboard ownership is VS
775
+ * Code's. One keybinding system … Two listeners cannot both own the
776
+ * keyboard."* Under the Code-OSS frame the workbench's keybinding service is
777
+ * the one keyboard: the fork's contribution registers one `vgai.<action id>`
778
+ * command per entry of `actions()`, gives each the chords `keymaps()` reports
779
+ * under a `when` clause over its own context keys, and dispatches through
780
+ * `invoke`. The editor installs no `keydown` listener of its own at all.
781
+ */
782
+ export interface EditorHostKeyboard {
783
+ /**
784
+ * Every action the editor has a live handler for right now, with the scope
785
+ * its chord belongs to — `'stage'` (the focused stage alone), `'panel'`
786
+ * (any of the editor's own parts) or `'global'`. The viewport set appears
787
+ * only while a three stage is mounted, so this is a live list, not a
788
+ * catalogue; `subscribe`/`version` report when it moves.
789
+ */
790
+ actions(): readonly { readonly id: string; readonly scope: 'stage' | 'panel' | 'global' }[];
791
+ /**
792
+ * Every registered keymap and the chords it assigns each action — the
793
+ * editor's own `vgai` table and whatever a project's packages contribute
794
+ * (Blender's G/R/S). A keymap is a SET of keybinding rules to the frame:
795
+ * the same commands, different chords, gated on `activeKeymap()`.
796
+ */
797
+ keymaps(): readonly {
798
+ readonly id: string;
799
+ readonly title: string;
800
+ readonly chords: Readonly<
801
+ Record<
802
+ string,
803
+ readonly {
804
+ readonly key: string;
805
+ readonly code?: string;
806
+ readonly mod?: boolean;
807
+ readonly shift?: boolean;
808
+ readonly alt?: boolean;
809
+ }[]
810
+ >
811
+ >;
812
+ }[];
813
+ /** The keymap the PROJECT selected (its adapter's `editor.keymap`, its own
814
+ * settings over it). The frame publishes it as a context key and never
815
+ * keeps a second setting of its own. */
816
+ activeKeymap(): string;
817
+ /** Run one action by id. `false` when nothing handles it now, or its own
818
+ * gate refused — never silent. */
819
+ invoke(id: string): boolean;
820
+ subscribe(listener: () => void): () => void;
821
+ version(): number;
822
+ /**
823
+ * WHAT THE FOCUSED STAGE IS SHOWING, for the frame's context keys. It is
824
+ * here rather than beside `viewport` because it exists for exactly one
825
+ * reader: the `when` clauses that decide which keyboard action a chord
826
+ * reaches. `surface` is the stage's (`stage-context.ts`); `mode` is the
827
+ * document's own interaction mode when it reports one (Blender's
828
+ * object/edit/sculpt) and `null` when nothing does.
829
+ */
830
+ stage(): {
831
+ readonly surface: 'three' | 'canvas' | 'dom' | null;
832
+ readonly mode: string | null;
833
+ };
834
+ }
835
+
836
+ /**
837
+ * ONE RECORDED EDIT, as whoever owns undo sees it — the SDK's spelling of
838
+ * `packages/editor/src/history/history-delegate.ts`'s `HistoryElement`.
839
+ *
840
+ * It is deliberately an `IResourceUndoRedoElement` (one file) or an
841
+ * `IWorkspaceUndoRedoElement` (several) without naming either: the frame does
842
+ * that translation, so no editor module imports VS Code and no file under the
843
+ * fork's `src/vs/` imports an editor module.
844
+ */
845
+ export interface EditorHostHistoryElement {
846
+ readonly id: string;
847
+ /** User-presentable, already trimmed ("Transform Selection"). This is what
848
+ * the frame's Edit menu shows after "Undo". */
849
+ readonly label: string;
850
+ /**
851
+ * The PROJECT-RELATIVE files this edit changed, in the order the
852
+ * transaction declared them — `src/prefabs/Crate.tsx`, not a URI and not an
853
+ * opaque key, because only the frame knows the workspace folder they
854
+ * resolve against, and resolving them there is what puts a gizmo drag and a
855
+ * keystroke in the same file's text editor on ONE resource's stack.
856
+ *
857
+ * EMPTY for a session-scoped edit (a live journal on a held canvas surface,
858
+ * a play run), which has no file at all. Placing such an element is the
859
+ * frame's decision, named there — never silently attached to whatever
860
+ * document happened to be open.
861
+ */
862
+ readonly resources: readonly string[];
863
+ /**
864
+ * THE WORKSPACE DOCUMENT THIS EDIT WAS MADE IN, at the moment it was
865
+ * recorded — the id, or null when nothing was active.
866
+ *
867
+ * MEASURED (2026-09-19, the game template inside the frame): a three root's
868
+ * document is NOT one file. Its adapter's own source path is the root entry
869
+ * `src/world.tsx`, while a gizmo drag on the scene's HeroBox instance writes
870
+ * `src/scenes/MainScene.tsx` — so "the document's resource" is a SET that
871
+ * grows with what the person edits, and asking the undo service about the
872
+ * entry file alone would find nothing to undo. The document id is the stable
873
+ * thing; WHICH file its next undo acts on is the newest element recorded in
874
+ * it. It is also what keeps a component view's stack apart from the main
875
+ * scene's.
876
+ */
877
+ readonly document: string | null;
878
+ /** Revert this one entry. `false` when the editor refused (a conflict, an
879
+ * expired resource, blocked history) — never a silent no-op. */
880
+ undo(): Promise<boolean>;
881
+ redo(): Promise<boolean>;
882
+ }
883
+
884
+ /**
885
+ * UNDO, for the Code-OSS frame (ARCHITECTURE-CORE §The core is Code-OSS:
886
+ * *"history-service.ts → IUndoRedoService … there is one Cmd+Z"*).
887
+ *
888
+ * The same two halves as {@link EditorHostKeyboard}, for the same reason: the
889
+ * STANDALONE `vgai edit` shape fills this with the editor's own
890
+ * `history-service.ts` cursor, and the FRAME takes ownership before the editor
891
+ * mounts and pushes every {@link EditorHostHistoryElement} into VS Code's
892
+ * `IUndoRedoService` instead. Nothing here caps anything by bytes — snapshot
893
+ * size stays the adapter's concern, stated where the snapshot is taken.
894
+ */
895
+ /** The frame's own undo, for every editor affordance that is not a chord. */
896
+ export interface EditorHostHistoryDelegate {
897
+ undo(): void;
898
+ redo(): void;
899
+ canUndo(): boolean;
900
+ canRedo(): boolean;
901
+ }
902
+
903
+ export interface EditorHostHistory {
904
+ /**
905
+ * Install the frame's own undo as the one stack. Called once its service
906
+ * exists, which is AFTER the editor mounts.
907
+ *
908
+ * The delegate is the OTHER direction of this door: the editor has undo
909
+ * affordances that are not the keyboard — its Edit menu's "Undo <label>",
910
+ * the command palette, `vgai eval`'s undo verb — and every one of them must
911
+ * reach the ONE stack. Without it the Edit menu still names the step (the
912
+ * label comes from the last recorded entry) while the click refuses, which
913
+ * is worse than no menu item at all.
914
+ */
915
+ setDelegate(delegate: EditorHostHistoryDelegate): void;
916
+ /**
917
+ * Every entry as it is recorded, once a delegate is installed. Returns the
918
+ * removal. Pair it with {@link elements}: the bridge mounts after edits are
919
+ * already possible, so it pushes what it missed first, in order.
920
+ */
921
+ onElement(listener: (element: EditorHostHistoryElement) => void): () => void;
922
+ /** Everything recorded so far, oldest first. */
923
+ elements(): readonly EditorHostHistoryElement[];
924
+ /**
925
+ * THE FOCUSED DOCUMENT'S OWN FILE, project-relative — the first resource a
926
+ * ⌘Z with focus on a vgai stage tries, and the one a refusal names. `null`
927
+ * when nothing is open and the active adapter writes nowhere.
928
+ *
929
+ * It is deliberately NOT the whole answer, because a three root's document
930
+ * spans several files (see {@link EditorHostHistoryElement.document}); the
931
+ * frame falls back to the newest element recorded IN THAT DOCUMENT, which is
932
+ * what keeps a component view's ⌘Z off the main scene's stack — the whole
933
+ * of "Component-view Ctrl+Z acts on the MAIN scene's history" (WORK.md
934
+ * §The core is Code-OSS, U4's absorb list).
935
+ */
936
+ focusedResource(): string | null;
937
+ /** The HOST shape's undo/redo — the editor's own cursor. Under frame
938
+ * ownership these refuse by name; the frame drives elements instead. */
939
+ undo(): Promise<boolean>;
940
+ redo(): Promise<boolean>;
941
+ canUndo(): boolean;
942
+ canRedo(): boolean;
943
+ /** What the next undo/redo would be called, for a menu that shows it. */
944
+ undoLabel(): string | null;
945
+ redoLabel(): string | null;
946
+ subscribe(listener: () => void): () => void;
947
+ }
948
+
949
+ /**
950
+ * THE FILE DOOR — reading and writing the OPEN PROJECT'S OWN FILES, over
951
+ * project-relative paths, with the same two-owner shape as
952
+ * {@link EditorHostKeyboard} and {@link EditorHostHistory}.
953
+ *
954
+ * ARCHITECTURE-CORE §The core is Code-OSS: *"the storage backends → file
955
+ * system providers, the dev server as one provider"* — and the rule above it
956
+ * that governs HOW: **everything VS Code already does is USED, not rebuilt.**
957
+ *
958
+ * ## Why this is a CALL and not a file-system provider (U5, measured)
959
+ *
960
+ * Both product shapes already have a real file service over the project:
961
+ * DESKTOP opens the project folder as the workspace folder on Electron's own
962
+ * disk provider, and WEB + SERVER (the REH) serves the same folder over
963
+ * `vscode-remote://`. A `vgai-session:` provider mounting the session's
964
+ * `/__editor/*` routes would be a SECOND path to bytes the workbench can
965
+ * already reach — more code, a second cache, and two notions of when a file
966
+ * changed. So under the frame this door CALLS `IFileService` (and
967
+ * `ITextFileService` for text), and the session's file routes stay exactly
968
+ * what the STANDALONE shape speaks.
969
+ *
970
+ * ## Why the frame's write is the point (U4's open, closed here)
971
+ *
972
+ * A vgai element's REDO used to be lost while a text model for that file was
973
+ * open: the editor's undo wrote the file through its own transport, which is
974
+ * an EXTERNAL change to the workbench, so Monaco reloaded and
975
+ * `modelService.updateModel` pushed a fresh text element — and `pushElement`
976
+ * destroys the redo future. A write made THROUGH the workbench is the
977
+ * workbench's own: the open model is updated in place and no reload fires, so
978
+ * the future survives. That is the reason this door exists rather than a
979
+ * fourth storage backend.
980
+ *
981
+ * ## Paths
982
+ *
983
+ * PROJECT-RELATIVE, `/`-separated, no leading slash — `src/scenes/Main.tsx`,
984
+ * `public/models/hero.glb` — exactly the spelling
985
+ * {@link EditorHostHistoryElement.resources} uses, and for the same reason:
986
+ * only the frame knows the workspace folder they resolve against
987
+ * (`URI.joinPath(workspaceFolder.uri, path)`), and resolving them there is
988
+ * what lands a write on the same URI Monaco holds for that file.
989
+ *
990
+ * This is deliberately NOT `StorageBackend`'s spelling, which means two
991
+ * different things by tier — measured 2026-09-19: `HttpStorage` is rooted at
992
+ * `<project>/public/` while a project-rooted backend is rooted at the
993
+ * PROJECT ROOT, which is why the session grew four separate purpose-scoped
994
+ * project-root routes beside it (`/__editor/vgai-file`,
995
+ * `/__editor/project-resource`, `/__editor/data-file`,
996
+ * `/__editor/source-files`). One spelling, here.
997
+ */
998
+ export interface EditorHostFiles {
999
+ /**
1000
+ * Install the workbench's file service as the project's files. Called once
1001
+ * its own services exist, which is AFTER the editor mounts — a
1002
+ * `ServicesAccessor` is valid only for the synchronous part of an
1003
+ * invocation, so the provider cannot be built before the mount it is handed
1004
+ * to (docs/CODE-OSS.md records that trap). Until it lands the door falls
1005
+ * back to the session's transports rather than refusing: a write in that
1006
+ * window is a real write with nowhere else to go.
1007
+ */
1008
+ setProvider(provider: EditorHostFileProvider): void;
1009
+ /** Read a UTF-8 text file. Rejects BY NAME if it is missing or a
1010
+ * directory. */
1011
+ read(path: string): Promise<string>;
1012
+ /** Read raw bytes. */
1013
+ readBytes(path: string): Promise<Uint8Array>;
1014
+ /**
1015
+ * Write a file, creating parent directories as needed.
1016
+ *
1017
+ * Under the frame this is the workbench's own write: when a text model is
1018
+ * open for the file it is updated IN PLACE and saved, so no external-change
1019
+ * reload fires and no text undo element lands on top of ours.
1020
+ */
1021
+ write(path: string, data: string | Uint8Array): Promise<void>;
1022
+ exists(path: string): Promise<boolean>;
1023
+ /** Shallow directory listing. */
1024
+ list(dir: string): Promise<readonly EditorHostFileEntry[]>;
1025
+ /** Change events for the project's files. Returns the unsubscribe. */
1026
+ watch(listener: (event: EditorHostFileEvent) => void): () => void;
1027
+ }
1028
+
1029
+ export interface EditorHostFileEntry {
1030
+ /** Base name, no slashes. */
1031
+ readonly name: string;
1032
+ /** Project-relative path. */
1033
+ readonly path: string;
1034
+ readonly type: 'file' | 'dir';
1035
+ }
1036
+
1037
+ export interface EditorHostFileEvent {
1038
+ readonly type: 'create' | 'update' | 'remove';
1039
+ /** Project-relative path. */
1040
+ readonly path: string;
1041
+ }
1042
+
1043
+ /**
1044
+ * THE FRAME'S HALF — what the Code-OSS bridge installs, backed by
1045
+ * `IFileService`/`ITextFileService`. Every member takes the same
1046
+ * project-relative paths the door does; the frame joins them onto the
1047
+ * workspace folder.
1048
+ *
1049
+ * A member the frame cannot answer is ABSENT rather than faked, and the door
1050
+ * falls back to the host transport for it — the anti-shim rule applied to a
1051
+ * file API, because a fabricated listing reads exactly like a real empty
1052
+ * folder.
1053
+ */
1054
+ export interface EditorHostFileProvider {
1055
+ read(path: string): Promise<string>;
1056
+ readBytes?(path: string): Promise<Uint8Array>;
1057
+ write(path: string, data: string | Uint8Array): Promise<void>;
1058
+ exists(path: string): Promise<boolean>;
1059
+ list?(dir: string): Promise<readonly EditorHostFileEntry[]>;
1060
+ watch?(listener: (event: EditorHostFileEvent) => void): () => void;
1061
+ }
1062
+
1063
+ /**
1064
+ * THE STAGE TRANSPORT DOOR — reach the transport of the stage a document runs
1065
+ * on, holding only that document's id.
1066
+ *
1067
+ * Shaped like `documents` beside it: a lookup plus a subscription, because a
1068
+ * stage mounting or unmounting changes what `for` answers and a look drawn
1069
+ * over it must re-render when it does. `null` for a document with no stage of
1070
+ * its own (a tool tab, a text document).
1071
+ */
1072
+ export interface EditorHostTransport {
1073
+ for(documentId: string): StageTransportHandle | null;
1074
+ subscribe(listener: () => void): () => void;
1075
+ }
1076
+
1077
+ /**
1078
+ * The transport vocabulary, re-exported through the host door so a skew
1079
+ * package reaches it without importing the editor's source or an engine
1080
+ * value. See `./transport.ts` for what each member means.
1081
+ */
1082
+ export type {
1083
+ StageTransportHandle,
1084
+ StageTransportSnapshot,
1085
+ TransportPlaybackState,
1086
+ TransportSubject,
1087
+ } from './transport';
1088
+
1089
+ /** Text logs and source diagnostics rendered by the native workbench. */
1090
+ export interface EditorHostOutputDiagnostic {
1091
+ readonly path: string;
1092
+ readonly line: number;
1093
+ readonly column: number;
1094
+ readonly message: string;
1095
+ readonly severity: 'error' | 'warning';
1096
+ }
1097
+
1098
+ export interface EditorHostOutput {
1099
+ /** Replace a named channel's text and its current diagnostics. Paths are project-relative. */
1100
+ write(
1101
+ id: string,
1102
+ label: string,
1103
+ text: string,
1104
+ diagnostics: readonly EditorHostOutputDiagnostic[],
1105
+ ): void;
1106
+ show(id: string): void;
1107
+ }
1108
+
1109
+ export interface EditorHost {
1110
+ readonly output: EditorHostOutput;
1111
+ readonly console: EditorHostConsole;
1112
+ readonly live: EditorHostLive;
1113
+ readonly viewport: EditorHostViewport;
1114
+ readonly hierarchy: EditorHostHierarchy;
1115
+ readonly systems: EditorHostSystems;
1116
+ readonly session: EditorHostSession;
1117
+ readonly project: EditorHostProject;
1118
+ /** Raise a notification in the editor's own tray. Returns the dismiss. */
1119
+ notify(notification: EditorHostNotification): () => void;
1120
+ readonly availability: EditorHostAvailability;
1121
+ readonly workspace: EditorHostWorkspace;
1122
+ readonly documents: EditorHostDocuments;
1123
+ readonly projectLocalState: EditorHostProjectLocalState;
1124
+ readonly keyboard: EditorHostKeyboard;
1125
+ readonly history: EditorHostHistory;
1126
+ readonly files: EditorHostFiles;
1127
+ readonly settings: EditorHostSettings;
1128
+ readonly transport: EditorHostTransport;
1129
+ }
1130
+
1131
+ /**
1132
+ * ONE registration across every copy of this module. Under the packaged
1133
+ * runtime the editor shell is a prebuilt bundle with this SDK inlined, while
1134
+ * a package's contribution is served from the project's own installed SDK —
1135
+ * two module instances, so plain module state would be an empty registry on
1136
+ * the contribution's side (measured 2026-09-17: "No editor host is
1137
+ * registered" from `@vgai/game`'s connection pill on a registry install).
1138
+ * The layout host (`layouts.tsx`) solved the same split with a `Symbol.for`
1139
+ * key on `globalThis`; this door does the same.
1140
+ */
1141
+ const HOST_KEY = Symbol.for('vgai.editor.host');
1142
+ const hosts = globalThis as typeof globalThis & { [HOST_KEY]?: EditorHost | null };
1143
+
1144
+ /** The editor's boot registers itself; `null` unregisters (tests). */
1145
+ export function registerEditorHost(next: EditorHost | null): void {
1146
+ hosts[HOST_KEY] = next;
1147
+ }
1148
+
1149
+ export function editorHost(): EditorHost {
1150
+ const host = hosts[HOST_KEY];
1151
+ if (!host)
1152
+ throw new Error(
1153
+ 'No editor host is registered: this contribution is running outside the editor ' +
1154
+ '(`registerEditorHost` from `@volter/editor-sdk/host` is called by the editor at boot).',
1155
+ );
1156
+ return host;
1157
+ }
1158
+
1159
+ /**
1160
+ * Re-render only when `select()`'s value changes, sampled on the host's
1161
+ * availability tick — the shape every session-state gate in the editor uses,
1162
+ * so a package's status item costs the same as a built-in one.
1163
+ */
1164
+ export function useHostAvailabilitySelector<T>(select: () => T): T {
1165
+ const { availability } = editorHost();
1166
+ return useSyncExternalStore(availability.subscribe, select, select);
1167
+ }