@volter/sdk 0.0.0-stage → 0.5.204

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 (523) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +20 -0
  3. package/README.md +38 -3
  4. package/package.json +510 -4
  5. package/src/account.ts +210 -0
  6. package/src/chrome.ts +88 -0
  7. package/src/client.ts +1646 -0
  8. package/src/commands.ts +66 -0
  9. package/src/contributions.ts +619 -0
  10. package/src/css-numeric-style.ts +97 -0
  11. package/src/document-probe.ts +282 -0
  12. package/src/editor-view.ts +225 -0
  13. package/src/extension.ts +40 -0
  14. package/src/generations.ts +178 -0
  15. package/src/host.ts +1157 -0
  16. package/src/http-transport.browser.ts +14 -0
  17. package/src/http-transport.node.ts +19 -0
  18. package/src/index.ts +131 -0
  19. package/src/kit/CapabilityCoverageSection.tsx +185 -0
  20. package/src/kit/account-client.ts +333 -0
  21. package/src/kit/action-registry.ts +317 -0
  22. package/src/kit/active-product.ts +76 -0
  23. package/src/kit/active-project.ts +155 -0
  24. package/src/kit/adapter-editor-config.ts +25 -0
  25. package/src/kit/adapter-module.ts +7 -0
  26. package/src/kit/adapter-observation.ts +49 -0
  27. package/src/kit/animation/animation-clock.ts +479 -0
  28. package/src/kit/animation/stage-transport.ts +385 -0
  29. package/src/kit/api/assets.ts +365 -0
  30. package/src/kit/api/project-open.ts +355 -0
  31. package/src/kit/api/project-source.ts +180 -0
  32. package/src/kit/api/project-state.ts +110 -0
  33. package/src/kit/api/relay.ts +270 -0
  34. package/src/kit/api/themes.ts +45 -0
  35. package/src/kit/api-asset-library-wire.ts +45 -0
  36. package/src/kit/api-base.ts +10 -0
  37. package/src/kit/api-build.ts +99 -0
  38. package/src/kit/api-git-wire.ts +56 -0
  39. package/src/kit/api-logs.ts +92 -0
  40. package/src/kit/api-project-identity.ts +74 -0
  41. package/src/kit/api-settings.ts +36 -0
  42. package/src/kit/api-worktrees.ts +205 -0
  43. package/src/kit/asset-capabilities.ts +344 -0
  44. package/src/kit/asset-compare-core.ts +171 -0
  45. package/src/kit/asset-editor-context.tsx +101 -0
  46. package/src/kit/asset-events.ts +96 -0
  47. package/src/kit/asset-inspector-actions.ts +87 -0
  48. package/src/kit/asset-selection-viewer-registry.ts +113 -0
  49. package/src/kit/asset-selection.ts +146 -0
  50. package/src/kit/asset-thumbnails.ts +25 -0
  51. package/src/kit/asset-viewers.ts +115 -0
  52. package/src/kit/asset-workflow/asset-import-jobs.ts +106 -0
  53. package/src/kit/asset-workflow/asset-ledger-backend.ts +126 -0
  54. package/src/kit/asset-workflow/asset-ledger.ts +156 -0
  55. package/src/kit/asset-workflow/asset-materialization-report.ts +140 -0
  56. package/src/kit/asset-workflow/asset-pack-manifest.ts +320 -0
  57. package/src/kit/asset-workflow/asset-types.ts +142 -0
  58. package/src/kit/asset-workflow/audio-preview-player.ts +193 -0
  59. package/src/kit/asset-workflow/audio-waveform.ts +22 -0
  60. package/src/kit/asset-workflow/cloud-asset-client.ts +263 -0
  61. package/src/kit/asset-workflow/hosted-asset-materialization.ts +236 -0
  62. package/src/kit/asset-workflow/image-view-scale.ts +31 -0
  63. package/src/kit/asset-workflow/import-contract.ts +124 -0
  64. package/src/kit/asset-workflow/ledger-write-lock.ts +244 -0
  65. package/src/kit/asset-workflow/pixi-spritesheet.ts +197 -0
  66. package/src/kit/asset-workflow/preview-resource-lifetime.ts +44 -0
  67. package/src/kit/asset-workflow/project-asset-commands.ts +20 -0
  68. package/src/kit/asset-workflow/project-content.ts +288 -0
  69. package/src/kit/asset-workflow/project-source-index.ts +545 -0
  70. package/src/kit/asset-workflow/thumbnail-system.ts +256 -0
  71. package/src/kit/authoring/active-adapter.ts +200 -0
  72. package/src/kit/authoring/active-systems.ts +422 -0
  73. package/src/kit/authoring/adapter-key.ts +18 -0
  74. package/src/kit/authoring/authoring-asset-url.ts +27 -0
  75. package/src/kit/authoring/bootstrap-state.ts +49 -0
  76. package/src/kit/authoring/boundary-authoring-adapter.ts +189 -0
  77. package/src/kit/authoring/canvas-scene-guides.ts +84 -0
  78. package/src/kit/authoring/composite-authoring-adapter.ts +2109 -0
  79. package/src/kit/authoring/consumer-actions.ts +531 -0
  80. package/src/kit/authoring/design-time-layers.ts +852 -0
  81. package/src/kit/authoring/design-time-mount-registry.ts +244 -0
  82. package/src/kit/authoring/edit-mode-authoring.ts +637 -0
  83. package/src/kit/authoring/empty-project-authoring.ts +22 -0
  84. package/src/kit/authoring/instance-source-menu.ts +135 -0
  85. package/src/kit/authoring/layered-pick.ts +185 -0
  86. package/src/kit/authoring/mounted-root-subjects.ts +146 -0
  87. package/src/kit/authoring/no-authoring-adapter.ts +59 -0
  88. package/src/kit/authoring/object3d-document-persistence.ts +122 -0
  89. package/src/kit/authoring/panel-authoring.ts +121 -0
  90. package/src/kit/authoring/project-authoring-session.ts +105 -0
  91. package/src/kit/authoring/provenance.ts +99 -0
  92. package/src/kit/authoring/react-canvas-navigation.ts +259 -0
  93. package/src/kit/authoring/react-design-canvas-style.ts +20 -0
  94. package/src/kit/authoring/react-story-board.ts +917 -0
  95. package/src/kit/authoring/selection-scope.ts +195 -0
  96. package/src/kit/authoring/shell-document-ops.ts +169 -0
  97. package/src/kit/authoring/story-board-chrome-fit.ts +107 -0
  98. package/src/kit/authoring/story-board-presentation.ts +111 -0
  99. package/src/kit/authoring/three-root.ts +67 -0
  100. package/src/kit/authoring/viewport-tool-context.ts +73 -0
  101. package/src/kit/authoring/viewport-tool-owner.ts +38 -0
  102. package/src/kit/authoring/world-session-state.ts +101 -0
  103. package/src/kit/authoring-seam-evidence.ts +300 -0
  104. package/src/kit/availability-tick.ts +66 -0
  105. package/src/kit/bitmap-label.ts +120 -0
  106. package/src/kit/boot-routing.ts +392 -0
  107. package/src/kit/breakpoint-state.ts +43 -0
  108. package/src/kit/build-identity.ts +16 -0
  109. package/src/kit/bytes-codec.ts +62 -0
  110. package/src/kit/cancellation-reason.ts +58 -0
  111. package/src/kit/canvas-frames.ts +88 -0
  112. package/src/kit/capture-camera-pose.ts +77 -0
  113. package/src/kit/capture-size.ts +88 -0
  114. package/src/kit/chrome-registry.ts +159 -0
  115. package/src/kit/chrome-slot-registry.ts +91 -0
  116. package/src/kit/collaboration-client.ts +264 -0
  117. package/src/kit/collaboration-presence.ts +41 -0
  118. package/src/kit/command-dispatch.ts +19 -0
  119. package/src/kit/command-listener.ts +2182 -0
  120. package/src/kit/command-registry.ts +71 -0
  121. package/src/kit/component-board-registry.ts +205 -0
  122. package/src/kit/component-states-registry.ts +199 -0
  123. package/src/kit/components/AlignToolbar.tsx +204 -0
  124. package/src/kit/components/ApplicationMenus.tsx +372 -0
  125. package/src/kit/components/AssetEditorShell.tsx +216 -0
  126. package/src/kit/components/AssetInspectorToolSection.tsx +124 -0
  127. package/src/kit/components/BoardRulers.tsx +354 -0
  128. package/src/kit/components/CanvasAddNodeDialogs.tsx +529 -0
  129. package/src/kit/components/CanvasSceneViewport.tsx +1195 -0
  130. package/src/kit/components/ChromeSlot.tsx +20 -0
  131. package/src/kit/components/CodeView.tsx +470 -0
  132. package/src/kit/components/CompactInspectorShell.tsx +39 -0
  133. package/src/kit/components/ConsolePanel.tsx +273 -0
  134. package/src/kit/components/GameplaySessionTimeline.tsx +295 -0
  135. package/src/kit/components/InspectionProjection.tsx +932 -0
  136. package/src/kit/components/Inspector.tsx +270 -0
  137. package/src/kit/components/InspectorCanvasPreview.tsx +35 -0
  138. package/src/kit/components/InspectorFieldsSection.tsx +290 -0
  139. package/src/kit/components/InspectorStoriesSection.tsx +92 -0
  140. package/src/kit/components/InspectorToolSection.tsx +96 -0
  141. package/src/kit/components/InspectorTransformSection.tsx +245 -0
  142. package/src/kit/components/LightExplorerPanel.tsx +433 -0
  143. package/src/kit/components/MediaProperties.tsx +145 -0
  144. package/src/kit/components/ProjectHeader.tsx +328 -0
  145. package/src/kit/components/ReactCanvasControls.tsx +358 -0
  146. package/src/kit/components/RootSelectionOverlay.tsx +3688 -0
  147. package/src/kit/components/RootTextEditor.tsx +79 -0
  148. package/src/kit/components/SaveStatus.tsx +70 -0
  149. package/src/kit/components/SurfaceCrashBoundary.tsx +105 -0
  150. package/src/kit/components/SurfaceStateOverlay.tsx +24 -0
  151. package/src/kit/components/ToolContributionSurfaces.tsx +49 -0
  152. package/src/kit/components/ToolHost.tsx +380 -0
  153. package/src/kit/components/Toolbar.tsx +811 -0
  154. package/src/kit/components/TransientHint.tsx +44 -0
  155. package/src/kit/components/VersionControlSection.tsx +470 -0
  156. package/src/kit/components/ViewportOverlaysMenu.tsx +177 -0
  157. package/src/kit/components/VolterLogo.tsx +18 -0
  158. package/src/kit/components/WorktreeSwitcher.tsx +712 -0
  159. package/src/kit/components/account-documents.tsx +1162 -0
  160. package/src/kit/components/asset-documents.tsx +794 -0
  161. package/src/kit/components/asset-editor-persistence.ts +216 -0
  162. package/src/kit/components/asset-selection-section.tsx +545 -0
  163. package/src/kit/components/asset-thumbnails.tsx +307 -0
  164. package/src/kit/components/asset-viewers/AudioViewer.tsx +201 -0
  165. package/src/kit/components/asset-viewers/GenericJsonViewer.tsx +102 -0
  166. package/src/kit/components/asset-viewers/ImageViewer.tsx +300 -0
  167. package/src/kit/components/asset-viewers/JsonAssetDocument.tsx +98 -0
  168. package/src/kit/components/asset-viewers/OnlineAssetDetail.tsx +426 -0
  169. package/src/kit/components/asset-viewers/SourceAssetViewer.tsx +356 -0
  170. package/src/kit/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
  171. package/src/kit/components/asset-viewers/VideoViewer.tsx +101 -0
  172. package/src/kit/components/asset-viewers/shader-source.ts +144 -0
  173. package/src/kit/components/board-guides.ts +150 -0
  174. package/src/kit/components/canvas-scene-hotkeys.ts +37 -0
  175. package/src/kit/components/canvas-temporary-pivot.ts +34 -0
  176. package/src/kit/components/core-utilities.tsx +94 -0
  177. package/src/kit/components/inspector-preview-section.tsx +223 -0
  178. package/src/kit/components/inspector-revert-label.ts +20 -0
  179. package/src/kit/components/inspector-selection.ts +42 -0
  180. package/src/kit/components/inspector-stories-gating.ts +171 -0
  181. package/src/kit/components/inspector-transform-subject.ts +11 -0
  182. package/src/kit/components/inspector-transform.ts +88 -0
  183. package/src/kit/components/kind-documents.tsx +544 -0
  184. package/src/kit/components/primitives/DraftColorInput.tsx +74 -0
  185. package/src/kit/components/project-tool-documents.tsx +402 -0
  186. package/src/kit/components/scene-documents.tsx +221 -0
  187. package/src/kit/components/status-contributions.tsx +407 -0
  188. package/src/kit/components/tool-documents.tsx +302 -0
  189. package/src/kit/components/tool-schema-form.tsx +262 -0
  190. package/src/kit/components/use-after-paint.ts +41 -0
  191. package/src/kit/components/use-project-image-assets.ts +86 -0
  192. package/src/kit/components/workspace-history.ts +32 -0
  193. package/src/kit/components/world-documents.tsx +570 -0
  194. package/src/kit/components/world-overlay-gestures.ts +1939 -0
  195. package/src/kit/composite-screenshot.ts +2238 -0
  196. package/src/kit/content-entry-source-registry.ts +184 -0
  197. package/src/kit/contribution-surfaces.ts +48 -0
  198. package/src/kit/coverage/canvas-reveal.ts +192 -0
  199. package/src/kit/coverage/design-time-surfaces.ts +101 -0
  200. package/src/kit/coverage/ontology-invariants.ts +466 -0
  201. package/src/kit/coverage/session-vitals.ts +503 -0
  202. package/src/kit/crash-null-boundary.ts +36 -0
  203. package/src/kit/creation-site-edit.ts +1491 -0
  204. package/src/kit/creation-site-registry.ts +160 -0
  205. package/src/kit/delegate-harness-registry.ts +134 -0
  206. package/src/kit/document-areas.ts +70 -0
  207. package/src/kit/document-context-registry.ts +193 -0
  208. package/src/kit/document-open-registry.ts +200 -0
  209. package/src/kit/document-play-extension.ts +221 -0
  210. package/src/kit/document-preview-source.ts +20 -0
  211. package/src/kit/document-renderer-session.ts +138 -0
  212. package/src/kit/document-stage-sessions.ts +26 -0
  213. package/src/kit/document-viewports.ts +120 -0
  214. package/src/kit/editor-api.ts +46 -0
  215. package/src/kit/editor-chrome-capture.ts +136 -0
  216. package/src/kit/editor-commands.ts +176 -0
  217. package/src/kit/editor-console.ts +580 -0
  218. package/src/kit/editor-current-view.ts +56 -0
  219. package/src/kit/editor-document-probe.ts +1168 -0
  220. package/src/kit/editor-git-client.ts +115 -0
  221. package/src/kit/editor-hotkeys.ts +728 -0
  222. package/src/kit/editor-lease-view.ts +39 -0
  223. package/src/kit/editor-lease.ts +415 -0
  224. package/src/kit/editor-mode.ts +19 -0
  225. package/src/kit/editor-notifications.ts +140 -0
  226. package/src/kit/editor-presence.ts +563 -0
  227. package/src/kit/editor-presentation-activity.ts +58 -0
  228. package/src/kit/editor-presentation-notice.ts +42 -0
  229. package/src/kit/editor-runtime.tsx +147 -0
  230. package/src/kit/editor-server-response.ts +86 -0
  231. package/src/kit/editor-session-attribution.ts +85 -0
  232. package/src/kit/editor-session-mode.ts +54 -0
  233. package/src/kit/editor-state-facets.ts +74 -0
  234. package/src/kit/editor-view-presentation.ts +777 -0
  235. package/src/kit/environment-images.ts +58 -0
  236. package/src/kit/eyedropper-session.ts +60 -0
  237. package/src/kit/files/file-provider.ts +62 -0
  238. package/src/kit/files/project-files.ts +270 -0
  239. package/src/kit/finders/index.ts +137 -0
  240. package/src/kit/finders/scenes-from-entrypoint-selection.ts +387 -0
  241. package/src/kit/frame/frame-parts.ts +30 -0
  242. package/src/kit/framed-document-capture.ts +34 -0
  243. package/src/kit/game-globals-prelude.ts +143 -0
  244. package/src/kit/game-surface-defaults.ts +33 -0
  245. package/src/kit/gameplay-dom-recording.ts +318 -0
  246. package/src/kit/gameplay-export-state.ts +14 -0
  247. package/src/kit/gameplay-replay.ts +417 -0
  248. package/src/kit/gameplay-session-time.ts +9 -0
  249. package/src/kit/gameplay-sessions.ts +204 -0
  250. package/src/kit/hierarchy-component-marks.ts +298 -0
  251. package/src/kit/hierarchy-internals.ts +197 -0
  252. package/src/kit/hierarchy-kind-icon.ts +217 -0
  253. package/src/kit/hierarchy-menu-registry.ts +67 -0
  254. package/src/kit/hierarchy-node-rows.ts +307 -0
  255. package/src/kit/hierarchy-panel-view.ts +280 -0
  256. package/src/kit/hierarchy-projection.ts +76 -0
  257. package/src/kit/hierarchy-row-media.ts +45 -0
  258. package/src/kit/hierarchy-row-model.ts +308 -0
  259. package/src/kit/hierarchy-rows.ts +11 -0
  260. package/src/kit/hierarchy-walk.ts +86 -0
  261. package/src/kit/history/editor-session.ts +25 -0
  262. package/src/kit/history/history-commands.ts +147 -0
  263. package/src/kit/history/history-delegate.ts +187 -0
  264. package/src/kit/history/history-limit-notices.ts +43 -0
  265. package/src/kit/history/history-service.ts +1189 -0
  266. package/src/kit/history/persistence-coordinator.ts +35 -0
  267. package/src/kit/history/project-file-history.ts +386 -0
  268. package/src/kit/history/project-root-history-backends.ts +139 -0
  269. package/src/kit/history/resource-registry.ts +209 -0
  270. package/src/kit/history/snapshot-store.ts +103 -0
  271. package/src/kit/history/source-history-backend.ts +546 -0
  272. package/src/kit/history-types.ts +124 -0
  273. package/src/kit/hmr-registration-group.ts +67 -0
  274. package/src/kit/hmr-stable-react-context.ts +23 -0
  275. package/src/kit/hotkeys.ts +190 -0
  276. package/src/kit/inference-diagnostics.ts +69 -0
  277. package/src/kit/initial-project.ts +80 -0
  278. package/src/kit/inspection/active-subject.ts +571 -0
  279. package/src/kit/inspection/active-surface.ts +142 -0
  280. package/src/kit/inspection/compose-subject.ts +1055 -0
  281. package/src/kit/inspection/compose.ts +7 -0
  282. package/src/kit/inspection/display.ts +171 -0
  283. package/src/kit/inspection/document-subject.ts +109 -0
  284. package/src/kit/inspection/game-subject.ts +85 -0
  285. package/src/kit/inspection/null-subject.ts +119 -0
  286. package/src/kit/inspection/serialize.ts +357 -0
  287. package/src/kit/inspection/use-active-inspection.ts +180 -0
  288. package/src/kit/inspection-model.ts +542 -0
  289. package/src/kit/inspection-node-media.ts +58 -0
  290. package/src/kit/inspector-presentation.ts +203 -0
  291. package/src/kit/inspector-property-grouping.ts +64 -0
  292. package/src/kit/inspector-section-registry.ts +221 -0
  293. package/src/kit/instance-source-actions.ts +163 -0
  294. package/src/kit/js-heap.ts +71 -0
  295. package/src/kit/key-actions.ts +91 -0
  296. package/src/kit/keymap-presets.ts +428 -0
  297. package/src/kit/layout-policy.ts +31 -0
  298. package/src/kit/light-explorer-model.ts +134 -0
  299. package/src/kit/live-canvas-frame.ts +55 -0
  300. package/src/kit/live-document.ts +296 -0
  301. package/src/kit/live-gesture-lock.ts +50 -0
  302. package/src/kit/live-seam-evidence.ts +11 -0
  303. package/src/kit/live-session-registry.ts +220 -0
  304. package/src/kit/live-transition.ts +391 -0
  305. package/src/kit/manifest-project.ts +107 -0
  306. package/src/kit/module-fetch-diagnosis.ts +192 -0
  307. package/src/kit/mount-failure-report.ts +154 -0
  308. package/src/kit/native-selection-style.ts +497 -0
  309. package/src/kit/object3d-document-write-policy.ts +137 -0
  310. package/src/kit/packaged-runtime.ts +108 -0
  311. package/src/kit/palettes/maya.palette.json +57 -0
  312. package/src/kit/palettes/substance.palette.json +57 -0
  313. package/src/kit/performance-profiler.ts +367 -0
  314. package/src/kit/performance-sources.ts +69 -0
  315. package/src/kit/photograph-notice.ts +141 -0
  316. package/src/kit/play-boot-phase.ts +166 -0
  317. package/src/kit/play-camera-flight.ts +35 -0
  318. package/src/kit/png-encode.worker.ts +26 -0
  319. package/src/kit/presentation-surface.ts +248 -0
  320. package/src/kit/product-command.ts +90 -0
  321. package/src/kit/project-adapter.ts +1140 -0
  322. package/src/kit/project-asset-refresh.ts +23 -0
  323. package/src/kit/project-asset-roots.ts +68 -0
  324. package/src/kit/project-local-state.ts +151 -0
  325. package/src/kit/project-manager.ts +243 -0
  326. package/src/kit/project-module-changes.ts +201 -0
  327. package/src/kit/project-module-split.ts +270 -0
  328. package/src/kit/project-play-layers.ts +25 -0
  329. package/src/kit/project-provenance.ts +115 -0
  330. package/src/kit/project-ready.ts +42 -0
  331. package/src/kit/project-shape.ts +68 -0
  332. package/src/kit/project-tools.ts +107 -0
  333. package/src/kit/projection-types.ts +44 -0
  334. package/src/kit/readiness.ts +113 -0
  335. package/src/kit/renderer-resource-counts.ts +27 -0
  336. package/src/kit/reported-play-state.ts +90 -0
  337. package/src/kit/resolve-contributed-command.ts +14 -0
  338. package/src/kit/resolve-relative-specifier.ts +33 -0
  339. package/src/kit/retained-document-states.ts +91 -0
  340. package/src/kit/scene-document-plan.ts +320 -0
  341. package/src/kit/scene-live-open.ts +210 -0
  342. package/src/kit/scoped-game-css.ts +152 -0
  343. package/src/kit/served-url.ts +5 -0
  344. package/src/kit/session-close.ts +17 -0
  345. package/src/kit/session-tombstone.ts +127 -0
  346. package/src/kit/settings/settings-provider.ts +82 -0
  347. package/src/kit/settings-store.ts +348 -0
  348. package/src/kit/shell-document-state.ts +27 -0
  349. package/src/kit/shell-store-door.ts +45 -0
  350. package/src/kit/shell-store.ts +722 -0
  351. package/src/kit/source-conflict.ts +122 -0
  352. package/src/kit/stage-context.ts +377 -0
  353. package/src/kit/stage-invalidation.ts +25 -0
  354. package/src/kit/stage-store-registry.ts +69 -0
  355. package/src/kit/startup-failure.ts +80 -0
  356. package/src/kit/state-report-deferral.ts +73 -0
  357. package/src/kit/storage/host-files-storage.ts +97 -0
  358. package/src/kit/storage/http-storage.ts +174 -0
  359. package/src/kit/storage/index.ts +75 -0
  360. package/src/kit/storage/mem-storage.ts +158 -0
  361. package/src/kit/storage/path-lock.ts +44 -0
  362. package/src/kit/storage/paths.ts +26 -0
  363. package/src/kit/storage-types.ts +127 -0
  364. package/src/kit/stories/StoryPreviewMount.tsx +306 -0
  365. package/src/kit/stories/compose-project-stories.ts +255 -0
  366. package/src/kit/stories/prefabs-finder.ts +54 -0
  367. package/src/kit/stories/prefabs-from-stories.ts +182 -0
  368. package/src/kit/stories/project-story-regions.ts +24 -0
  369. package/src/kit/stories/story-capture.ts +579 -0
  370. package/src/kit/stories/story-declared-medium.ts +126 -0
  371. package/src/kit/stories/story-discovery.ts +176 -0
  372. package/src/kit/stories/story-dom-runtime.ts +78 -0
  373. package/src/kit/stories/story-grouping.ts +111 -0
  374. package/src/kit/stories/story-mount-turn.ts +27 -0
  375. package/src/kit/stories/story-presentation.ts +215 -0
  376. package/src/kit/stories/story-preview-component.ts +7 -0
  377. package/src/kit/stories/story-registry.ts +530 -0
  378. package/src/kit/stories-scope.ts +35 -0
  379. package/src/kit/story-document-openers.ts +36 -0
  380. package/src/kit/story-thumbnails.ts +47 -0
  381. package/src/kit/surface-keyboard.ts +101 -0
  382. package/src/kit/surface-state.ts +135 -0
  383. package/src/kit/system-seam-evidence.ts +72 -0
  384. package/src/kit/tab-census.ts +202 -0
  385. package/src/kit/tab-lifecycle-client.ts +227 -0
  386. package/src/kit/theme-library.ts +897 -0
  387. package/src/kit/theme-preference.ts +429 -0
  388. package/src/kit/three-viewport-presentation.ts +23 -0
  389. package/src/kit/tool-contribution-play.ts +74 -0
  390. package/src/kit/tool-loader.ts +1918 -0
  391. package/src/kit/transform-mode-request.ts +66 -0
  392. package/src/kit/transient-hint.ts +78 -0
  393. package/src/kit/transport-strip.tsx +174 -0
  394. package/src/kit/ui-source/adapter-region-includes.ts +238 -0
  395. package/src/kit/ui-source/file-region-resolver.ts +302 -0
  396. package/src/kit/ui-source/inspect.ts +775 -0
  397. package/src/kit/ui-source/source-write-backend.ts +605 -0
  398. package/src/kit/ui-source/tier-source-write-backend.ts +279 -0
  399. package/src/kit/user-local-state.ts +105 -0
  400. package/src/kit/viewport-activation-timings.ts +840 -0
  401. package/src/kit/viewport-editor-controls.ts +22 -0
  402. package/src/kit/viewport-presentation.ts +668 -0
  403. package/src/kit/viewport-surface-status.tsx +55 -0
  404. package/src/kit/wait-until.ts +37 -0
  405. package/src/kit/worker-call-metrics.ts +166 -0
  406. package/src/kit/workspace-areas.ts +191 -0
  407. package/src/kit/workspace-aux-commands.ts +11 -0
  408. package/src/kit/workspace-available-documents.ts +142 -0
  409. package/src/kit/workspace-core-utilities.ts +31 -0
  410. package/src/kit/workspace-document-ids.ts +59 -0
  411. package/src/kit/workspace-document-registry.ts +624 -0
  412. package/src/kit/workspace-document-restore.ts +146 -0
  413. package/src/kit/workspace-host-commands.ts +141 -0
  414. package/src/kit/workspace-persistence-gate.ts +40 -0
  415. package/src/kit/workspace-play-utilities.ts +44 -0
  416. package/src/kit/workspace-presets.ts +446 -0
  417. package/src/kit/workspace-regions.ts +276 -0
  418. package/src/kit/workspace-static-panels.ts +73 -0
  419. package/src/kit/workspace-status-registry.ts +121 -0
  420. package/src/kit/workspace-storage.ts +35 -0
  421. package/src/kit/workspace-style.ts +226 -0
  422. package/src/kit/workspace-utility-commands.ts +74 -0
  423. package/src/kit/workspace-utility-registry.ts +263 -0
  424. package/src/kit/world-adoption-event.ts +23 -0
  425. package/src/kit/world-adoption.ts +115 -0
  426. package/src/kit/world-canvas-viewport-state.ts +35 -0
  427. package/src/kit/world-document-routing.ts +104 -0
  428. package/src/kit/world-pan-state.ts +198 -0
  429. package/src/kit/write-pipe.ts +173 -0
  430. package/src/layout-arrangements.ts +5 -0
  431. package/src/layouts.tsx +108 -0
  432. package/src/looks.ts +16 -0
  433. package/src/project/output-roots.ts +73 -0
  434. package/src/project/tab-census.ts +155 -0
  435. package/src/project-tool-catalog.ts +104 -0
  436. package/src/selection.tsx +107 -0
  437. package/src/services.ts +18 -0
  438. package/src/session/build-report.ts +22 -0
  439. package/src/session/collaboration-types.ts +262 -0
  440. package/src/session/command-table.ts +327 -0
  441. package/src/session/discovery.ts +100 -0
  442. package/src/session/editor-brand.ts +48 -0
  443. package/src/session/editor-compatibility.ts +329 -0
  444. package/src/session/editor-control-lifecycle.ts +68 -0
  445. package/src/session/editor-control-protocol.ts +5 -0
  446. package/src/session/entrypoint-selection-readers.ts +66 -0
  447. package/src/session/entrypoint-selection-source.ts +120 -0
  448. package/src/session/game-css-scope.ts +30 -0
  449. package/src/session/hosted-attachment.ts +225 -0
  450. package/src/session/limited-view.ts +82 -0
  451. package/src/session/product-create.ts +24 -0
  452. package/src/session/product-locator.ts +478 -0
  453. package/src/session/project-module-url.ts +242 -0
  454. package/src/session/project-serving.ts +164 -0
  455. package/src/session/project-upgrade.ts +669 -0
  456. package/src/session/registry-format.ts +210 -0
  457. package/src/session/relative-path-guard.ts +56 -0
  458. package/src/session/scoped-game-css.ts +461 -0
  459. package/src/session/source-glob.ts +15 -0
  460. package/src/session/tool-contribution-convention.ts +123 -0
  461. package/src/session/workbench-locator.ts +712 -0
  462. package/src/session.ts +41 -0
  463. package/src/share.ts +160 -0
  464. package/src/source-analysis.ts +28 -0
  465. package/src/source-authoring.ts +439 -0
  466. package/src/tools/errors.ts +91 -0
  467. package/src/tools/provider-execution.ts +70 -0
  468. package/src/tools/registry.ts +341 -0
  469. package/src/tools/types.ts +159 -0
  470. package/src/transport.ts +100 -0
  471. package/src/types.ts +1693 -0
  472. package/src/views.ts +164 -0
  473. package/src/widgets/design-system.ts +93 -0
  474. package/src/widgets/editor-appearance.ts +151 -0
  475. package/src/widgets/editor-material.ts +83 -0
  476. package/src/widgets/icon-set-registry.ts +105 -0
  477. package/src/widgets/index.ts +71 -0
  478. package/src/widgets/inspector-widgets/AlignmentGrid.tsx +182 -0
  479. package/src/widgets/inspector-widgets/AssetSlotPicker.tsx +123 -0
  480. package/src/widgets/inspector-widgets/BorderEditor.tsx +309 -0
  481. package/src/widgets/inspector-widgets/ColorPicker.tsx +549 -0
  482. package/src/widgets/inspector-widgets/CurveEditor.tsx +359 -0
  483. package/src/widgets/inspector-widgets/FilterEditor.tsx +108 -0
  484. package/src/widgets/inspector-widgets/FontPicker.tsx +191 -0
  485. package/src/widgets/inspector-widgets/GradientEditor.tsx +623 -0
  486. package/src/widgets/inspector-widgets/ScrubbableInput.tsx +180 -0
  487. package/src/widgets/inspector-widgets/ShadowEditor.tsx +319 -0
  488. package/src/widgets/inspector-widgets/color-utils.ts +201 -0
  489. package/src/widgets/inspector-widgets/curve-utils.ts +212 -0
  490. package/src/widgets/inspector-widgets/index.ts +25 -0
  491. package/src/widgets/inspector-widgets/shared.tsx +140 -0
  492. package/src/widgets/interactive-edit-scope.ts +33 -0
  493. package/src/widgets/patterns/Dialog.tsx +140 -0
  494. package/src/widgets/patterns/Fields.tsx +44 -0
  495. package/src/widgets/patterns/List.tsx +25 -0
  496. package/src/widgets/patterns/StateSurface.tsx +40 -0
  497. package/src/widgets/patterns/Surfaces.tsx +122 -0
  498. package/src/widgets/patterns/Tabs.tsx +80 -0
  499. package/src/widgets/patterns/Toolbar.tsx +72 -0
  500. package/src/widgets/patterns/Tree.tsx +72 -0
  501. package/src/widgets/primitives/AnchoredMenu.tsx +260 -0
  502. package/src/widgets/primitives/Button.tsx +62 -0
  503. package/src/widgets/primitives/ColorInput.tsx +78 -0
  504. package/src/widgets/primitives/DraftTextInput.tsx +63 -0
  505. package/src/widgets/primitives/EditorIcon.tsx +157 -0
  506. package/src/widgets/primitives/FormControls.tsx +88 -0
  507. package/src/widgets/primitives/HoverPreview.tsx +96 -0
  508. package/src/widgets/primitives/JsonInput.tsx +113 -0
  509. package/src/widgets/primitives/Layout.tsx +100 -0
  510. package/src/widgets/primitives/Menu.tsx +161 -0
  511. package/src/widgets/primitives/NumberInput.tsx +169 -0
  512. package/src/widgets/primitives/Panel.tsx +80 -0
  513. package/src/widgets/primitives/SectionHeader.tsx +77 -0
  514. package/src/widgets/primitives/Text.tsx +54 -0
  515. package/src/widgets/primitives/ThemeRootPortal.tsx +52 -0
  516. package/src/widgets/primitives/Tooltip.tsx +204 -0
  517. package/src/widgets/primitives/Vec3Input.tsx +70 -0
  518. package/src/widgets/primitives/banner-tones.ts +32 -0
  519. package/src/widgets/primitives/clamp-to-viewport.ts +44 -0
  520. package/src/widgets/primitives/editor-icons.ts +254 -0
  521. package/src/widgets/primitives/panel-header-styles.ts +42 -0
  522. package/src/widgets/theme.ts +2841 -0
  523. package/src/widgets/z-index.ts +25 -0
@@ -0,0 +1,2182 @@
1
+ import { captureSizeFromCommand } from '@volter/sdk/kit/capture-size';
2
+ import { getProjectStoryModules } from '@volter/sdk/kit/stories/story-registry';
3
+ import {
4
+ applyViewPreset,
5
+ setViewPresentation,
6
+ studioPresets,
7
+ viewPresentation,
8
+ viewPresets,
9
+ viewPresentationBinding,
10
+ boundViewPresentations,
11
+ viewDrawReport,
12
+ type PresentationLayer,
13
+ } from '@volter/sdk/kit/viewport-presentation';
14
+ import { environmentImages } from '@volter/sdk/kit/environment-images';
15
+ import { documentViewport } from '@volter/sdk/kit/document-viewports';
16
+ import { inspectionNodeMedia } from '@volter/sdk/kit/inspection-node-media';
17
+ import { isGameplayExportActive } from './gameplay-export-state';
18
+
19
+ /**
20
+ * Command listener — receives commands from the editor server via SSE
21
+ * and dispatches them to the ShellStore and play-mode functions.
22
+ *
23
+ * The server broadcasts `editor-command` events sent by the SDK/CLI.
24
+ * This module translates them into store method calls.
25
+ */
26
+
27
+ import type {
28
+ DocumentProbeStep,
29
+ EditorView,
30
+ InspectedHierarchy,
31
+ InspectedInspection,
32
+ InspectedWriteDestination,
33
+ HelperVisibility as SdkHelperVisibility,
34
+ } from '@volter/sdk';
35
+ import { type AssetKind, setSelectedAsset } from '@volter/sdk/kit/asset-selection';
36
+ import { assetCapabilities, assetDocumentKind } from '@volter/sdk/kit/asset-capabilities';
37
+ import { systemsForInstance } from '@volter/sdk/kit/authoring/active-systems';
38
+ import { getMountFailureReports } from '@volter/sdk/kit/mount-failure-report';
39
+ import {
40
+ activeDocumentAuthoring,
41
+ activeHierarchyRows,
42
+ activeSaveDestination,
43
+ activeSaveState,
44
+ activeSelectionCreationSite,
45
+ activeSelectionIds,
46
+ activeSelectionWriteAnchorKind,
47
+ } from '@volter/sdk/kit/authoring/shell-document-ops';
48
+ import { noteCommandDispatched } from './command-dispatch';
49
+ import { contributedCommandDerivedRefresh } from '@volter/sdk/kit/command-registry';
50
+ import { resolveContributedCommand } from './resolve-contributed-command';
51
+ import {
52
+ isAssetDocumentId,
53
+ openAssetDocument,
54
+ waitForAssetDocumentInspector,
55
+ } from '@volter/sdk/kit/components/asset-documents';
56
+ import { openSceneTableEntryWhenListed } from './components/scene-documents';
57
+ import { systemAdapterEpoch } from '@volter/sdk/kit/system-seam-evidence';
58
+ import {
59
+ connectEvents,
60
+ reportCommandListener,
61
+ reportCommandReceived,
62
+ reportCommandResult,
63
+ reportEditorState,
64
+ reportPlayBootPhase,
65
+ } from '@volter/sdk/kit/editor-api';
66
+ import { captureEditorChrome } from './editor-chrome-capture';
67
+ import type { ConsoleEntry } from '@volter/sdk/kit/editor-console';
68
+ import { editorConsole } from '@volter/sdk/kit/editor-console';
69
+ import { currentEditorView } from './editor-current-view';
70
+ import { editorIsPlaying } from '@volter/sdk/kit/editor-session-mode';
71
+ import type { HelperVisibility } from '@volter/sdk/kit/shell-store';
72
+ import type { ShellStore } from '@volter/sdk/kit/shell-store';
73
+ import { collectEditorStateFacets, reusableFacetKeys } from '@volter/sdk/kit/editor-state-facets';
74
+ import {
75
+ hierarchyPanelSnapshot,
76
+ nextHierarchyPanelSnapshot,
77
+ serializeHierarchyPanel,
78
+ } from './hierarchy-panel-view';
79
+ import { announcePhotograph, withdrawPhotograph } from '@volter/sdk/kit/photograph-notice';
80
+ import type { HistoryCommands } from '@volter/sdk/kit/history/history-commands';
81
+ import {
82
+ anyLiveSessionMounted,
83
+ anyLiveSessionPlaying,
84
+ dispatchLiveCommand,
85
+ liveRunWindow,
86
+ liveScenes,
87
+ remountLiveSelection,
88
+ subscribeLiveSessions,
89
+ } from '@volter/sdk/kit/live-session-registry';
90
+ import { setPlayBootPhaseReporter } from '@volter/sdk/kit/play-boot-phase';
91
+ import { projectAdapterFacet, subscribeProjectAdapter } from '@volter/sdk/kit/project-adapter';
92
+ import { getProjectModuleSplitReports } from '@volter/sdk/kit/project-module-split';
93
+ import { onSessionEndedChange, sessionEndedRefusal, sessionEndedState } from '@volter/sdk/kit/session-tombstone';
94
+ import { prepareSessionClose } from './session-close';
95
+ import { scheduleDeferredFullReport } from './state-report-deferral';
96
+ import { rendererResourceCounts } from '@volter/sdk/kit/renderer-resource-counts';
97
+ import {
98
+ subscribeViewportActivationTimings,
99
+ viewportActivationTimings,
100
+ } from '@volter/sdk/kit/viewport-activation-timings';
101
+
102
+ type AssertSameKeys<P, Q> = [keyof P] extends [keyof Q]
103
+ ? [keyof Q] extends [keyof P]
104
+ ? true
105
+ : false
106
+ : false;
107
+ type RequireTrue<T extends true> = T;
108
+ /**
109
+ * Compile-time drift guard (W3a N1) — its consumer is `tsc` itself. The SDK
110
+ * republishes `HelperVisibility` for control-API clients, and the
111
+ * `{ ...store.helperVisibility }` spread in `collectState` below typechecks
112
+ * happily even when the SDK mirror is MISSING keys (spreads skip excess
113
+ * property checks) — exactly how `joints`/`lod` drifted out of the SDK
114
+ * pre-W3a. This alias fails `npm run typecheck` the moment either side gains
115
+ * a key the other lacks.
116
+ */
117
+ export type HelperVisibilityMirrorInSync = RequireTrue<
118
+ AssertSameKeys<HelperVisibility, SdkHelperVisibility>
119
+ >;
120
+
121
+ import {
122
+ type CommandResult,
123
+ isRelayCommandType,
124
+ type RelayCommandDerivedRefresh,
125
+ type RelayCommandType,
126
+ relayCommandDerivedRefresh,
127
+ } from '@volter/sdk/session/command-table';
128
+ import { editorMaterials, isEditorMaterialId } from '@volter/sdk/widgets';
129
+ import {
130
+ copyAuthoringNodes,
131
+ createAuthoringNode,
132
+ cutAuthoringNodes,
133
+ duplicateAuthoringNode,
134
+ duplicateManyAuthoringNodes,
135
+ groupAuthoringNodes,
136
+ pasteAuthoringNodes,
137
+ removeAuthoringNode,
138
+ removeManyAuthoringNodes,
139
+ reorderAuthoringNode,
140
+ reparentAuthoringNode,
141
+ ungroupAuthoringNode,
142
+ unwrapAuthoringNode,
143
+ wrapAuthoringNode,
144
+ } from '@volter/sdk/kit/authoring/consumer-actions';
145
+ import { resolvePanelAuthoring } from '@volter/sdk/kit/authoring/panel-authoring';
146
+ import { ontologyInvariantFacet } from './coverage/session-vitals';
147
+ import { documentContextFor, waitForDocumentContext } from '@volter/sdk/kit/document-context-registry';
148
+ import { executeCommand, openCommandPalette } from '@volter/sdk/kit/editor-commands';
149
+ import { runDocumentProbe } from './editor-document-probe';
150
+ import {
151
+ captureActiveEditorDocument,
152
+ presentEditorView,
153
+ revealStaticPanel,
154
+ } from './editor-view-presentation';
155
+ import { toolGameplaySessions } from '@volter/sdk/kit/gameplay-sessions';
156
+ import {
157
+ InspectionRemovalUnavailableError,
158
+ inspectActiveSubject,
159
+ removeActiveInspectionField,
160
+ runActiveInspectionAction,
161
+ setActiveInspectionField,
162
+ } from './inspection/active-subject';
163
+ import { measuredReadinessWarning, readinessFacet } from '@volter/sdk/kit/readiness';
164
+ import { deriveReportedPlayState } from './reported-play-state';
165
+ import { openLiveSceneEntry } from './scene-live-open';
166
+ import { editorMaterialSnapshot, setEditorMaterialPreference } from '@volter/sdk/kit/theme-preference';
167
+ import { GAME_DOCUMENT_ID } from '@volter/sdk/kit/workspace-document-ids';
168
+ import {
169
+ activateWorkspaceDocument,
170
+ activeWorkspaceDocumentId,
171
+ closeWorkspaceDocument,
172
+ openWorkspaceDocuments,
173
+ } from '@volter/sdk/kit/workspace-document-registry';
174
+ import { availableWorkspaceDocuments } from '@volter/sdk/kit/workspace-available-documents';
175
+ import { activeWorkspaceUtility } from '@volter/sdk/kit/workspace-host-commands';
176
+ import {
177
+ activeEditorWorkspace,
178
+ editorWorkspaceIds,
179
+ isEditorWorkspaceId,
180
+ setEditorWorkspace,
181
+ whenEditorWorkspaceApplied,
182
+ } from '@volter/sdk/kit/workspace-presets';
183
+ import {
184
+ activeWorkspaceStyleId,
185
+ applyWorkspaceStyle,
186
+ workspaceStyleDifferences,
187
+ workspaceStyles,
188
+ } from '@volter/sdk/kit/workspace-style';
189
+ import { documentContributionForKind } from '@volter/sdk/kit/tool-loader';
190
+ import { toggleConsoleUtility } from '@volter/sdk/kit/workspace-utility-commands';
191
+ import { worldAdoptionFacet } from './world-adoption';
192
+
193
+ export interface EditorCommand {
194
+ type: string;
195
+ _requestId?: string;
196
+ [key: string]: unknown;
197
+ }
198
+
199
+ /** Browser-listener scheduling seam. The live relay holds presentation until
200
+ * its acknowledgement settles; direct callers fall back to the next task. */
201
+ export interface CommandHandlingOptions {
202
+ deferPresentation?: ((present: () => void) => void) | undefined;
203
+ /** The session's own undo/redo queue — see {@link connectCommandListener}'s
204
+ * `history` parameter for why the verbs go through it rather than through
205
+ * `store.projectHistory` directly. */
206
+ history?: HistoryCommands | undefined;
207
+ }
208
+
209
+ const REPEATED_DEBUG_READS = new Set([
210
+ 'state',
211
+ 'stateAll',
212
+ 'providers',
213
+ 'commands',
214
+ 'events',
215
+ 'snapshot',
216
+ ]);
217
+
218
+ /**
219
+ * The coverage proof a read-only debug call can add to the current adapter epoch.
220
+ *
221
+ * The first successful read still earns a full status derivation so coverage immediately reflects
222
+ * the operation. Repeating that same proof cannot change any derived facet: forcing another full
223
+ * 4,000-node hierarchy/coverage walk on every `waitSimTime` clock poll only stalls the game being
224
+ * measured. A provider name is part of a `state` proof because each declared provider is graded
225
+ * independently; adapter epoch keeps a remount's first read from reusing the retired mount's proof.
226
+ */
227
+ function repeatedDebugReadProofKeys(command: EditorCommand): readonly string[] {
228
+ if (command['type'] !== 'bridge-call') return [];
229
+ const method = command['method'];
230
+ if (typeof method !== 'string' || !REPEATED_DEBUG_READS.has(method)) return [];
231
+ const instance = command['instance'] as string | undefined;
232
+ try {
233
+ const debug = systemsForInstance(instance).debug;
234
+ if (debug === undefined) return [];
235
+ const prefix = [systemAdapterEpoch(debug), instance ?? ''].join(':');
236
+ const key = (member: string, provider = '') => [prefix, member, provider].join(':');
237
+ if (method === 'snapshot') {
238
+ // `snapshot` dispatches these three reads in one batch; the next lightweight `state(time)`
239
+ // poll is therefore already the same current-epoch proof, not a second first use.
240
+ return [key('snapshot'), key('state', 'time'), key('stateAll'), key('events')];
241
+ }
242
+ const provider = method === 'state' ? String((command['callArgs'] as unknown[])?.[0]) : '';
243
+ return [key(method, provider)];
244
+ } catch {
245
+ // An unresolved instance is a real command failure. Keep the conservative full refresh so its
246
+ // status/error facets cannot be hidden behind the optimization for successful repeated reads.
247
+ return [];
248
+ }
249
+ }
250
+
251
+ interface CompletedCommandRefresh {
252
+ readonly derived: RelayCommandDerivedRefresh;
253
+ /** A successful, explicit game read or command may have changed the mounted
254
+ * scene. During Play it earns one settled full report; ordinary input,
255
+ * presence and repeated reads do not. */
256
+ readonly playFullReport: 'none' | 'explicit' | 'if-content-changed';
257
+ }
258
+
259
+ function completedCommandDerivedRefresh(
260
+ command: EditorCommand,
261
+ succeeded: boolean,
262
+ reportedProofs: Set<string>,
263
+ ): CompletedCommandRefresh {
264
+ const proofKeys = repeatedDebugReadProofKeys(command);
265
+ const repeated =
266
+ succeeded && proofKeys.length > 0 && proofKeys.every((key) => reportedProofs.has(key));
267
+ if (succeeded) {
268
+ for (const key of proofKeys) reportedProofs.add(key);
269
+ }
270
+ return {
271
+ derived: repeated
272
+ ? 'none'
273
+ : succeeded && proofKeys.length > 0
274
+ ? 'always'
275
+ : (contributedCommandDerivedRefresh(command['type']) ??
276
+ relayCommandDerivedRefresh(command['type'])),
277
+ playFullReport:
278
+ succeeded && !repeated && proofKeys.length > 0
279
+ ? 'explicit'
280
+ : succeeded && command['type'] === 'bridge-call' && command['method'] === 'invoke'
281
+ ? 'if-content-changed'
282
+ : 'none',
283
+ };
284
+ }
285
+
286
+ function playCommandOwesDerivedRefresh(
287
+ derivedRefresh: RelayCommandDerivedRefresh,
288
+ playFullReport: CompletedCommandRefresh['playFullReport'],
289
+ contentVersion: number,
290
+ lastFullContentVersion: number,
291
+ ): boolean {
292
+ if (derivedRefresh === 'none') return false;
293
+ return !(
294
+ derivedRefresh === 'if-content-changed' &&
295
+ playFullReport === 'none' &&
296
+ contentVersion === lastFullContentVersion
297
+ );
298
+ }
299
+
300
+ /**
301
+ * The refusal for `editor.select(id)` on an id no authoring surface owns, or
302
+ * `null` when the id resolves.
303
+ *
304
+ * The resolvers are the ones a selection's own CONSUMERS use, and both of them,
305
+ * for the reason `entity-object.ts` records: the active adapter's
306
+ * `hierarchy.node` (what the hierarchy panel and the inspector resolve against)
307
+ * and `entityObject3D` (what the gizmo and the selection brackets resolve
308
+ * against). Neither is a superset of the other, so checking only one would
309
+ * refuse ids the editor can genuinely select.
310
+ *
311
+ * It used to consult neither: `editor.select('Player')` wrote the string
312
+ * straight into the store's selection set, and the inspector then composed a
313
+ * SUBJECT for it (measured 2026-08-14 on the translated platformer: `id:
314
+ * "Player"`, a blank title and an `R3F source` kind label for an entity that has
315
+ * never existed; through a composite the same id reached `children[0]` and drew
316
+ * a full transform section reading all zeros). A selection is a claim about an
317
+ * entity, so an id nothing owns is refused by name here rather than fabricated
318
+ * downstream.
319
+ */
320
+ function unresolvedSelectionRefusal(store: ShellStore, id: string): string | null {
321
+ // Resolve through the same document-scoped binding as Hierarchy and
322
+ // Inspector. Asset Lab documents publish their own adapter while the scene
323
+ // adapter remains globally active; asking the latter would reject the exact
324
+ // ids editor.hierarchy() just reported for the open asset/story.
325
+ const adapter = activeDocumentAuthoring(store);
326
+ if (adapter.hierarchy.node(id) !== null) return null;
327
+ // A live object the adapter's tree does not list (a runtime-built child) is
328
+ // still a subject when the medium that draws it answers for it.
329
+ if (inspectionNodeMedia(adapter, id) !== null) return null;
330
+ return (
331
+ `select: no entity with id "${id}" — neither the active authoring adapter's ` +
332
+ `hierarchy nor the live object index owns it, so there is nothing to select. ` +
333
+ `Read the ids that exist with editor.hierarchy() (the rows the panel is ` +
334
+ `rendering) or editor.status().entities.`
335
+ );
336
+ }
337
+
338
+ function applyControlSelection(
339
+ store: ShellStore,
340
+ ids: readonly string[],
341
+ options: CommandHandlingOptions | undefined,
342
+ ): void {
343
+ // Object3D documents own selection outside the scene store. This is also
344
+ // the route used by human hierarchy clicks and present-view, and is what
345
+ // lets semantic Asset Lab subjects (bones, physics bodies, joints) update
346
+ // their native highlight and Inspector state.
347
+ const ownSelection = documentViewport(activeWorkspaceDocumentId())?.selection;
348
+ if (ownSelection) {
349
+ ownSelection.apply(ids);
350
+ return;
351
+ }
352
+ const present = store.applySelectionBeforePresentation(ids);
353
+ if (options?.deferPresentation) options.deferPresentation(present);
354
+ else setTimeout(present, 0);
355
+ }
356
+
357
+ /** #145 — what the human's tab is actually showing right now: page
358
+ * visibility + window focus. `null` outside a real browser (the relay's
359
+ * node-side tests). The agent reads this to know whether the user is
360
+ * LOOKING at the shared session before deciding how to narrate/verify;
361
+ * `connectCommandListener` re-reports state on visibilitychange/focus/blur
362
+ * so the server's `/__editor/state` snapshot stays current between
363
+ * commands.
364
+ *
365
+ * P21 — `reportedAt` is stamped HERE, at read time, and it is the field that
366
+ * makes the other two honest. A CLI banner that says "the tab is hidden" is
367
+ * reading a snapshot with no age on it; the owner was told that repeatedly
368
+ * while looking straight at the foregrounded tab. With an age attached, the
369
+ * reader can see the difference between a reading taken 40ms ago and one
370
+ * taken 40 seconds ago, and no downstream surface has to invent a story to
371
+ * fill the gap. */
372
+ function collectPresence(): {
373
+ visibility: DocumentVisibilityState;
374
+ focused: boolean;
375
+ reportedAt: number;
376
+ } | null {
377
+ if (typeof document === 'undefined') return null;
378
+ return {
379
+ visibility: document.visibilityState,
380
+ focused: document.hasFocus(),
381
+ reportedAt: Date.now(),
382
+ };
383
+ }
384
+
385
+ /**
386
+ * The facets `collectState` will REUSE from a previous snapshot when it is
387
+ * given one — the hierarchy walk and the capability GRADING families. They are
388
+ * the whole measured cost of a collect (see `state-report-deferral.ts`), and
389
+ * none of them can change without a mount or an edit, which the deferred full
390
+ * collect that always follows a reusing one picks up.
391
+ *
392
+ * Everything NOT named here is derived fresh on every single report, including
393
+ * the fields a reader needs the instant they change: play state, loop
394
+ * liveness, selection, active document, save state, presence, and every error
395
+ * and warning channel.
396
+ *
397
+ * THE HOST'S HALF ONLY. The four capability-GRADING families
398
+ * (`rootCoverage`, `systemCoverage`, `projectCoverage`, `authoringCoverage`)
399
+ * used to be listed here; they are `@volter/editor-game`'s now
400
+ * (`contributions/coverage.service.ts`) and declare their own reusable keys
401
+ * on `session.reportFacet`, which {@link reusableFacetKeys} reads back. Both
402
+ * blockers P5b measured against that move are gone: the facet registry
403
+ * carries reusable key names and hands the collect its `reuse` snapshot, and
404
+ * the vitals' union call is now a subscription to the SAME five-second sample
405
+ * (`coverage/session-vitals.ts`'s `onSessionSample`) rather than a second
406
+ * timer. What is left in the host is the question the grade was asked ABOUT —
407
+ * `authoring/mounted-root-subjects.ts` — which was never a grade.
408
+ */
409
+ export const REUSABLE_DERIVED_FACETS = ['entities', 'entityCount', 'ontologyInvariants'] as const;
410
+
411
+ /** The immediate interaction report is a PATCH over the last full state. The
412
+ * server already owns that full snapshot; resending these unchanged,
413
+ * tree-scale facets on every runtime structure/store notification turns a
414
+ * large Play world into megabytes of duplicate control traffic per frame.
415
+ *
416
+ * A contributed facet's own reusable keys are stripped the same way — read
417
+ * live, because a contribution pass adds and removes facets while the session
418
+ * runs. */
419
+ function currentStatePatch(state: Record<string, unknown>): Record<string, unknown> {
420
+ const patch = { ...state };
421
+ for (const facet of REUSABLE_DERIVED_FACETS) delete patch[facet];
422
+ for (const facet of reusableFacetKeys()) delete patch[facet];
423
+ return { ...patch, _statePatch: true };
424
+ }
425
+
426
+ export function collectState(
427
+ store: ShellStore,
428
+ /**
429
+ * A previous full snapshot whose {@link REUSABLE_DERIVED_FACETS} this collect
430
+ * may copy instead of re-deriving, or `null`/omitted for a full collect.
431
+ *
432
+ * This is the interaction path's escape from a 76ms-to-1.3s synchronous
433
+ * derivation on every store notification. It is deliberately a caller's
434
+ * choice rather than an internal cache: only the caller knows whether it is
435
+ * on a user's critical path, and only the caller can promise the deferred
436
+ * full collect that makes the reused halves current again.
437
+ */
438
+ reuse: Record<string, unknown> | null = null,
439
+ ): Record<string, unknown> {
440
+ // S-1 (the SimCity ledger's false-alive ingest status): `store.playState` is
441
+ // editor UI STATE — ingest and module mode both write 'playing' into it
442
+ // without ever owning the `play-mode.ts` session the whole debug seam gates
443
+ // on, and ingest writes it BEFORE its mount is even attempted. Reporting it
444
+ // verbatim is how a session printed `playState: "playing"` in the same breath
445
+ // as `game.state()` refusing with "not in play mode", and how a mount that
446
+ // threw still reported itself alive. Derive it from what is actually running.
447
+ const mountFailures = getMountFailureReports();
448
+ const activeDocumentId = activeWorkspaceDocumentId();
449
+ const selectedIds = activeSelectionIds(store);
450
+ const gameplaySessions = toolGameplaySessions.getSnapshot();
451
+ // ONE walk, read by both `entityCount` and `entities` below. It used to be
452
+ // called once for each, and the walk is the expensive half of this whole
453
+ // function (23ms of a 76ms collect at 251 nodes, measured 2026-08-19) — two
454
+ // identical breadth-first traversals of the same tree in the same tick.
455
+ const hierarchyRows =
456
+ (reuse?.['entities'] as ReturnType<typeof activeHierarchyRows> | undefined) ??
457
+ activeHierarchyRows(store);
458
+ return {
459
+ playState: deriveReportedPlayState({
460
+ storePlayState: store.playState,
461
+ liveMounted: anyLiveSessionMounted(),
462
+ livePlaying: anyLiveSessionPlaying(),
463
+ mountFailures,
464
+ }),
465
+ // The lane's successful run window, independent of recording or document kind.
466
+ liveRunWindow: liveRunWindow(),
467
+ // Every world whose mount FAILED, with the error that killed it. `[]` on a
468
+ // healthy session; non-empty with `ingest: null` is what a dead game looks
469
+ // like from the control API, instead of a silent "playing". (`ingest` and
470
+ // `ingestCaptureWait` are the ingest lane's own facets, registered by it.)
471
+ mountFailures: mountFailures.map((r) => ({ ...r })),
472
+ // Every project story module that did not load or compose, with its error. A story that
473
+ // fails drops its prefab from the scene table with nothing else to show for it: `[]` on a
474
+ // healthy project; the console carries the module the failure was traced to.
475
+ storyFailures: getProjectStoryModules().flatMap((module_) =>
476
+ module_.ok ? [] : [{ modulePath: module_.modulePath, error: module_.error }],
477
+ ),
478
+ // PD-3 — every project module that was evaluated more than once during
479
+ // the current mount, i.e. every module whose module-level state the roots
480
+ // no longer share. `[]` on a healthy mount. A split does not fail the
481
+ // mount (both copies run), so this is the ONLY machine-readable signal
482
+ // that it happened at all.
483
+ moduleSplits: getProjectModuleSplitReports().map((s) => ({ path: s.path, urls: [...s.urls] })),
484
+ // The project's own ADAPTER, resolved (project-adapter.ts): which module
485
+ // supplied the binding table (`editor/volter.adapter.ts`, or the declared native
486
+ // default), the regions derived for it, and its scene table. `null` means
487
+ // NOBODY HAS LOOKED YET — deliberately distinct from a loaded adapter with
488
+ // an empty table, which is a real (and gradable) answer.
489
+ adapter: projectAdapterFacet(),
490
+ // DECLARED READINESS, per root (`readiness.ts`). One row per mounted root
491
+ // saying WHO answers "is this ready" — the host's own completed mount, the
492
+ // game's `window.volterGame.ready`, or a host-side measured wait. `[]` means
493
+ // nothing has mounted, which is not "nothing is ready".
494
+ readiness: readinessFacet().map((entry) => ({ ...entry })),
495
+ // The ONE sentence for a project whose readiness is entirely measured. It
496
+ // is the visibility half of the declared-vs-measured rule: an undeclared
497
+ // game still works, it just stops being silent about it. `null` when
498
+ // nothing is mounted or at least one root declared.
499
+ readinessWarning: measuredReadinessWarning(),
500
+ // WHICH WORLD the capture adopted for a self-booting root and how
501
+ // (`declared` from the game's contract, or the trap's measured
502
+ // first-non-host-render), plus every DISTINCT world that rendered
503
+ // afterwards. The adoption itself is unchanged; this is the reader that
504
+ // used to not exist for a permanent, silent choice.
505
+ worldAdoption: worldAdoptionFacet().map((entry) => ({ ...entry })),
506
+ presence: collectPresence(),
507
+ // Target-blaster friction #3 (#146 ledger): the play-verify loop's error
508
+ // channel — the same current-run-fenced uncaught-error list the relay
509
+ // snapshot carries (see collectPlayRunPageErrors), so the editor's `status` command answers
510
+ // "did anything go wrong since play started" without a second command.
511
+ // [] while stopped or when nothing threw.
512
+ pageErrors: collectPlayRunPageErrors(),
513
+ // The OTHER half of the same question, and the half that was missing: an
514
+ // otherwise-HEALTHY run that logged errors. `pageErrors` above is the
515
+ // 'runtime' slice (uncaught error/unhandledrejection) and is rendered only
516
+ // by the play-FAILED path, so a game that threw five `console.error`s
517
+ // during a fine-looking play run said nothing to any CLI reader — the same
518
+ // "already in the JSON and invisible in practice" failure PD-13 describes.
519
+ // Disjoint from `pageErrors` by construction (see collectConsoleErrors), so
520
+ // the two counts sum.
521
+ consoleErrors: collectConsoleErrors(),
522
+ // The SESSION-lifetime half — everything the two play-fenced facets above
523
+ // do not claim, which before `installEditorConsoleCapture()` was
524
+ // everything an editor frame logged while play was stopped and reached no
525
+ // reader at all (see `collectSessionErrors`). Disjoint from both by
526
+ // construction, so all three counts sum.
527
+ sessionErrors: collectSessionErrors(),
528
+ // Warnings had NO CLI-facing facet whatsoever until this one.
529
+ sessionWarnings: collectSessionWarnings(),
530
+ // GPU ownership is measured independently from DOM canvas attachment.
531
+ // These live counters make context-budget regressions observable without
532
+ // waiting for the browser to evict the oldest viewport.
533
+ rendererResources: {
534
+ ...rendererResourceCounts(),
535
+ },
536
+ // The ontology's LIVE invariants, re-derived on read. Every row is present
537
+ // every time — including the ones this session cannot measure, which say so
538
+ // rather than vanishing (`coverage/ontology-invariants.ts`).
539
+ ontologyInvariants:
540
+ reuse?.['ontologyInvariants'] ?? ontologyInvariantFacet().map((row) => ({ ...row })),
541
+ // What only a running lane knows — its loop's time scale and liveness,
542
+ // its seed, a pending restart, and the four capability-GRADING families
543
+ // (`editor-state-facets.ts`). `reuse` rides through: a facet that declared
544
+ // reusable keys serves them from the snapshot instead of re-deriving.
545
+ ...collectEditorStateFacets(reuse),
546
+ selectedEntityId: selectedIds[0] ?? null,
547
+ selectedEntityIds: selectedIds,
548
+ // The machine door onto the creation-site index: the source
549
+ // location that constructed the SELECTED object, or the named reason there
550
+ // isn't one. `null` only when nothing is selected or the active adapter
551
+ // indexes no creation sites.
552
+ selectedCreationSite: activeSelectionCreationSite(store),
553
+ // The WRITE-side half of the same answer: which lane an edit to the
554
+ // selection would take (`WriteAnchorKind`). A sweep needs it to find one
555
+ // representative subject per lane — the anchor above cannot distinguish a
556
+ // JSX prop from a construction literal, nor either from a body-placed
557
+ // spawn, and those have different correctness contracts.
558
+ selectedWriteAnchorKind: activeSelectionWriteAnchorKind(store),
559
+ activeViewportTab: store.activeViewportTab,
560
+ activeDocumentId,
561
+ activeUtilityId: activeWorkspaceUtility(),
562
+ gameplaySession: {
563
+ selectedSessionId: gameplaySessions.selectedSessionId,
564
+ latestSessionId: gameplaySessions.sessions[0]?.id ?? null,
565
+ selectedStatus: gameplaySessions.selectedSession?.status ?? null,
566
+ cursorMs: gameplaySessions.cursorMs,
567
+ liveEdgeMs: gameplaySessions.liveEdgeMs,
568
+ },
569
+ // The registry is the authority for which center subjects EXIST. Doctor
570
+ // uses this to photograph the project's actual component boards instead
571
+ // of spending long activation windows guessing every medium-specific id.
572
+ openDocumentIds: openWorkspaceDocuments().map((document) => document.descriptor.id),
573
+ availableDocuments: availableWorkspaceDocuments().map((entry) => ({
574
+ id: entry.descriptor.id,
575
+ category: entry.category,
576
+ default: entry.default,
577
+ })),
578
+ // W2: asset viewers are center workspace documents now; this facet keeps
579
+ // its legacy key vocabulary for control-API consumers — the active ASSET
580
+ // document's key, or the '__inspector__' sentinel. The sentinel does NOT
581
+ // imply a visible Inspector: that surface is selection-owned.
582
+ activeTabKey: (() => {
583
+ const activeId = activeWorkspaceDocumentId();
584
+ return activeId && isAssetDocumentId(activeId) ? activeId : '__inspector__';
585
+ })(),
586
+ // The viewport's keys (grid, helpers, stats, shading, helper visibility, the
587
+ // transform tool, space and snap, the camera) are the Three set's status
588
+ // facet (`viewport-status-facet.ts`), spread in above.
589
+ entityCount: hierarchyRows.length,
590
+ // Persistence truth comes from the ACTIVE adapter's PersistenceProvider —
591
+ // never a per-format store field.
592
+ savePath: activeSaveDestination(store),
593
+ // SDK clients need the same persistence truth the editor UI exposes.
594
+ saveState: activeSaveState(store),
595
+ // Hierarchy facet (B3-followup): the real node tree, flattened to
596
+ // id/name/childIds rows (same shape editor.hierarchy.inspect declares),
597
+ // read from the ACTIVE authoring adapter.
598
+ entities: hierarchyRows,
599
+ // Design-surface first-frame stamps. Doctor `--timings` subtracts these
600
+ // from its `active-tab` ack so a tab flip is a measured wait, not a
601
+ // blank canvas with no number.
602
+ viewportActivationTimings: viewportActivationTimings(),
603
+ };
604
+ }
605
+
606
+ /**
607
+ * The answer a caller gets when a command HANDLER threw instead of returning.
608
+ *
609
+ * Two things must happen and neither used to: the caller is told what killed
610
+ * its command (rather than timing out against a message about the tab), and
611
+ * the failure is logged so it reaches `editorConsole` — which is what
612
+ * `collectSessionErrors` reads, and therefore what the editor's `status` command prints. The
613
+ * browser's own `unhandledrejection` path did the second job only for the
614
+ * FIRST occurrence, because the console capture dedupes an identical message.
615
+ */
616
+ export function commandThrewResult(cmd: EditorCommand, error: unknown): CommandResult {
617
+ const message = error instanceof Error ? error.message : String(error);
618
+ const text = `editor command "${String(cmd['type'])}" threw: ${message}`;
619
+ // biome-ignore lint/suspicious/noConsole: this IS the loud leg — editorConsole is fed by the console, and it is what the editor's `status` command prints.
620
+ console.error(text, error);
621
+ return { ok: false, error: text };
622
+ }
623
+
624
+
625
+ /** THE ONE PLAY-RUN FENCE all four error facets below split on: `true` when a
626
+ * console entry logged at `timestamp` belongs to the most recent play run.
627
+ *
628
+ * Closed at BOTH ends (the live registry's newest run window), which is
629
+ * what makes the play facets and the session facets a genuine partition of the
630
+ * session's error entries — every entry belongs to exactly one side, so the
631
+ * counts sum with nothing double-counted and nothing dropped. With an
632
+ * open-ended window a single play run captured the rest of the session: every
633
+ * later editor error read as "during the play run" and was reported only by a
634
+ * banner that renders while play is live. */
635
+ function inPlayRun(timestamp: number): boolean {
636
+ const window = liveRunWindow();
637
+ if (!window || timestamp < window.startedAt) return false;
638
+ return window.endedAt === null || timestamp <= window.endedAt;
639
+ }
640
+
641
+ /** #146 — uncaught page errors from the CURRENT play run, for the editor's `status` command
642
+ * and for `@volter/editor-game`'s `bridge-call` snapshot, which imports it from here
643
+ * (the two must report the same set; the facet moves when Play does).
644
+ * Reads the editor console's 'runtime'-source error entries
645
+ * (fed by `installEditorConsoleCapture`'s window error/unhandledrejection
646
+ * capture — the same events, same message format as `window.__volter`'s own
647
+ * pageErrors ring buffer), fenced to the play run. Same 100-cap as the bridge
648
+ * (`PAGE_ERROR_CAP`), keeping the most recent. */
649
+ export function collectPlayRunPageErrors(): string[] {
650
+ return editorConsole
651
+ .getEntries()
652
+ .filter((e) => e.level === 'error' && e.source === 'runtime' && inPlayRun(e.timestamp))
653
+ .slice(-100)
654
+ .map((e) => (e.count > 1 ? `${e.message} (×${e.count})` : e.message));
655
+ }
656
+
657
+ /** How many of the newest console-error messages the summary carries, and how
658
+ * far each is truncated. A banner that reprints every message on a noisy run
659
+ * stops being readable, which is the failure mode this exists to fix (same
660
+ * reasoning as `authoringWarningsWarning`'s per-file summary in the CLI); the
661
+ * full text is in the editor console and the run's `logs/play-*.jsonl`. */
662
+ const CONSOLE_ERROR_SAMPLE = 3;
663
+ const CONSOLE_ERROR_MESSAGE_CAP = 200;
664
+
665
+ /** Error-level editor-console entries from the CURRENT play run that
666
+ * `collectPlayRunPageErrors` above does NOT already report — i.e. everything except
667
+ * the 'runtime' source. That exclusion is what makes the two facets disjoint,
668
+ * so a reader can add the counts without double-counting one error.
669
+ *
670
+ * The dominant member is source 'game': `play-mode.ts`'s console patch funnels
671
+ * the running game's own `console.error` here, and that channel reached no CLI
672
+ * reader at all. `count` totals OCCURRENCES (the console store collapses
673
+ * identical consecutive messages into one entry with a `count`), so a loop
674
+ * erroring every frame reports the real number rather than 1. Fenced to
675
+ * the live run window exactly like `collectPlayRunPageErrors`, and `{count: 0,
676
+ * recent: []}` while stopped. */
677
+ function collectConsoleErrors(): { count: number; recent: string[] } {
678
+ return summarizeEntries(
679
+ editorConsole
680
+ .getEntries()
681
+ .filter((e) => e.level === 'error' && e.source !== 'runtime' && inPlayRun(e.timestamp)),
682
+ );
683
+ }
684
+
685
+ /** The shared `{count, recent}` shape: `count` totals OCCURRENCES (the console
686
+ * store collapses identical consecutive messages into one entry carrying a
687
+ * `count`), `recent` is the newest few, source-tagged and truncated. */
688
+ function summarizeEntries(entries: readonly ConsoleEntry[]): { count: number; recent: string[] } {
689
+ const count = entries.reduce((sum, e) => sum + e.count, 0);
690
+ const recent = entries.slice(-CONSOLE_ERROR_SAMPLE).map((e) => {
691
+ const source = e.source ? `[${e.source}] ` : '';
692
+ const repeats = e.count > 1 ? ` (×${e.count})` : '';
693
+ const message =
694
+ e.message.length > CONSOLE_ERROR_MESSAGE_CAP
695
+ ? `${e.message.slice(0, CONSOLE_ERROR_MESSAGE_CAP)}…`
696
+ : e.message;
697
+ return `${source}${message}${repeats}`;
698
+ });
699
+ return { count, recent };
700
+ }
701
+
702
+ /** Error-level entries from the whole EDITOR SESSION that the two play-fenced
703
+ * facets above do NOT report — i.e. everything logged outside the most recent
704
+ * play run's window (`inPlayRun`). That exclusion is what keeps all three
705
+ * disjoint, exactly as `collectConsoleErrors` excludes source 'runtime'.
706
+ *
707
+ * Measured defect this closes: a human watching the editor's browser console
708
+ * saw real errors — Content-tab story previews throwing `useRapier must be
709
+ * used within <Physics>` — while the editor's `status` command reported `consoleErrors:
710
+ * {count: 0}` and `pageErrors: []`. Nothing was wrong with either facet: both
711
+ * are fenced to a play run, and the ONLY funnel from a raw `console.error`
712
+ * into the store was play-mode's patch, installed at play start and removed at
713
+ * play stop. Outside play, an editor-frame error existed in the browser
714
+ * console and nowhere else. `installEditorConsoleCapture()` (editor boot) now
715
+ * feeds the store for the whole session, and this is the facet that reports
716
+ * it — including uncaught page errors ('runtime') thrown outside a play run,
717
+ * which `collectPlayRunPageErrors` deliberately still does not claim.
718
+ *
719
+ * Fence: since editor page load (the store starts empty at boot), NOT since
720
+ * play start. */
721
+ function collectSessionErrors(): { count: number; recent: string[] } {
722
+ return summarizeEntries(
723
+ editorConsole.getEntries().filter((e) => e.level === 'error' && !inPlayRun(e.timestamp)),
724
+ );
725
+ }
726
+
727
+ /** Warn-level entries from the whole editor session. No play-fenced facet
728
+ * reports warnings at ALL — `console.warn` had no CLI-facing channel of any
729
+ * kind — so this one is not narrowed to outside-play: narrowing it would
730
+ * simply re-hide every warning a play run emits. Same `{count, recent}`
731
+ * discipline as the error facets; the `[source]` tag on each sample is what
732
+ * tells a reader whether a warning came from the game, the editor, or the
733
+ * server. */
734
+ function collectSessionWarnings(): { count: number; recent: string[] } {
735
+ return summarizeEntries(editorConsole.getEntries().filter((e) => e.level === 'warn'));
736
+ }
737
+
738
+ /**
739
+ * `document-script` relay op — `editor.document.run(ctx => …)`. The same
740
+ * wire contract as `page-script` (a step's own source, reconstructed here;
741
+ * closures do not survive), bound not to a page shim but to the object the
742
+ * ACTIVE document published as its context. Refuses by name when no document
743
+ * is active or the active one published nothing, so an agent is never told
744
+ * a step ran against a document that has no session to run it on.
745
+ */
746
+ async function handleDocumentScript(cmd: EditorCommand): Promise<CommandResult> {
747
+ const src = cmd['src'];
748
+ if (typeof src !== 'string') {
749
+ return { ok: false, error: 'document-script requires a string "src" (the step\'s toString())' };
750
+ }
751
+ const documentId = activeWorkspaceDocumentId();
752
+ if (!documentId) {
753
+ return {
754
+ ok: false,
755
+ error: 'document-script: no document is active',
756
+ data: { code: 'DOCUMENT_SCRIPT_UNAVAILABLE' },
757
+ };
758
+ }
759
+ await waitForDocumentContext(documentId);
760
+ const context = documentContextFor(documentId);
761
+ if (activeWorkspaceDocumentId() !== documentId) {
762
+ return {
763
+ ok: false,
764
+ error: 'document-script: the active document changed while its context was loading',
765
+ data: { code: 'DOCUMENT_SCRIPT_UNAVAILABLE', documentId },
766
+ };
767
+ }
768
+ if (context === undefined) {
769
+ return {
770
+ ok: false,
771
+ error:
772
+ `document-script: the active document (${documentId}) publishes no context to run ` +
773
+ 'against — a document opts in through its `publishContext` prop (the mesh document ' +
774
+ 'publishes its session).',
775
+ data: { code: 'DOCUMENT_SCRIPT_UNAVAILABLE', documentId },
776
+ };
777
+ }
778
+ let step: (ctx: unknown, info: { documentId: string }) => unknown;
779
+ try {
780
+ step = new Function('ctx', 'info', `return (${src})(ctx, info)`) as typeof step;
781
+ } catch (err) {
782
+ return {
783
+ ok: false,
784
+ error: `document-script: failed to reconstruct the step function from source — ${
785
+ err instanceof Error ? err.message : String(err)
786
+ }`,
787
+ data: { code: 'DOCUMENT_SCRIPT_ERROR' },
788
+ };
789
+ }
790
+ try {
791
+ const result = await step(context, { documentId });
792
+ return { ok: true, data: { result: result === undefined ? null : result } };
793
+ } catch (err) {
794
+ return {
795
+ ok: false,
796
+ error: `document-script: step threw — ${err instanceof Error ? err.message : String(err)}`,
797
+ data: { code: 'DOCUMENT_SCRIPT_ERROR' },
798
+ };
799
+ }
800
+ }
801
+
802
+ /**
803
+ * Exported (alongside `collectState` above) so unit tests can dispatch
804
+ * commands directly against a headless `ShellStore`, with no SSE/server
805
+ * round-trip — see `packages/editor/test/command-listener.test.ts`, notably
806
+ * the false-ack regression test: an unrecognized command must return
807
+ * `{ok:false}`, never fall through to a fabricated `{ok:true}`.
808
+ */
809
+ export async function handleCommand(
810
+ store: ShellStore,
811
+ cmd: EditorCommand,
812
+ options?: CommandHandlingOptions,
813
+ ): Promise<CommandResult> {
814
+ // EVERY relayed command is observed (`command-dispatch.ts`): Play's idle
815
+ // watchdog reads it, and a stamp filed per-case would quietly exclude
816
+ // whichever case someone forgot.
817
+ noteCommandDispatched(cmd['type'] as string);
818
+ // A video export OWNS the paused run it is stepping frame by frame, so no
819
+ // other command may touch it mid-export. `stop` is the one exception and
820
+ // falls THROUGH: the export's cancel handle lives with the verb that
821
+ // started it (`@volter/editor-game`'s `play.command.ts`), and its `stop` handler
822
+ // aborts the controller before tearing the run down. The FLAG stays host
823
+ // state (`gameplay-export-state.ts`) because two surfaces outside that verb
824
+ // read it — this prologue and the PlayBar's transport ownership.
825
+ if (isGameplayExportActive() && cmd['type'] !== 'stop') {
826
+ return {
827
+ ok: false,
828
+ error: 'Video export owns this paused run. Stop it to cancel before another command.',
829
+ };
830
+ }
831
+ // A running lane answers its own play verbs first (`LiveSession.command`,
832
+ // the live registry): an ingested game owns its mount, so the first-party
833
+ // boot must never be handed its container.
834
+ const answered = dispatchLiveCommand(cmd as { type: string });
835
+ if (answered) return answered;
836
+ if (cmd['type'] === 'play' || cmd['type'] === 'restart') {
837
+ // NO live ingest, but a mount FAILED — refuse with the failure instead of
838
+ // letting `enterPlayMode` boot a first-party composition over an ingest
839
+ // manifest. It cannot: `resolveRootBinding` throws for every ingest
840
+ // identity by construction, and the sentence it throws is about
841
+ // `ThreeHostContext` not carrying an `EditorStore` — true, internal, and
842
+ // about a mechanism the reader was never using. MEASURED on the
843
+ // bubbo-bubbo canvas ingest: the real cause was a capture window spent on
844
+ // a hidden tab, and the editor's `play` command reported the resolver's contract note,
845
+ // naming neither the game nor the reason. A failed mount is the answer to
846
+ // "why can't I play this", whichever lane failed.
847
+ const failures = getMountFailureReports();
848
+ if (failures.length > 0) {
849
+ return {
850
+ ok: false,
851
+ error:
852
+ `this project's ${failures.length === 1 ? 'root' : 'roots'} did not mount, so there ` +
853
+ `is nothing to ${cmd['type'] === 'play' ? 'play' : 'restart'}: ` +
854
+ failures.map((f) => `"${f.worldId}" (${f.identity}) — ${f.message}`).join('; '),
855
+ };
856
+ }
857
+ }
858
+ // The dispatch keys off the TABLE's union, not off a raw string. Two things
859
+ // follow, and they are the whole point of `command-table.ts`: a `case` whose
860
+ // label is not a row does not compile, and a row with no `case` fails the
861
+ // exhaustiveness check in the `default` below. The runtime guard in front of
862
+ // it is what makes the narrowing honest — an unknown string from an older
863
+ // CLI still reaches the "unknown command type" answer.
864
+ // A PACKAGE'S verb (`@volter/sdk/commands`, `command-registry.ts`):
865
+ // answered by its own handler, through the same relay, ack and derivation
866
+ // as the host's table below. Asked first so a contributed verb never
867
+ // reads as "editor page predates this CLI".
868
+ const contributed = await resolveContributedCommand(cmd['type']);
869
+ if (contributed) {
870
+ try {
871
+ const answer = await contributed.handle(cmd);
872
+ return {
873
+ ok: answer.ok,
874
+ ...(answer.error !== undefined ? { error: answer.error } : {}),
875
+ ...(answer.data !== undefined ? { data: answer.data } : {}),
876
+ };
877
+ } catch (error) {
878
+ return commandThrewResult(cmd, error);
879
+ }
880
+ }
881
+ if (!isRelayCommandType(cmd['type'])) {
882
+ return {
883
+ ok: false,
884
+ error:
885
+ `unknown command type "${cmd['type']}" — not one of the editor's own, and no package this ` +
886
+ 'project declares contributes it (or the editor page predates this CLI)',
887
+ data: { code: 'UNKNOWN_COMMAND_TYPE' },
888
+ };
889
+ }
890
+ const commandType: RelayCommandType = cmd['type'];
891
+ switch (commandType) {
892
+ case 'session-prepare-close':
893
+ await prepareSessionClose();
894
+ return { ok: true };
895
+ // Selection
896
+ case 'select': {
897
+ const id = (cmd['id'] as string | null) ?? null;
898
+ if (id !== null) {
899
+ const refusal = unresolvedSelectionRefusal(store, id);
900
+ if (refusal) return { ok: false, error: refusal };
901
+ }
902
+ applyControlSelection(store, id ? [id] : [], options);
903
+ break;
904
+ }
905
+ case 'select-multiple': {
906
+ const ids = cmd['ids'] as string[];
907
+ // All-or-nothing: a partial selection silently dropping the id the caller
908
+ // cared about is the same fabrication in a quieter form.
909
+ for (const id of ids) {
910
+ const refusal = unresolvedSelectionRefusal(store, id);
911
+ if (refusal) return { ok: false, error: refusal };
912
+ }
913
+ applyControlSelection(store, ids, options);
914
+ break;
915
+ }
916
+ case 'select-all': {
917
+ applyControlSelection(
918
+ store,
919
+ activeHierarchyRows(store).map((row) => row.id),
920
+ options,
921
+ );
922
+ break;
923
+ }
924
+
925
+ // Panels
926
+ case 'viewport-tab': {
927
+ // The tab is which document has focus: `play` is the Game document, `edit` any other.
928
+ // Asking for one activates that document; the workspace owns focus.
929
+ const tab = cmd['tab'];
930
+ if (tab !== 'edit' && tab !== 'play') {
931
+ return { ok: false, error: `viewport-tab requires "edit" or "play", got ${String(tab)}.` };
932
+ }
933
+ const open = openWorkspaceDocuments().map((document) => document.descriptor.id);
934
+ const target =
935
+ tab === 'play'
936
+ ? open.find((id) => id === GAME_DOCUMENT_ID)
937
+ : (open.find((id) => id === 'workspace:scene') ?? open.find((id) => id !== GAME_DOCUMENT_ID));
938
+ if (!target || !activateWorkspaceDocument(target)) {
939
+ return {
940
+ ok: false,
941
+ error:
942
+ tab === 'play'
943
+ ? 'viewport-tab play: no Game document is open. Start Play first.'
944
+ : 'viewport-tab edit: no document other than Game is open.',
945
+ };
946
+ }
947
+ break;
948
+ }
949
+ // FOCUS A PANEL. The vocabulary is the static-panel REGISTRY, resolved by
950
+ // `revealStaticPanel` at call time — the same resolution and the same
951
+ // refusal a view's `panel` gets, and the same reveal the Window menu's own
952
+ // items use. This handler recognises no panel by name.
953
+ case 'show-panel': {
954
+ try {
955
+ const panel = cmd['panel'];
956
+ if (typeof panel !== 'string' || panel.trim() === '') {
957
+ return { ok: false, error: 'show-panel requires a non-empty "panel".' };
958
+ }
959
+ return { ok: true, data: { panel: await revealStaticPanel(panel) } };
960
+ } catch (error) {
961
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
962
+ }
963
+ }
964
+ // W2: the legacy right-rail tab commands keep their key vocabulary but
965
+ // now drive the CENTER workspace documents (`asset-documents.tsx`).
966
+ // '__inspector__' ("show the Inspector") is a no-op now: the Inspector is
967
+ // selection-owned, so a command cannot force it open without a subject.
968
+ case 'active-tab': {
969
+ const key = cmd['key'] as string;
970
+ // The ack must MEAN the document is in front. This handler used to
971
+ // discard `activateWorkspaceDocument`'s boolean, so asking for a
972
+ // document that does not exist acked `ok` while the workspace kept
973
+ // showing whatever tab was already there — and every caller that
974
+ // photographs, inspects or drives "the active document" then read a
975
+ // surface it never asked for and had no way to notice (doctor's walk
976
+ // photographed the UI board and filed it as the Scene). The refusal
977
+ // lists what IS open, because a wrong id is nearly always a stale or
978
+ // misspelled one and the registry already knows the real set.
979
+ if (key !== '__inspector__' && !activateWorkspaceDocument(key)) {
980
+ const open = openWorkspaceDocuments().map((d) => d.descriptor.id);
981
+ return {
982
+ ok: false,
983
+ error:
984
+ `No open workspace document with id "${key}" — nothing was activated. ` +
985
+ (open.length > 0
986
+ ? `Open documents: ${open.join(', ')}.`
987
+ : 'No documents are open in this workspace.'),
988
+ };
989
+ }
990
+ break;
991
+ }
992
+ case 'select-asset': {
993
+ // The OTHER half of the browser's selection-vs-open contract
994
+ // (`asset-selection.ts`): a single click SELECTS an asset and fills the
995
+ // Inspector; a double click OPENS its document. Only `open` had a verb,
996
+ // so every `asset.inspector` contribution — a project's own Inspector
997
+ // door — was reachable by mouse alone.
998
+ const path = cmd['path'];
999
+ if (typeof path !== 'string' || path.trim() === '') {
1000
+ return { ok: false, error: 'select-asset requires a non-empty "path".' };
1001
+ }
1002
+ // No existence check, for the same reason `open-asset-tab` has none:
1003
+ // the sections that match report their own failures (the Edit Mesh
1004
+ // door says "exports no build()"), and a second file-exists door here
1005
+ // would answer for a tier it cannot see (a hosted project's source has
1006
+ // no `public/` listing). An unknown path selects and the Inspector
1007
+ // shows nothing matched, which is the same answer the browser gives.
1008
+ const clean = path.replace(/^\/+/, '');
1009
+ const name = clean.split('/').pop() ?? clean;
1010
+ const capability = assetCapabilities(name);
1011
+ setSelectedAsset({
1012
+ path,
1013
+ name,
1014
+ kind: assetDocumentKind(capability) ?? 'unknown',
1015
+ capabilities: capability,
1016
+ origin: 'project',
1017
+ });
1018
+ break;
1019
+ }
1020
+ case 'open-asset-tab': {
1021
+ const kind = cmd['kind'] as AssetKind;
1022
+ const documentId = openAssetDocument(cmd['path'] as string, kind);
1023
+ if (!(await waitForAssetDocumentInspector(documentId, kind))) {
1024
+ return {
1025
+ ok: false,
1026
+ error: `Asset document did not finish mounting its Inspector: ${documentId}`,
1027
+ };
1028
+ }
1029
+ break;
1030
+ }
1031
+ case 'close-asset-tab': {
1032
+ // Legacy key vocabulary: the deleted store-era `closeAssetTab` only
1033
+ // ever acted on asset tabs and no-op'd for anything else (scene/game/
1034
+ // story keys included). `closeWorkspaceDocument` itself has no such
1035
+ // guard — it closes ANY registered id, pinned or not (`closeable:
1036
+ // false` is only a UI-affordance rule, not a registry invariant; see
1037
+ // workspace-document-registry.ts) — so this command must keep the
1038
+ // no-op itself. `isAssetDocumentId` is exactly the legacy asset key
1039
+ // vocabulary check (project asset path / `asset-editor:entity:<id>` /
1040
+ // `online:<source>:<id>` — see asset-documents.tsx), so gating on it
1041
+ // blocks the pinned `workspace:scene`/`workspace:game` ids (and any
1042
+ // story `story:<path>#<name>` id) while preserving the real behavior
1043
+ // for actual asset tabs.
1044
+ const key = cmd['key'] as string;
1045
+ if (isAssetDocumentId(key)) closeWorkspaceDocument(key);
1046
+ break;
1047
+ }
1048
+ case 'toggle-command-palette':
1049
+ // The palette is the workbench's; the frame hands over the opener that
1050
+ // shows it (`editor-commands.ts`).
1051
+ openCommandPalette();
1052
+ break;
1053
+ case 'toggle-console':
1054
+ toggleConsoleUtility();
1055
+ break;
1056
+ // RELOAD THIS PAGE — `@volter/game-live`'s `page.reload()` and P20's prescribed
1057
+ // recovery. Deliberately NOT routed through `page-script`: that verb is
1058
+ // gated on a mounted game surface, and the one thing a reload has to fix —
1059
+ // a page whose module-scope loaders and page-lifetime asset caches hold
1060
+ // bytes that have since changed on disk — is just as real with play
1061
+ // stopped, and just as real in a product that has no game at all.
1062
+ //
1063
+ // It is the HOST's for that last reason. It was a `@volter/editor-game` command
1064
+ // contribution until walk 5, so `page.reload()` answered `unknown command
1065
+ // type "page-reload"` in Cyclotron, which declares no `@volter/editor-game`.
1066
+ //
1067
+ // Scheduled for the NEXT task rather than run inline, so this handler can
1068
+ // return and the caller's ack can travel before the navigation tears the
1069
+ // channel down: the client acks the ORDER here and waits for the new page
1070
+ // load on the server's own tab table.
1071
+ case 'page-reload':
1072
+ setTimeout(() => {
1073
+ window.location.reload();
1074
+ }, 0);
1075
+ return { ok: true, data: { scheduled: true } };
1076
+ // NAMED WORKSPACES (ARCHITECTURE-CORE §Editor chrome) — the session
1077
+ // operation `editor.workspace(id)`, the third of the ruling's three
1078
+ // switching doors beside `Window → Workspace` and the registered actions.
1079
+ // A wrong id refuses and NAMES the vocabulary: this is a fixed registry,
1080
+ // so the refusal can be complete.
1081
+ case 'set-workspace': {
1082
+ const id = cmd['workspace'];
1083
+ if (!isEditorWorkspaceId(id)) {
1084
+ return {
1085
+ ok: false,
1086
+ error: `set-workspace requires one of ${editorWorkspaceIds().join(', ')}, got ${String(id)}.`,
1087
+ };
1088
+ }
1089
+ if (activeEditorWorkspace() === id)
1090
+ return { ok: true, data: { workspace: id, applied: true } };
1091
+ // Registered BEFORE the store flip — the dock's rebuild is what resolves
1092
+ // it, and that can land before this handler's next await point. Bounded,
1093
+ // because the store flip is real whether or not a dock is mounted to
1094
+ // follow it: a hung wait would otherwise be reported as "the editor did
1095
+ // not respond", which names the wrong thing.
1096
+ const rebuilt = whenEditorWorkspaceApplied().then(() => true);
1097
+ setEditorWorkspace(id);
1098
+ const applied = await Promise.race([
1099
+ rebuilt,
1100
+ new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 3000)),
1101
+ ]);
1102
+ // NOTHING REVEALS A DRAWER UTILITY HERE ANY MORE. `drawerUtility` was
1103
+ // this line's reason and it is retired: a Blender editor AREA is an
1104
+ // EDITOR GROUP, not a drawer view (orchestrator ruling 2026-09-19), so
1105
+ // the node editor and the UV editor are `workspace.document`
1106
+ // contributions the workspace opens into `volter:area:<id>`
1107
+ // (`workspace-areas.ts`) and the drawer keeps only the utilities that
1108
+ // are not Blender areas.
1109
+ return { ok: true, data: { workspace: id, applied } };
1110
+ }
1111
+ case 'set-style': {
1112
+ const id = cmd['style'];
1113
+ const ids = workspaceStyles().map((bundle) => bundle.id);
1114
+ if (typeof id !== 'string' || !ids.includes(id)) {
1115
+ return {
1116
+ ok: false,
1117
+ error: `set-style requires one of ${ids.join(', ')}, got ${String(id)}.`,
1118
+ };
1119
+ }
1120
+ applyWorkspaceStyle(id);
1121
+ // Reported from the axes, not echoed: a bundle applies through the
1122
+ // settings layer that DECLARES each axis (`updatePreferenceSettings`),
1123
+ // so what the chrome wears is what this answers.
1124
+ const applied = activeWorkspaceStyleId();
1125
+ if (applied !== id) {
1126
+ return {
1127
+ ok: false,
1128
+ error: `set-style applied "${id}" but the editor is wearing ${applied === null ? 'a custom mix of axes' : `"${applied}"`} (${workspaceStyleDifferences(id).join('; ')}).`,
1129
+ data: { style: applied, differences: workspaceStyleDifferences(id) },
1130
+ };
1131
+ }
1132
+ return { ok: true, data: { style: id } };
1133
+ }
1134
+ case 'viewport-presentation': {
1135
+ // A view's PRESENTATION (`kit/viewport-presentation`): what it draws with, lights by and
1136
+ // shows behind the scene. With a `layer`, the person's choice is recorded first — the
1137
+ // same write the toolbar's Lighting and Exposure make — and the view answers resolved.
1138
+ const requested = cmd['documentId'];
1139
+ if (typeof requested !== 'string' || requested === '') {
1140
+ return { ok: false, error: 'viewport-presentation needs a `documentId` (a document stage).' };
1141
+ }
1142
+ // The view is bound under its stage's id, the workspace's `document:` wrapper around the
1143
+ // document id `currentView` reports; either spelling reaches it.
1144
+ const documentId =
1145
+ viewPresentationBinding(requested) === null && viewPresentationBinding(`document:${requested}`) !== null
1146
+ ? `document:${requested}`
1147
+ : requested;
1148
+ const layer = cmd['layer'];
1149
+ if (layer !== undefined) {
1150
+ if (typeof layer !== 'object' || layer === null) {
1151
+ return { ok: false, error: 'viewport-presentation `layer` must be a presentation layer object.' };
1152
+ }
1153
+ setViewPresentation(documentId, layer as PresentationLayer);
1154
+ }
1155
+ // Or a NAMED view (`*.view.ts`), put on the view whole.
1156
+ const preset = cmd['preset'];
1157
+ if (preset !== undefined) {
1158
+ if (typeof preset !== 'string' || !applyViewPreset(documentId, preset)) {
1159
+ return {
1160
+ ok: false,
1161
+ error: `viewport-presentation \`preset\` must be one of: ${viewPresets().map((one) => one.id).join(', ') || '(none registered)'}.`,
1162
+ };
1163
+ }
1164
+ }
1165
+ return {
1166
+ ok: true,
1167
+ data: {
1168
+ presentation: viewPresentation(documentId),
1169
+ binding: viewPresentationBinding(documentId),
1170
+ bound: boundViewPresentations(),
1171
+ lastDraw: viewDrawReport(documentId),
1172
+ presets: studioPresets().map((preset) => preset.id),
1173
+ viewPresets: viewPresets().map((one) => one.id),
1174
+ environmentImages: environmentImages().map((one) => one.id),
1175
+ },
1176
+ };
1177
+ }
1178
+ case 'set-appearance': {
1179
+ // The MATERIAL apart from the bundle that usually carries it.
1180
+ // Appearance is palette × material by ruling (ARCHITECTURE-CORE
1181
+ // §Editor chrome), so nothing through the session could otherwise ask
1182
+ // "is it the blur or the palette?" about a stall a style switch
1183
+ // produces. This is the door that measures the axes apart; it applies
1184
+ // through the same preference writer the menu uses, so what the chrome
1185
+ // wears afterwards is what it answers.
1186
+ const material = cmd['material'];
1187
+ if (material === undefined) {
1188
+ return { ok: false, error: 'set-appearance needs a `material` to set.' };
1189
+ }
1190
+ if (!isEditorMaterialId(material)) {
1191
+ const ids = editorMaterials().map((choice) => choice.id);
1192
+ return {
1193
+ ok: false,
1194
+ error: `set-appearance material must be one of ${ids.join(', ')}, got ${String(material)}.`,
1195
+ };
1196
+ }
1197
+ setEditorMaterialPreference(material);
1198
+ return {
1199
+ ok: true,
1200
+ data: { material: editorMaterialSnapshot(), style: activeWorkspaceStyleId() },
1201
+ };
1202
+ }
1203
+ case 'present-view': {
1204
+ try {
1205
+ const presented = await presentEditorView(store, cmd['view'] as EditorView);
1206
+ return { ok: true, data: { ...presented } };
1207
+ } catch (error) {
1208
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1209
+ }
1210
+ }
1211
+ case 'current-view':
1212
+ return { ok: true, data: { view: currentEditorView(store) } };
1213
+ case 'inspect': {
1214
+ // `editor.inspect` — the SERIALIZED projection of the inspection model
1215
+ // (`inspection/serialize.ts`), composed from the same live state, by the
1216
+ // same composer, as the column and the compact card
1217
+ // (`inspection/active-subject.ts`). The `InspectedInspection` annotation
1218
+ // is the drift check: the SDK's wire mirror and the editor's own
1219
+ // serialized shape are structurally compared by `tsc` on every build —
1220
+ // including its `{none:true}` arm, which is what a human seeing no
1221
+ // inspector at all serializes to.
1222
+ const subject: InspectedInspection = inspectActiveSubject(store);
1223
+ return { ok: true, data: { subject } };
1224
+ }
1225
+ case 'run-inspection-action': {
1226
+ try {
1227
+ const actionId = cmd['actionId'];
1228
+ if (typeof actionId !== 'string' || actionId.trim() === '') {
1229
+ throw new Error('run-inspection-action requires a non-empty actionId.');
1230
+ }
1231
+ const subject: InspectedInspection = await runActiveInspectionAction(store, actionId);
1232
+ return { ok: true, data: { subject } };
1233
+ } catch (error) {
1234
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1235
+ }
1236
+ }
1237
+ case 'run-command': {
1238
+ // `editor.command(id, args)` — the ONE door to a command by id (U8's
1239
+ // ruling 1: "so the editor's `eval` command reaches it through the frame's command
1240
+ // service"). Under the frame that IS `ICommandService`; standalone it is
1241
+ // the views registry, and `editor-commands.ts` owns both arms plus
1242
+ // the refusal that names the id shape that would have worked.
1243
+ try {
1244
+ const commandId = cmd['commandId'];
1245
+ if (typeof commandId !== 'string' || commandId.trim() === '') {
1246
+ throw new Error('run-command requires a non-empty commandId.');
1247
+ }
1248
+ const result = await executeCommand(commandId, cmd['args']);
1249
+ // The command's own answer, as far as it survives the wire: a view
1250
+ // verb answers with its state, a workbench command usually with
1251
+ // nothing. `undefined` is not JSON, so it is reported as null rather
1252
+ // than dropping the key and making "ran, said nothing" look like a
1253
+ // malformed reply.
1254
+ return { ok: true, data: { result: result === undefined ? null : result } };
1255
+ } catch (error) {
1256
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1257
+ }
1258
+ }
1259
+ case 'hierarchy': {
1260
+ // `editor.hierarchy` — the hierarchy panel's OWN rendered row tree,
1261
+ // serialized. Not a fresh walk of the adapter: the panel publishes the
1262
+ // rows and predicates it rendered with and this serializes those, so the
1263
+ // door cannot report a tree the human is not looking at
1264
+ // (`hierarchy-panel-view.ts` header). `editor.status().entities` answers a
1265
+ // deliberately DIFFERENT question — the raw adapter tree, unprojected.
1266
+ const snapshot = hierarchyPanelSnapshot();
1267
+ if (snapshot === null) {
1268
+ return {
1269
+ ok: false,
1270
+ error:
1271
+ 'the hierarchy panel (GameHierarchy) is not mounted — no row tree is rendered, so there is nothing to report. Open the Hierarchy panel in the workspace dock and retry.',
1272
+ };
1273
+ }
1274
+ const hierarchy: InspectedHierarchy = serializeHierarchyPanel(snapshot, {
1275
+ playState: store.playState,
1276
+ activeViewportTab: store.activeViewportTab,
1277
+ });
1278
+ return { ok: true, data: { hierarchy } };
1279
+ }
1280
+ // EXPAND/COLLAPSE ALL, and they ship as a pair for a reason. The machine
1281
+ // door uses the panel's own mutations. Reading the raw adapter hierarchy
1282
+ // here would fabricate rows the panel has not rendered and would make
1283
+ // collapsed-branch certification meaningless; and expanding without a way
1284
+ // back is a ONE-WAY door — the fold is written to this project's persisted
1285
+ // preference, and a chevron's own click is not drivable through the
1286
+ // control API (`editor.document.click` refuses editor chrome by name), so
1287
+ // for as long as `expand-hierarchy-all` stood alone, no reader that used
1288
+ // it could ever see the tree's REST STATE again. Both call the toolbar's
1289
+ // own actions, so the human and machine paths cannot diverge. The ack
1290
+ // resolves on the panel's NEXT PUBLISHED SNAPSHOT, so a `hierarchy()` in
1291
+ // the same breath reads the mutated tree rather than the one before it.
1292
+ case 'expand-hierarchy-all':
1293
+ case 'collapse-hierarchy-all': {
1294
+ const expanding = cmd['type'] === 'expand-hierarchy-all';
1295
+ const snapshot = hierarchyPanelSnapshot();
1296
+ if (snapshot === null) {
1297
+ return {
1298
+ ok: false,
1299
+ error: `the hierarchy panel (GameHierarchy) is not mounted — no row tree is rendered, so there is nothing to ${expanding ? 'expand' : 'collapse'}. Open the Hierarchy panel in the workspace dock and retry.`,
1300
+ };
1301
+ }
1302
+ const committed = nextHierarchyPanelSnapshot();
1303
+ if (expanding) snapshot.expandAll();
1304
+ else snapshot.collapseAll();
1305
+ try {
1306
+ await committed;
1307
+ } catch (error) {
1308
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1309
+ }
1310
+ return { ok: true, data: {} };
1311
+ }
1312
+ // UNDO/REDO over the control API. The keyboard shortcut and the command
1313
+ // palette have always had this; an agent authoring through the editor did
1314
+ // not — and for an ingest root, whose only authoring surface IS the editor,
1315
+ // that left an edit with no way back. Same queue, same guards, same
1316
+ // per-transaction semantics as the key press.
1317
+ case 'undo':
1318
+ case 'redo': {
1319
+ const history = options?.history;
1320
+ if (!history) {
1321
+ return {
1322
+ ok: false,
1323
+ error: `${String(cmd['type'])}: this editor has no history session attached, so there is nothing to undo.`,
1324
+ };
1325
+ }
1326
+ const moved = cmd['type'] === 'undo' ? await history.undo() : await history.redo();
1327
+ const snapshot = history.getSnapshot();
1328
+ return {
1329
+ ok: true,
1330
+ data: {
1331
+ moved,
1332
+ canUndo: snapshot.canUndo,
1333
+ canRedo: snapshot.canRedo,
1334
+ undoLabel: snapshot.undoLabel,
1335
+ redoLabel: snapshot.redoLabel,
1336
+ },
1337
+ };
1338
+ }
1339
+ case 'set-inspection-field': {
1340
+ try {
1341
+ const path = cmd['path'];
1342
+ if (typeof path !== 'string' || path.trim() === '') {
1343
+ throw new Error('set-inspection-field requires a non-empty field path.');
1344
+ }
1345
+ // THE ACK NAMES ITS DESTINATION. A write with no persistence route
1346
+ // still ACKs `ok` — it lands on the live object — so without `write`
1347
+ // the caller cannot tell a persisted edit from a vanished one, and
1348
+ // `scripts/doctor-walk.ts` graded a healthy consent-off session as a
1349
+ // silent no-op on exactly that ambiguity.
1350
+ const written: { subject: InspectedInspection; write: InspectedWriteDestination } =
1351
+ await setActiveInspectionField(store, path, cmd['value']);
1352
+ return { ok: true, data: { subject: written.subject, write: written.write } };
1353
+ } catch (error) {
1354
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1355
+ }
1356
+ }
1357
+ // THE OTHER HALF OF THE WRITE DOOR, and the only one that can express
1358
+ // byte-ABSENCE. `set-inspection-field` writes a VALUE, so reverting a prop
1359
+ // an authoring gesture APPENDED leaves an explicit `[0, 0, 0]` where the
1360
+ // source carried nothing — the file ends one attribute heavier than it
1361
+ // started and no byte-level round trip can close. The human revert arrow
1362
+ // has reached `io.remove` since it shipped; the control API had no door at
1363
+ // all, which made every agent-driven edit/revert unverifiable at byte
1364
+ // level on lanes that were in fact healthy.
1365
+ //
1366
+ // Same io, same persistence pipe, same awaited per-edit ack as `set`.
1367
+ case 'remove-inspection-field': {
1368
+ try {
1369
+ const path = cmd['path'];
1370
+ if (typeof path !== 'string' || path.trim() === '') {
1371
+ throw new Error('remove-inspection-field requires a non-empty field path.');
1372
+ }
1373
+ const removed: { subject: InspectedInspection; write: InspectedWriteDestination } =
1374
+ await removeActiveInspectionField(store, path);
1375
+ return { ok: true, data: { subject: removed.subject, write: removed.write } };
1376
+ } catch (error) {
1377
+ // A LANE WITH NO REMOVAL DOOR IS NOT A FAILED REMOVAL, and the caller
1378
+ // has to be able to tell them apart without matching prose: an
1379
+ // instrument grades the first UNVERIFIABLE (an unreached seam) and the
1380
+ // second FAILED (a removal that ran and left the bytes changed).
1381
+ if (error instanceof InspectionRemovalUnavailableError) {
1382
+ return { ok: false, error: error.message, data: { code: 'REMOVAL_UNAVAILABLE' } };
1383
+ }
1384
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1385
+ }
1386
+ }
1387
+ // THE STRUCTURE OPS over the control API — the same operations the
1388
+ // hierarchy row's context menu performs, on the same
1389
+ // `authoring/consumer-actions.ts` helpers, so there is one implementation
1390
+ // and not a second that can disagree with the menu.
1391
+ //
1392
+ // A COMMAND VERB rather than inspector `quickActions` deliberately:
1393
+ // `inspect().quickActions` reports the verbs a human sees on the
1394
+ // inspector's identity row, and a dozen structure icons there would be
1395
+ // either a UI redesign or a list of actions nobody can see — both worse
1396
+ // than transcribing what the component verbs already established for
1397
+ // exactly this gap (`@volter/editor-game/contributions/component-verbs.command.ts`,
1398
+ // which is where `extract-component`/`fork-component` live now).
1399
+ //
1400
+ // `id`/`ids` default to the current selection, the menu's own subject. An
1401
+ // op the active adapter does not provide answers `ok: false` naming it,
1402
+ // never a silent no-op.
1403
+ case 'structure-op': {
1404
+ try {
1405
+ const op = String(cmd['op'] ?? '');
1406
+ // THE ACTIVE DOCUMENT'S ADAPTER AND ITS SELECTION, which is the pair
1407
+ // every other consumer of this seam already reads (`editor-hotkeys.ts`'s
1408
+ // `actionSelectionIds`, the hierarchy panel, `editor.status()`). This
1409
+ // case read `getActiveAuthoring` + the SHELL store instead, so on a
1410
+ // document whose adapter owns its own selection — the Blender Model
1411
+ // document, whose Outliner publishes Blender row ids — it refused with
1412
+ // "the active authoring adapter exposes no structure provider" while
1413
+ // the document's own adapter had one and the panel was drawing its
1414
+ // selection (measured 2026-09-21: `editor.structure('duplicate')` on a
1415
+ // selected Torus, B6's last named leftover).
1416
+ const adapter = resolvePanelAuthoring(store).adapter;
1417
+ const selected = activeSelectionIds(store);
1418
+ const ids = Array.isArray(cmd['ids'])
1419
+ ? (cmd['ids'] as unknown[]).filter((v): v is string => typeof v === 'string')
1420
+ : typeof cmd['id'] === 'string' && cmd['id'].trim() !== ''
1421
+ ? [cmd['id']]
1422
+ : selected;
1423
+ const first = ids[0];
1424
+ const needsId = (): string => {
1425
+ if (!first) throw new Error(`structure-op "${op}" needs an id or a selected row.`);
1426
+ return first;
1427
+ };
1428
+ const optional = (key: string): string | undefined =>
1429
+ typeof cmd[key] === 'string' && (cmd[key] as string).trim() !== ''
1430
+ ? (cmd[key] as string)
1431
+ : undefined;
1432
+ const structure = adapter.structure;
1433
+ if (!structure) {
1434
+ return {
1435
+ ok: false,
1436
+ error: 'structure-op: the active authoring adapter exposes no structure provider.',
1437
+ };
1438
+ }
1439
+ switch (op) {
1440
+ case 'create': {
1441
+ const kind = optional('kind');
1442
+ if (!kind) return { ok: false, error: 'structure-op "create" needs a `kind`.' };
1443
+ const created = createAuthoringNode(adapter, kind, optional('parentId'));
1444
+ return { ok: true, data: { id: created.id, write: await created.ack } };
1445
+ }
1446
+ case 'delete': {
1447
+ const write =
1448
+ ids.length > 1 && structure.removeMany
1449
+ ? await removeManyAuthoringNodes(adapter, ids)
1450
+ : await removeAuthoringNode(adapter, needsId());
1451
+ return { ok: true, data: { write } };
1452
+ }
1453
+ case 'duplicate': {
1454
+ if (ids.length > 1 && structure.duplicateMany) {
1455
+ const write = await duplicateManyAuthoringNodes(adapter, ids);
1456
+ return { ok: true, data: { write } };
1457
+ }
1458
+ const copy = duplicateAuthoringNode(adapter, needsId());
1459
+ return { ok: true, data: { id: copy.id, write: await copy.ack } };
1460
+ }
1461
+ case 'reparent': {
1462
+ const parentId = optional('parentId') ?? null;
1463
+ return {
1464
+ ok: true,
1465
+ data: { write: await reparentAuthoringNode(adapter, needsId(), parentId) },
1466
+ };
1467
+ }
1468
+ case 'reorder': {
1469
+ if (!structure.reorder) {
1470
+ return { ok: false, error: 'structure-op: this adapter has no `reorder`.' };
1471
+ }
1472
+ const before = optional('beforeSiblingId') ?? null;
1473
+ return {
1474
+ ok: true,
1475
+ data: { write: await reorderAuthoringNode(adapter, needsId(), before) },
1476
+ };
1477
+ }
1478
+ case 'wrap': {
1479
+ if (!structure.wrap) {
1480
+ return { ok: false, error: 'structure-op: this adapter has no `wrap`.' };
1481
+ }
1482
+ return {
1483
+ ok: true,
1484
+ data: { write: await wrapAuthoringNode(adapter, needsId(), optional('tag')) },
1485
+ };
1486
+ }
1487
+ case 'unwrap': {
1488
+ if (!structure.unwrap) {
1489
+ return { ok: false, error: 'structure-op: this adapter has no `unwrap`.' };
1490
+ }
1491
+ return { ok: true, data: { write: await unwrapAuthoringNode(adapter, needsId()) } };
1492
+ }
1493
+ case 'group': {
1494
+ if (!structure.group) {
1495
+ return { ok: false, error: 'structure-op: this adapter has no `group`.' };
1496
+ }
1497
+ const grouped = groupAuthoringNodes(adapter, ids);
1498
+ return { ok: true, data: { id: grouped.id, write: await grouped.ack } };
1499
+ }
1500
+ case 'ungroup': {
1501
+ if (!structure.ungroup) {
1502
+ return { ok: false, error: 'structure-op: this adapter has no `ungroup`.' };
1503
+ }
1504
+ const ungrouped = ungroupAuthoringNode(adapter, needsId());
1505
+ return { ok: true, data: { ids: ungrouped.ids, write: await ungrouped.ack } };
1506
+ }
1507
+ case 'copy': {
1508
+ if (!structure.copy) {
1509
+ return { ok: false, error: 'structure-op: this adapter has no `copy`.' };
1510
+ }
1511
+ return { ok: true, data: { copied: await copyAuthoringNodes(adapter, ids) } };
1512
+ }
1513
+ case 'cut': {
1514
+ if (!structure.cut) {
1515
+ return { ok: false, error: 'structure-op: this adapter has no `cut`.' };
1516
+ }
1517
+ const outcome = await cutAuthoringNodes(adapter, ids);
1518
+ return outcome === false
1519
+ ? { ok: false, error: 'structure-op "cut" was refused by the adapter.' }
1520
+ : { ok: true, data: { write: outcome } };
1521
+ }
1522
+ case 'paste': {
1523
+ if (!structure.paste) {
1524
+ return { ok: false, error: 'structure-op: this adapter has no `paste`.' };
1525
+ }
1526
+ const parentId =
1527
+ optional('parentId') ??
1528
+ (first ? (adapter.hierarchy.node(first)?.parentId ?? null) : null);
1529
+ const outcome = await pasteAuthoringNodes(adapter, parentId);
1530
+ return outcome === false
1531
+ ? { ok: false, error: 'structure-op "paste" was refused by the adapter.' }
1532
+ : { ok: true, data: { write: outcome } };
1533
+ }
1534
+ default:
1535
+ return {
1536
+ ok: false,
1537
+ error:
1538
+ `structure-op: unknown op ${JSON.stringify(op)}. Known ops: create, delete, ` +
1539
+ 'duplicate, reparent, reorder, wrap, unwrap, group, ungroup, copy, cut, paste.',
1540
+ };
1541
+ }
1542
+ } catch (error) {
1543
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1544
+ }
1545
+ }
1546
+ case 'document-probe': {
1547
+ // `editor.document.*` — the scoped editor-chrome door. Every refusal
1548
+ // (no active document, unmounted surface, target outside the scope)
1549
+ // comes back as the step's own honest error text, because the scope
1550
+ // NAME is the useful half of the answer
1551
+ // (`editor-document-probe.ts`'s header).
1552
+ try {
1553
+ const result = await runDocumentProbe(cmd['step'] as DocumentProbeStep);
1554
+ return { ok: true, data: { ...result } };
1555
+ } catch (error) {
1556
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1557
+ }
1558
+ }
1559
+ case 'capture-active-document': {
1560
+ const requested = captureSizeFromCommand(cmd);
1561
+ if ('error' in requested) return { ok: false, error: requested.error };
1562
+ try {
1563
+ if (cmd['view']) await presentEditorView(store, cmd['view'] as EditorView);
1564
+ const capture = await captureActiveEditorDocument(store, requested.size);
1565
+ announcePhotograph(capture.base64);
1566
+ return { ok: true, data: { ...capture } };
1567
+ } catch (error) {
1568
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1569
+ }
1570
+ }
1571
+ case 'capture-editor-chrome': {
1572
+ // The editor PAGE itself — see `editor-chrome-capture.ts`. The page is
1573
+ // photographed at its own LAYOUT; `scale` chooses only how many output
1574
+ // pixels one CSS pixel becomes, defaulting to the display's own ratio.
1575
+ // A stroke weight compared against a 2x reference needs `scale: 2`.
1576
+ const scale = cmd['scale'];
1577
+ if (scale !== undefined && (typeof scale !== 'number' || !(scale > 0) || scale > 4)) {
1578
+ return {
1579
+ ok: false,
1580
+ error:
1581
+ 'capture-editor-chrome: "scale" must be a number greater than 0 and no more than 4 ' +
1582
+ '(output pixels per CSS pixel; the frame crosses the relay as base64 PNG). ' +
1583
+ "It defaults to the page's own devicePixelRatio.",
1584
+ };
1585
+ }
1586
+ const region = cmd['region'];
1587
+ if (region !== undefined && region !== 'page' && region !== 'document' && region !== 'play') {
1588
+ return { ok: false, error: 'capture-editor-chrome: "region" is "page", "document" or "play".' };
1589
+ }
1590
+ const name = cmd['name'];
1591
+ if (name !== undefined && (typeof name !== 'string' || name.trim() === '' || name.length > 80)) {
1592
+ return { ok: false, error: 'capture-editor-chrome: "name" is a few words saying what the photograph is of (1 to 80 characters).' };
1593
+ }
1594
+ try {
1595
+ // The whole page holds the corner picture; a document or play region never does.
1596
+ if (region === undefined || region === 'page') withdrawPhotograph();
1597
+ const capture = await captureEditorChrome(store, {
1598
+ ...(scale === undefined ? {} : { scale: scale as number }),
1599
+ ...(region === undefined ? {} : { region }),
1600
+ });
1601
+ announcePhotograph(capture.base64, typeof name === 'string' ? name.trim() : undefined);
1602
+ return { ok: true, data: { ...capture } };
1603
+ } catch (error) {
1604
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1605
+ }
1606
+ }
1607
+ /**
1608
+ * OPEN one piece of the adapter's scene table by id — the table's ONE
1609
+ * `open` verb (ARCHITECTURE-CORE §Editor). One verb for a scene, a prefab,
1610
+ * or a story state, because the table makes them siblings: they differ only
1611
+ * in instance site.
1612
+ *
1613
+ * TWO CLIENTS, one verb. With a game LIVE in this session the live half
1614
+ * answers first (`scene-live-open.ts`): the running game IS the surface, so
1615
+ * a `game-contract` scene is opened by asking the game to navigate and a
1616
+ * `root-mount` scene by showing the document it draws into. It answers
1617
+ * `null` for the reaches that are the Edit workspace's
1618
+ * (`components/scene-documents.tsx`), which then runs unchanged.
1619
+ *
1620
+ * Both halves answer from the SAME declared field — the live half switches
1621
+ * on `entry.reach.kind`, and the Edit half's `liveReach` is derived from
1622
+ * `entry.reach` alone (`scene-document-plan.ts`'s `liveReachOf`).
1623
+ * `authorable` decides the Edit DOCUMENT and nothing else — it is not asked
1624
+ * here, because whether a running game can be sent to one of its own screens
1625
+ * is the game's contract to answer, not the host's opinion of whether that
1626
+ * screen is authorable.
1627
+ *
1628
+ * The two halves agree about every reach the live half REACHES:
1629
+ * `liveReach` is `'game-contract' | 'root-mount' | 'entrypoint-selection'`,
1630
+ * exactly the set that ends `ok: true` there when the session can honour
1631
+ * it, so with nothing running both refuse as
1632
+ * `SCENE_NAVIGATION_NOT_RUNNING` rather than as a dead end.
1633
+ * A session with no remount seam still refuses `entrypoint-selection`
1634
+ * as `SCENE_NOT_OPENABLE_LIVE` — that is a session fact, not a reach
1635
+ * fact.
1636
+ *
1637
+ * Every refusal is CODED, because the classes are graded differently and
1638
+ * prose cannot separate them: `SCENE_NOT_FOUND` names the ids that DO
1639
+ * exist, `SCENE_NOT_OPENABLE` quotes the adapter's own declared reason
1640
+ * verbatim rather than paraphrasing a claim about someone else's game, and
1641
+ * `SCENE_NAVIGATION_NOT_RUNNING` says the scene is live-only rather than
1642
+ * unreachable.
1643
+ */
1644
+ // The document table the host resolved (ARCHITECTURE-CORE §The project
1645
+ // model, "Documents, not scenes"), as the wire projection `getState`
1646
+ // already carries. A COMMAND rather than a state read so a standing
1647
+ // document that lists the table (a production's Shots bin) is driven the
1648
+ // same way every other surface is.
1649
+ case 'document-table': {
1650
+ const facet = projectAdapterFacet();
1651
+ if (!facet) {
1652
+ return {
1653
+ ok: false,
1654
+ error: 'The project adapter has not resolved yet — no document table to list.',
1655
+ data: { code: 'SCENE_TABLE_UNAVAILABLE' },
1656
+ };
1657
+ }
1658
+ return {
1659
+ ok: true,
1660
+ data: {
1661
+ default: facet.scenes.default ?? null,
1662
+ entries: facet.scenes.entries,
1663
+ pending: facet.documentsPending === true,
1664
+ },
1665
+ };
1666
+ }
1667
+
1668
+ case 'open': {
1669
+ const id = cmd['id'];
1670
+ if (typeof id !== 'string' || id.trim() === '') {
1671
+ return { ok: false, error: 'open requires a non-empty scene-table entry id.' };
1672
+ }
1673
+ // Play's `open()` is live navigation. Edit's `open()` is the tab row —
1674
+ // an ingest mount is not Play (ARCHITECTURE-CORE: every authorable scene
1675
+ // is an Edit document). Routing ingest through the live half is how
1676
+ // opening GameScreen put the Play document up.
1677
+ // A deferred ingest run is a real Play SESSION even though it does not
1678
+ // allocate `play-mode.ts`'s first-party session object. Use the shared
1679
+ // mode predicate so opening an adapter scene reaches that running game's
1680
+ // contract instead of silently falling back to its Edit document.
1681
+ const liveGame = editorIsPlaying();
1682
+ // An entry whose KIND has its own document editor (a model, a machine, a page) opens in
1683
+ // that editor during Play too; only the rest navigate the running game.
1684
+ const entry = liveGame
1685
+ ? projectAdapterFacet()?.scenes.entries.find(
1686
+ (candidate) => candidate.id === id && documentContributionForKind(candidate.kind) === undefined,
1687
+ )
1688
+ : undefined;
1689
+ // The running lane's own remount (Play), asked of the live
1690
+ // registry so this verb names no lane.
1691
+ const remountSelection = liveGame
1692
+ ? async (args: { selection: string; key: string; regionId: string }) =>
1693
+ (await remountLiveSelection(args)) ?? {
1694
+ ok: false as const,
1695
+ error: 'No running lane can remount with a selection.',
1696
+ }
1697
+ : undefined;
1698
+ const live = entry
1699
+ ? await openLiveSceneEntry(entry, {
1700
+ scenes: liveScenes,
1701
+ activateGameDocument: () => activateWorkspaceDocument(GAME_DOCUMENT_ID),
1702
+ gameDocumentId: GAME_DOCUMENT_ID,
1703
+ ...(remountSelection ? { remountSelection } : {}),
1704
+ })
1705
+ : null;
1706
+ const opened = live ?? (await openSceneTableEntryWhenListed(id));
1707
+ return opened.ok
1708
+ ? {
1709
+ ok: true,
1710
+ data: {
1711
+ documentId: opened.documentId,
1712
+ title: opened.title,
1713
+ ...('scene' in opened && opened.scene ? { scene: opened.scene } : {}),
1714
+ ...('restart' in opened && opened.restart ? { restart: true } : {}),
1715
+ },
1716
+ }
1717
+ : {
1718
+ ok: false,
1719
+ error: opened.error,
1720
+ data: {
1721
+ code: opened.code,
1722
+ ...(opened.known === undefined ? {} : { known: [...opened.known] }),
1723
+ },
1724
+ };
1725
+ }
1726
+
1727
+ // The REPL door over the ACTIVE document's published context — Edit mode,
1728
+ // no play gate (`document-context-registry.ts`).
1729
+ case 'document-script':
1730
+ return handleDocumentScript(cmd);
1731
+
1732
+ // Version-skew honesty (the false-ack fix):
1733
+ // every unrecognized command type used to fall through to `return {ok:
1734
+ // true}` below — a silent lie: the browser did NOTHING, but the SDK/CLI
1735
+ // caller was told it succeeded. Reporting a structured failure must hold
1736
+ // for ANY unrecognized `type`, not just the ones known about today. The
1737
+ // `data.code` marker is what the SDK maps to its `*_UNSUPPORTED` codes;
1738
+ // the prose is for humans only.
1739
+ default: {
1740
+ // Unreachable: `isRelayCommandType` above already answered for anything
1741
+ // outside the table. This assignment is the EXHAUSTIVENESS CHECK — it
1742
+ // compiles only while every row of `RELAY_COMMANDS` has a case here.
1743
+ const unhandled: never = commandType;
1744
+ return {
1745
+ ok: false,
1746
+ error: `unknown command type "${String(unhandled)}" — editor page predates this CLI`,
1747
+ data: { code: 'UNKNOWN_COMMAND_TYPE' },
1748
+ };
1749
+ }
1750
+ }
1751
+
1752
+ return { ok: true };
1753
+ }
1754
+
1755
+ /**
1756
+ * Connect the command listener to the editor server's SSE stream.
1757
+ * Dispatches incoming commands to the store and play-mode functions.
1758
+ * Reports state after each command and on initial connect.
1759
+ */
1760
+ export function connectCommandListener(
1761
+ store: ShellStore,
1762
+ /**
1763
+ * The session's own undo/redo queue — the SAME object the keyboard shortcut
1764
+ * and the command palette drive, so a relayed undo is the user's undo and not
1765
+ * a second path into history. Omitted by the isolated component tests that
1766
+ * only need the listener's transport half; the verbs then refuse by name
1767
+ * rather than reaching for `store.projectHistory` behind the queue's back.
1768
+ */
1769
+ history?: HistoryCommands,
1770
+ ): () => void {
1771
+ // Browser mode (Phase A2): the `volter` CLI control channel is server-only (it
1772
+ // is an SSE command stream + state POSTs to `/__editor/*`, which do not exist
1773
+ // without a Node server). Skip it — otherwise the EventSource retry-loops
1774
+ // against a 404 and every `reportEditorState` POSTs into the void.
1775
+ const source = connectEvents();
1776
+
1777
+ // The last FULL snapshot this page sent. Every report on a user's critical
1778
+ // path reuses its derived facets rather than re-deriving them — see
1779
+ // `state-report-deferral.ts` for the measurement that bought this.
1780
+ let lastFullState: Record<string, unknown> | null = null;
1781
+ /**
1782
+ * The store's `contentVersion` at the moment `lastFullState` was collected.
1783
+ *
1784
+ * The facets `collectState` is allowed to REUSE are the tree-scale ones — the
1785
+ * hierarchy rows and the capability grading. When the store notifies with
1786
+ * nothing but a selection change, `contentVersion` holds still, and every
1787
+ * reused facet in `lastFullState` is therefore already current: scheduling a
1788
+ * full re-derivation to "make it current again" re-derives an answer it
1789
+ * already has. Measured at N=20000 on the canvas lane as a 54-70ms
1790
+ * `IdleRequestCallback @ command-listener.ts` block after EVERY click.
1791
+ */
1792
+ let lastFullContentVersion = -1;
1793
+ // One entry per read-only debug proof in the CURRENT adapter epoch. The epoch is in the key, so
1794
+ // remounts naturally pay for (and publish) their own first proof while repeated clock/snapshot
1795
+ // polls reuse the full report that already contains the identical verdict.
1796
+ const reportedDebugReadProofs = new Set<string>();
1797
+ /**
1798
+ * A Play scene transition is a burst, not one store notification. The
1799
+ * translated FPS measured 4,523 hierarchy objects and several asynchronous
1800
+ * React commits between `unity.load_scene.MainScene` returning and the tree
1801
+ * becoming quiescent. Re-deriving on every notification caused 325-430ms
1802
+ * main-thread blocks and eventually starved the command relay; deriving on
1803
+ * the command's first notification left the cached hierarchy at IntroMenu.
1804
+ *
1805
+ * Arm exactly one refresh for the burst and move it behind a short quiet
1806
+ * window. Once it lands, ordinary runtime structure, input, state polling,
1807
+ * presence and store reports cannot arm another one. A later explicit game
1808
+ * command/read or Play boundary can arm the next transition honestly.
1809
+ */
1810
+ let playFullReportArmed = false;
1811
+ let playSettleTimer: ReturnType<typeof setTimeout> | null = null;
1812
+ let playCommandContentVersion: number | null = null;
1813
+ let playCommandContentTimer: ReturnType<typeof setTimeout> | null = null;
1814
+ // A tombstone stops POSTing snapshots too. the editor's `status` command reads the server's
1815
+ // last snapshot, so a corpse that kept reporting would keep MINTING
1816
+ // fresh-looking state for a session that no longer exists — the exact
1817
+ // impersonation the tombstone latch exists to end.
1818
+ const reportLatestState = () => {
1819
+ if (sessionEndedState() !== null) return;
1820
+ lastFullState = collectState(store);
1821
+ lastFullContentVersion = store.contentVersion;
1822
+ void reportEditorState(lastFullState);
1823
+ };
1824
+ let cancelDeferredFullReport: (() => void) | null = null;
1825
+ const cancelPlaySettledFullReport = () => {
1826
+ if (playSettleTimer !== null) {
1827
+ clearTimeout(playSettleTimer);
1828
+ playSettleTimer = null;
1829
+ }
1830
+ cancelDeferredFullReport?.();
1831
+ cancelDeferredFullReport = null;
1832
+ };
1833
+ const clearPlayCommandContentCandidate = () => {
1834
+ playCommandContentVersion = null;
1835
+ if (playCommandContentTimer !== null) clearTimeout(playCommandContentTimer);
1836
+ playCommandContentTimer = null;
1837
+ };
1838
+ const schedulePlaySettledFullReport = () => {
1839
+ clearPlayCommandContentCandidate();
1840
+ playFullReportArmed = true;
1841
+ cancelPlaySettledFullReport();
1842
+ playSettleTimer = setTimeout(() => {
1843
+ playSettleTimer = null;
1844
+ cancelDeferredFullReport = scheduleDeferredFullReport(() => {
1845
+ cancelDeferredFullReport = null;
1846
+ playFullReportArmed = false;
1847
+ reportLatestState();
1848
+ });
1849
+ }, 250);
1850
+ };
1851
+ const armPlayCommandContentCandidate = (baseline: number) => {
1852
+ if (store.contentVersion !== baseline) {
1853
+ schedulePlaySettledFullReport();
1854
+ return;
1855
+ }
1856
+ clearPlayCommandContentCandidate();
1857
+ playCommandContentVersion = baseline;
1858
+ playCommandContentTimer = setTimeout(clearPlayCommandContentCandidate, 1_000);
1859
+ };
1860
+ const editorIsActivelyPlaying = () => editorIsPlaying() && store.playState === 'playing';
1861
+ const settleUnscopedPlayCommandRefresh = (commandStartContentVersion: number | null) => {
1862
+ if (commandStartContentVersion !== null) {
1863
+ // A successful relayed command whose declared policy still owes a
1864
+ // derived refresh must not disappear merely because it does not carry
1865
+ // one of the special debug-invoke transition scopes above. Read commands
1866
+ // reach here only when contentVersion moved; `always` mutation/boundary
1867
+ // commands reach here by declaration. Ordinary store notifications have
1868
+ // no command baseline and remain cheap during Play.
1869
+ schedulePlaySettledFullReport();
1870
+ } else if (
1871
+ playCommandContentVersion !== null &&
1872
+ store.contentVersion !== playCommandContentVersion
1873
+ ) {
1874
+ schedulePlaySettledFullReport();
1875
+ } else if (playFullReportArmed && store.contentVersion !== lastFullContentVersion) {
1876
+ // A transition already earned one refresh. Keep moving that ONE
1877
+ // refresh behind the mount burst; this does not arm periodic work.
1878
+ schedulePlaySettledFullReport();
1879
+ }
1880
+ };
1881
+ const handledByPlayReportGate = (
1882
+ playFullReport: CompletedCommandRefresh['playFullReport'],
1883
+ commandStartContentVersion: number | null,
1884
+ derivedRefresh: RelayCommandDerivedRefresh,
1885
+ ): boolean => {
1886
+ if (!editorIsActivelyPlaying()) return false;
1887
+ if (
1888
+ !playCommandOwesDerivedRefresh(
1889
+ derivedRefresh,
1890
+ playFullReport,
1891
+ store.contentVersion,
1892
+ lastFullContentVersion,
1893
+ )
1894
+ )
1895
+ return true;
1896
+ if (playFullReport === 'explicit') {
1897
+ schedulePlaySettledFullReport();
1898
+ } else if (playFullReport === 'if-content-changed') {
1899
+ armPlayCommandContentCandidate(commandStartContentVersion ?? store.contentVersion);
1900
+ } else settleUnscopedPlayCommandRefresh(commandStartContentVersion);
1901
+ return true;
1902
+ };
1903
+ /**
1904
+ * Report NOW with everything cheap fresh, and make the expensive halves
1905
+ * current in the background.
1906
+ *
1907
+ * This is the path every interaction takes. The immediate POST is what keeps
1908
+ * the "UI Play/Stop is visible to the editor's `status` command immediately" contract and
1909
+ * every other same-tick freshness promise in this file — playState, loop
1910
+ * liveness, selection, save state, presence and the error channels are all
1911
+ * derived fresh here. What it does NOT do is re-walk the hierarchy and
1912
+ * re-grade every capability on the frame the user clicked; a single deferred
1913
+ * full collect does that once, however many changes arrived in the burst.
1914
+ */
1915
+ const reportCurrentState = (
1916
+ /**
1917
+ * `'if-content-changed'` skips the deferred full re-derivation when the
1918
+ * store's `contentVersion` has not moved since the last full collect —
1919
+ * see `lastFullContentVersion`. ONLY the store-notification path may ask
1920
+ * for it: every other trigger here (restart-required, the ingest capture
1921
+ * wait, the adapter load, focus/blur, a completed command) can change a
1922
+ * reused facet WITHOUT any store notification at all, and the store's
1923
+ * version knows nothing about them.
1924
+ */
1925
+ derivedRefresh: RelayCommandDerivedRefresh = 'always',
1926
+ playFullReport: CompletedCommandRefresh['playFullReport'] = 'none',
1927
+ commandStartContentVersion: number | null = null,
1928
+ ) => {
1929
+ if (sessionEndedState() !== null) return;
1930
+ if (lastFullState === null) {
1931
+ // Nothing to reuse yet — the honest floor is to pay for a real collect
1932
+ // rather than report a fabricated or empty derivation.
1933
+ reportLatestState();
1934
+ return;
1935
+ }
1936
+ const state = collectState(store, lastFullState);
1937
+ lastFullState = state;
1938
+ void reportEditorState(currentStatePatch(state));
1939
+ if (derivedRefresh === 'none') return;
1940
+ if (handledByPlayReportGate(playFullReport, commandStartContentVersion, derivedRefresh)) return;
1941
+ // Pause/stop leave a complete, stable surface and must publish it. Any
1942
+ // pending Play refresh is superseded by this non-presenting full report.
1943
+ cancelPlaySettledFullReport();
1944
+ clearPlayCommandContentCandidate();
1945
+ playFullReportArmed = false;
1946
+ if (
1947
+ derivedRefresh === 'if-content-changed' &&
1948
+ store.contentVersion === lastFullContentVersion
1949
+ ) {
1950
+ return;
1951
+ }
1952
+ if (cancelDeferredFullReport !== null) return;
1953
+ cancelDeferredFullReport = scheduleDeferredFullReport(() => {
1954
+ cancelDeferredFullReport = null;
1955
+ reportLatestState();
1956
+ });
1957
+ };
1958
+ let storeReportScheduled = false;
1959
+ /** A burst that included any trigger OTHER than a store notify takes the
1960
+ * deferred full refresh unconditionally — see `reportCurrentState`'s
1961
+ * `derivedRefresh` argument for why the store's version cannot speak for
1962
+ * those. Widening, never narrowing: one such trigger in a burst is enough. */
1963
+ let storeReportNeedsFullRefresh = false;
1964
+ let reportedStorePlayState = store.playState;
1965
+ const scheduleStateReport = (needsFullRefresh: boolean) => {
1966
+ // One editor action commonly emits several store notifications. Collapse
1967
+ // that synchronous burst into one current snapshot without delaying it a
1968
+ // frame — UI Play/Stop must be visible to the editor's `status` command immediately even
1969
+ // though no relayed command caused the transition.
1970
+ if (needsFullRefresh) storeReportNeedsFullRefresh = true;
1971
+ if (storeReportScheduled) return;
1972
+ storeReportScheduled = true;
1973
+ queueMicrotask(() => {
1974
+ storeReportScheduled = false;
1975
+ const forceRefresh = storeReportNeedsFullRefresh;
1976
+ storeReportNeedsFullRefresh = false;
1977
+ const playBoundary = reportedStorePlayState !== store.playState;
1978
+ reportedStorePlayState = store.playState;
1979
+ reportCurrentState(
1980
+ forceRefresh ? 'always' : 'if-content-changed',
1981
+ playBoundary && store.playState === 'playing' ? 'explicit' : 'none',
1982
+ );
1983
+ });
1984
+ };
1985
+ const reportStoreChange = () => scheduleStateReport(false);
1986
+ const reportExternalChange = () => scheduleStateReport(true);
1987
+ const unsubscribeStoreReport = store.subscribe(reportStoreChange);
1988
+ // R1 — restart-required transitions don't flow through the store (they
1989
+ // have their own listener set in play-mode.ts), but the editor's `status` command readers
1990
+ // need `restartRequired` fresh even when NO editor command caused the
1991
+ // change (an external agent editing an R3F entry mid-play is exactly the
1992
+ // silent-staleness case R1 closes). Re-POST state on every transition.
1993
+ const unsubscribeRestartReport = subscribeLiveSessions(reportExternalChange);
1994
+ // Same reason, for the ingest capture wait: it starts and ends outside any
1995
+ // store notification (a mount awaiting its game's first frame), and on a
1996
+ // hidden tab it can hold for as long as the human is away. Without this the
1997
+ // server's snapshot would predate the wait entirely, so the editor's `status` command would
1998
+ // answer "nothing is ingested" for a mount that is very much in flight. The
1999
+ // wait's OTHER transition — parked↔running as the tab hides and shows —
2000
+ // already re-POSTs through `reportPresence`'s `visibilitychange` listener.
2001
+ // Same reason again, for the project's ADAPTER: it loads asynchronously at
2002
+ // editor init and on project switches, outside any store notification. The
2003
+ // adapter facet is the proof that a project's `editor/volter.adapter.ts` (or the
2004
+ // declared native default) loaded at all, so a snapshot that predates the
2005
+ // load would answer "no adapter" for one that is loaded and live.
2006
+ const unsubscribeAdapterReport = subscribeProjectAdapter(reportExternalChange);
2007
+ // A viewport's first completed frame also lands outside the store. Report
2008
+ // it proactively so remote readers can poll the server-held state instead
2009
+ // of injecting no-op `active-tab` commands while a large board is rendering.
2010
+ const unsubscribeViewportActivationReport =
2011
+ subscribeViewportActivationTimings(reportExternalChange);
2012
+
2013
+ // Report initial state
2014
+ reportLatestState();
2015
+ // …and the standing fact the state snapshot cannot carry: this page is now
2016
+ // running a command listener. Before this, a page that beat but never got
2017
+ // here was indistinguishable from a healthy one until somebody sent a
2018
+ // command and watched it hang. See `reportCommandListener`.
2019
+ void reportCommandListener(true);
2020
+ // The other standing fact of that shape: which step of a play boot this page
2021
+ // is inside. Wired HERE because this is the module that owns "facts this page
2022
+ // reports upstream", and because a phase is only useful to a reader who can
2023
+ // also see the command it explains. See `play-boot-phase.ts`.
2024
+ setPlayBootPhaseReporter(reportPlayBootPhase);
2025
+
2026
+ // And again on every RE-open. The server drops a tab's health snapshot
2027
+ // when its connection goes, and a tab with no health is deliberately never
2028
+ // chosen as the command controller (`editor-sse.ts`'s `selectedController`:
2029
+ // an identified tab that has not reported yet is connected but not
2030
+ // command-ready). Without this, a reconnect — the control socket's backoff
2031
+ // after any blip — left the tab uncontrollable until the next store
2032
+ // change, presence event, or command happened to fire.
2033
+ source.addEventListener('open', () => {
2034
+ // A reconnect is transport state, not a project mutation. In Play, a full
2035
+ // reconnect collect was enough to stall a large world and provoke another
2036
+ // disconnect; reuse the last honest derived facets and publish the cheap
2037
+ // liveness/presence fields immediately.
2038
+ reportCurrentState('if-content-changed');
2039
+ // Listener readiness belongs to the PAGE, but the server deliberately
2040
+ // drops its cached proof when this page has no connection left. Re-prove
2041
+ // it on the successor connection instead of leaving status at
2042
+ // `not attached` until some unrelated state transition happens.
2043
+ void reportCommandListener(sessionEndedState() === null);
2044
+ });
2045
+
2046
+ // A page whose session has ENDED must leave the bijection's account of live
2047
+ // tabs rather than sit in it answering probes. Reporting the listener
2048
+ // detached is exactly the fact `server/server-utils.ts`'s
2049
+ // `commandListenerHealth` already prints per tab (`not attached`), so the
2050
+ // server's own tab table names the corpse without a second liveness notion
2051
+ // beside it. The `resume` branch of the lease's recovery re-attaches.
2052
+ const unsubscribeTombstone = onSessionEndedChange((state) => {
2053
+ void reportCommandListener(state === null);
2054
+ });
2055
+
2056
+ source.addEventListener('editor-command', (e: MessageEvent) => {
2057
+ try {
2058
+ const cmd = JSON.parse(e.data as string) as EditorCommand;
2059
+ const requestId = cmd._requestId;
2060
+ const commandStartContentVersion = store.contentVersion;
2061
+ // A tombstone answers, and what it answers is a REFUSAL naming its state.
2062
+ // Silence here would be worse than the defect: the caller would wait out
2063
+ // its whole budget and then be told the tab did not respond — a sentence
2064
+ // about a healthy tab, for a page that is dead.
2065
+ const tombstone = sessionEndedState();
2066
+ if (tombstone !== null) {
2067
+ const error = sessionEndedRefusal(tombstone, String(cmd['type']));
2068
+ if (requestId) void reportCommandResult(requestId, false, error);
2069
+ return;
2070
+ }
2071
+ // Receipt FIRST, before any work: it answers "this tab's command
2072
+ // listener is running", which is the one thing the relay cannot observe
2073
+ // and the thing a long-budget command (`play`'s 120s) otherwise spends
2074
+ // its whole budget failing to learn. Not awaited — the work must not
2075
+ // queue behind it. See `server/server-utils.ts`'s `RELAY_DELIVERY_ACK_MS`.
2076
+ if (requestId) void reportCommandReceived(requestId);
2077
+ let present: (() => void) | undefined;
2078
+ handleCommand(store, cmd, {
2079
+ deferPresentation: (effect) => {
2080
+ present = effect;
2081
+ },
2082
+ ...(history ? { history } : {}),
2083
+ })
2084
+ // A handler that THROWS must still answer its caller. Without this leg
2085
+ // the rejection reached only the browser's `unhandledrejection`
2086
+ // channel: the relay's caller got nothing and timed out into
2087
+ // "the tab is present … and did not respond" — which blames the tab
2088
+ // for a defect in the command — and the console capture DEDUPES a
2089
+ // repeated message, so the SECOND occurrence of the same failure left
2090
+ // the session journal completely silent. Measured 2026-08-15 on a
2091
+ // racing-game ingest mount (`capture-viewport` with a non-number
2092
+ // `size`; see `captureSizeFromCommand`).
2093
+ .catch((error: unknown) => commandThrewResult(cmd, error))
2094
+ .then((result) => {
2095
+ // Report command result back to server (so SDK/CLI gets the response)
2096
+ if (requestId) {
2097
+ const ack = reportCommandResult(
2098
+ requestId,
2099
+ result.ok,
2100
+ result.error,
2101
+ result.data,
2102
+ present !== undefined,
2103
+ );
2104
+ if (present) {
2105
+ // Do not merely queue behind the result send in the same task: wait until the original
2106
+ // caller's response has finished. Only then may a 500-row hierarchy reveal or inspector
2107
+ // preview monopolize the page's main thread. Presentation still runs after the bounded
2108
+ // receipt wait if the server vanished.
2109
+ const schedule = () => window.setTimeout(present!, 0);
2110
+ void ack.then(schedule, schedule);
2111
+ }
2112
+ } else if (present) {
2113
+ window.setTimeout(present, 0);
2114
+ }
2115
+ // The selection SET is already current even when its React presentation is deferred, so
2116
+ // this snapshot reports the applied truth without waiting for hierarchy/Inspector work
2117
+ // — nor, now, for a re-walk and re-grade of the whole project (`reportCurrentState`).
2118
+ const refresh = completedCommandDerivedRefresh(cmd, result.ok, reportedDebugReadProofs);
2119
+ reportCurrentState(refresh.derived, refresh.playFullReport, commandStartContentVersion);
2120
+ });
2121
+ } catch {
2122
+ /* ignore malformed events */
2123
+ }
2124
+ });
2125
+
2126
+ // When the server switches projects, reload to pick up the new project's files
2127
+ source.addEventListener('project-changed', () => {
2128
+ window.location.reload();
2129
+ });
2130
+
2131
+ // #145 — presence freshness: the state snapshot the server holds is only
2132
+ // re-POSTed after commands, so visibility/focus changes between commands
2133
+ // would go stale. Report on the three events that change presence.
2134
+ //
2135
+ // Through `reportCurrentState`, never a full collect: a focus/blur used to
2136
+ // re-derive the entire status surface on the event's own frame, which was
2137
+ // measured at 1221ms (`DOMWindow.onfocus`) and 1306ms (`DOMWindow.onblur`)
2138
+ // of main-thread block on a game-heavy project. Tabbing into the editor
2139
+ // froze it for over a second, every time, and nothing a focus changes is in
2140
+ // the expensive half.
2141
+ // Wrapped, never passed directly: this is an EVENT listener, and handing the
2142
+ // browser's `Event` straight into `reportCurrentState`'s argument would make
2143
+ // the refresh policy depend on an accident of the DOM signature.
2144
+ const reportPresence = (): void => reportCurrentState();
2145
+ document.addEventListener('visibilitychange', reportPresence);
2146
+ window.addEventListener('focus', reportPresence);
2147
+ window.addEventListener('blur', reportPresence);
2148
+
2149
+ // pageErrors freshness (target-blaster friction #3): a runtime error
2150
+ // between commands must reach the server snapshot too, or the editor's `status` command
2151
+ // reads stale-clean. Deferred a tick so the boot-installed error-capture
2152
+ // listener (`installEditorConsoleCapture`, which feeds editorConsole — the
2153
+ // list collectPlayRunPageErrors/collectSessionErrors read) runs FIRST regardless of
2154
+ // registration order.
2155
+ const reportAfterError = () => setTimeout(reportPresence, 0);
2156
+ window.addEventListener('error', reportAfterError);
2157
+ window.addEventListener('unhandledrejection', reportAfterError);
2158
+
2159
+ return () => {
2160
+ // Reported BEFORE the socket closes, so it still has a channel to travel
2161
+ // on. A teardown that also loses the connection is reported by the
2162
+ // server's own `close` handler; this is the case where the page keeps its
2163
+ // channel and stops listening.
2164
+ void reportCommandListener(false);
2165
+ cancelDeferredFullReport?.();
2166
+ cancelDeferredFullReport = null;
2167
+ if (playSettleTimer !== null) clearTimeout(playSettleTimer);
2168
+ playSettleTimer = null;
2169
+ clearPlayCommandContentCandidate();
2170
+ unsubscribeTombstone();
2171
+ unsubscribeStoreReport();
2172
+ unsubscribeRestartReport();
2173
+ unsubscribeAdapterReport();
2174
+ unsubscribeViewportActivationReport();
2175
+ document.removeEventListener('visibilitychange', reportPresence);
2176
+ window.removeEventListener('focus', reportPresence);
2177
+ window.removeEventListener('blur', reportPresence);
2178
+ window.removeEventListener('error', reportAfterError);
2179
+ window.removeEventListener('unhandledrejection', reportAfterError);
2180
+ source.close();
2181
+ };
2182
+ }