@volter/editor-core 0.5.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (719) hide show
  1. package/BUNDLED_NOTICES +1527 -0
  2. package/LICENSE +686 -0
  3. package/LICENSE-APACHE +202 -0
  4. package/NOTICE +18 -0
  5. package/README.md +24 -0
  6. package/dist/build/chunk-JS4D4XZ3.js +175 -0
  7. package/dist/build/chunk-JS4D4XZ3.js.map +7 -0
  8. package/dist/build/vite-plugin-product-contributions.js +100 -0
  9. package/dist/build/vite-plugin-product-contributions.js.map +7 -0
  10. package/dist/build/vite-plugin-shared-react.js +25 -0
  11. package/dist/build/vite-plugin-shared-react.js.map +7 -0
  12. package/dist/build/vite-plugin-shared-three.js +86 -0
  13. package/dist/build/vite-plugin-shared-three.js.map +7 -0
  14. package/dist/server/chunk-KMJC7AGO.js +98 -0
  15. package/dist/server/chunk-KMJC7AGO.js.map +7 -0
  16. package/dist/server/chunk-PSILUMGS.js +46 -0
  17. package/dist/server/chunk-PSILUMGS.js.map +7 -0
  18. package/dist/server/frame-proxy.js +255 -0
  19. package/dist/server/frame-proxy.js.map +7 -0
  20. package/dist/server/open-browser.js +53 -0
  21. package/dist/server/open-browser.js.map +7 -0
  22. package/dist/server/process-shutdown.js +127 -0
  23. package/dist/server/process-shutdown.js.map +7 -0
  24. package/dist/server/session-registry.js +141 -0
  25. package/dist/server/session-registry.js.map +7 -0
  26. package/dist/server/spawn-opener.js +11 -0
  27. package/dist/server/spawn-opener.js.map +7 -0
  28. package/dist/server/worktree-identity.js +11 -0
  29. package/dist/server/worktree-identity.js.map +7 -0
  30. package/dist-server/packaged.mjs +88816 -0
  31. package/package.json +118 -0
  32. package/scripts/build-plugins.mjs +14 -0
  33. package/scripts/build-server.mjs +26 -0
  34. package/scripts/build-session.mjs +9 -0
  35. package/server/account-credentials.ts +60 -0
  36. package/server/account-service.ts +1434 -0
  37. package/server/adapter-region-includes.ts +47 -0
  38. package/server/asset-catalog-v2.ts +175 -0
  39. package/server/asset-history-snapshots.ts +196 -0
  40. package/server/asset-ledger-store.ts +195 -0
  41. package/server/asset-library-routes.ts +1595 -0
  42. package/server/blender-wasm-artifact.ts +430 -0
  43. package/server/boot-timings.ts +106 -0
  44. package/server/canonical-path.ts +50 -0
  45. package/server/checkout-workspace-preflight.ts +349 -0
  46. package/server/cloud-asset-catalog.ts +158 -0
  47. package/server/coding-inference-launch.ts +237 -0
  48. package/server/collaboration-account-client.ts +202 -0
  49. package/server/collaboration-attribution.ts +122 -0
  50. package/server/collaboration-session.ts +934 -0
  51. package/server/comfyui-bridge.ts +248 -0
  52. package/server/console-ledger.ts +499 -0
  53. package/server/creation-site-transform.ts +304 -0
  54. package/server/creation-site-write.ts +648 -0
  55. package/server/data-file-serialize.ts +158 -0
  56. package/server/editor-brand-html.ts +72 -0
  57. package/server/editor-control-socket.ts +366 -0
  58. package/server/editor-server-options.ts +190 -0
  59. package/server/editor-server.ts +1483 -0
  60. package/server/editor-sse.ts +352 -0
  61. package/server/engine-provenance.ts +169 -0
  62. package/server/engine-source-restart.ts +117 -0
  63. package/server/frame-bridge.ts +115 -0
  64. package/server/frame-proxy.ts +490 -0
  65. package/server/frame-workbench.ts +259 -0
  66. package/server/frontend-handoff.ts +229 -0
  67. package/server/game-globals-shadow.ts +113 -0
  68. package/server/gameplay-session-retention.ts +149 -0
  69. package/server/gameplay-sessions.ts +123 -0
  70. package/server/generation-jobs.ts +201 -0
  71. package/server/generation-reconciler.ts +137 -0
  72. package/server/generative-execution-context.ts +59 -0
  73. package/server/git-workflow.ts +612 -0
  74. package/server/harness-chat-caller.ts +36 -0
  75. package/server/harness-chat-service.ts +1681 -0
  76. package/server/idle-shutdown.ts +170 -0
  77. package/server/js-profiling-policy.ts +52 -0
  78. package/server/launcher-settings.ts +84 -0
  79. package/server/local-asset-catalog.ts +410 -0
  80. package/server/managed-account-defaults.ts +22 -0
  81. package/server/mock-control-plane.ts +540 -0
  82. package/server/model-import-conversion.ts +345 -0
  83. package/server/native-credential-store.ts +162 -0
  84. package/server/open-browser.ts +118 -0
  85. package/server/packaged.ts +1192 -0
  86. package/server/play-stall.ts +126 -0
  87. package/server/process-shutdown.ts +218 -0
  88. package/server/product-presets.ts +55 -0
  89. package/server/project-build-artifact.ts +95 -0
  90. package/server/project-components.ts +100 -0
  91. package/server/project-dependency-invalidation.ts +114 -0
  92. package/server/project-file-scan.ts +235 -0
  93. package/server/project-hmr-files.ts +215 -0
  94. package/server/project-install-roots.ts +210 -0
  95. package/server/project-kinds.ts +71 -0
  96. package/server/project-mcp-servers.ts +82 -0
  97. package/server/project-module-freshness.ts +109 -0
  98. package/server/project-module-instance.ts +375 -0
  99. package/server/project-optimize-deps-entries.ts +827 -0
  100. package/server/project-output-writer.ts +679 -0
  101. package/server/project-package-origin.ts +63 -0
  102. package/server/project-root-surface.ts +831 -0
  103. package/server/project-scratch-path.ts +47 -0
  104. package/server/project-script-hmr.ts +619 -0
  105. package/server/project-serving-plugins.ts +236 -0
  106. package/server/project-tools.ts +653 -0
  107. package/server/project-validation.ts +250 -0
  108. package/server/project-verbs.ts +127 -0
  109. package/server/project-view.ts +52 -0
  110. package/server/project-watch.ts +1134 -0
  111. package/server/project-work-coordinator.ts +288 -0
  112. package/server/provider-credentials.ts +331 -0
  113. package/server/recent-projects-store.ts +98 -0
  114. package/server/redact-secrets.ts +71 -0
  115. package/server/repository-presence.ts +267 -0
  116. package/server/routes/account.ts +357 -0
  117. package/server/routes/agents.ts +60 -0
  118. package/server/routes/assets.ts +154 -0
  119. package/server/routes/build.ts +13 -0
  120. package/server/routes/collaboration.ts +595 -0
  121. package/server/routes/configurations.ts +419 -0
  122. package/server/routes/context.ts +264 -0
  123. package/server/routes/control-plane.ts +1811 -0
  124. package/server/routes/logs.ts +272 -0
  125. package/server/routes/project-identity.ts +302 -0
  126. package/server/routes/project-open.ts +429 -0
  127. package/server/routes/project-source.ts +686 -0
  128. package/server/routes/project-state.ts +383 -0
  129. package/server/routes/relay.ts +1029 -0
  130. package/server/routes/route-helpers.ts +29 -0
  131. package/server/routes/served-modules.ts +138 -0
  132. package/server/routes/session-tabs.ts +196 -0
  133. package/server/routes/settings.ts +121 -0
  134. package/server/routes/share-control.ts +486 -0
  135. package/server/routes/themes.ts +116 -0
  136. package/server/routes/tools.ts +230 -0
  137. package/server/routes/worktrees.ts +595 -0
  138. package/server/scoped-game-css.ts +461 -0
  139. package/server/server-utils.ts +1194 -0
  140. package/server/session-product.ts +112 -0
  141. package/server/session-registry.ts +230 -0
  142. package/server/share-claims.ts +138 -0
  143. package/server/share-host.ts +526 -0
  144. package/server/share-session-gateway.ts +1362 -0
  145. package/server/share-tunnel.ts +123 -0
  146. package/server/spawn-opener.ts +74 -0
  147. package/server/support/play/log-format.ts +76 -0
  148. package/server/support/play/session-record.ts +199 -0
  149. package/server/support/project/build-discipline.ts +718 -0
  150. package/server/support/project/inspection-node.ts +119 -0
  151. package/server/support/project/log-naming.ts +54 -0
  152. package/server/support/project/provenance.ts +290 -0
  153. package/server/support/project/run-name.ts +18 -0
  154. package/server/support/project/session-journal.ts +830 -0
  155. package/server/support/project/shared.ts +327 -0
  156. package/server/tab-bootstrap.ts +62 -0
  157. package/server/tab-heartbeat.ts +477 -0
  158. package/server/tab-lifecycle.ts +550 -0
  159. package/server/tab-presence.ts +1508 -0
  160. package/server/team-agent-mirror.ts +95 -0
  161. package/server/twin-auth.ts +138 -0
  162. package/server/vendored-lock-recorder.ts +968 -0
  163. package/server/worktree-identity.ts +121 -0
  164. package/server/worktree-management.ts +398 -0
  165. package/server/worktree-retention.d.mts +13 -0
  166. package/server/worktree-retention.mjs +205 -0
  167. package/src/EditorContext.tsx +169 -0
  168. package/src/account.ts +333 -0
  169. package/src/action-registry.ts +532 -0
  170. package/src/active-product.ts +74 -0
  171. package/src/active-project.ts +155 -0
  172. package/src/adapter-editor-config.ts +25 -0
  173. package/src/adapter-observation.ts +49 -0
  174. package/src/animation/stage-transport.ts +379 -0
  175. package/src/animation/three-clips-subject.ts +172 -0
  176. package/src/api/asset-library-wire.ts +45 -0
  177. package/src/api/assets.ts +365 -0
  178. package/src/api/base.ts +10 -0
  179. package/src/api/build.ts +99 -0
  180. package/src/api/git-wire.ts +56 -0
  181. package/src/api/logs.ts +92 -0
  182. package/src/api/project-identity.ts +74 -0
  183. package/src/api/project-open.ts +355 -0
  184. package/src/api/project-source.ts +162 -0
  185. package/src/api/project-state.ts +107 -0
  186. package/src/api/relay.ts +268 -0
  187. package/src/api/settings.ts +36 -0
  188. package/src/api/themes.ts +45 -0
  189. package/src/api/worktrees.ts +257 -0
  190. package/src/asset-compare-core.ts +171 -0
  191. package/src/asset-compare.ts +294 -0
  192. package/src/asset-editor-context.tsx +100 -0
  193. package/src/asset-events.ts +97 -0
  194. package/src/asset-inspector-actions.ts +87 -0
  195. package/src/asset-preview-framing.ts +357 -0
  196. package/src/asset-preview.ts +2802 -0
  197. package/src/asset-selection-viewer-registry.ts +113 -0
  198. package/src/asset-selection.ts +133 -0
  199. package/src/asset-workflow/asset-capabilities.ts +344 -0
  200. package/src/asset-workflow/asset-import-jobs.ts +106 -0
  201. package/src/asset-workflow/asset-ledger-backend.ts +126 -0
  202. package/src/asset-workflow/asset-ledger.ts +156 -0
  203. package/src/asset-workflow/asset-materialization-report.ts +140 -0
  204. package/src/asset-workflow/asset-pack-manifest.ts +320 -0
  205. package/src/asset-workflow/asset-types.ts +142 -0
  206. package/src/asset-workflow/asset-workflow-quality.ts +61 -0
  207. package/src/asset-workflow/audio-preview-player.ts +193 -0
  208. package/src/asset-workflow/audio-waveform.ts +22 -0
  209. package/src/asset-workflow/cloud-asset-client.ts +263 -0
  210. package/src/asset-workflow/folder-preview.ts +345 -0
  211. package/src/asset-workflow/hosted-asset-materialization.ts +236 -0
  212. package/src/asset-workflow/image-view-scale.ts +32 -0
  213. package/src/asset-workflow/import-contract.ts +124 -0
  214. package/src/asset-workflow/ledger-write-lock.ts +244 -0
  215. package/src/asset-workflow/model-inspection.ts +818 -0
  216. package/src/asset-workflow/pixi-spritesheet.ts +197 -0
  217. package/src/asset-workflow/preview-resource-lifetime.ts +44 -0
  218. package/src/asset-workflow/project-asset-commands.ts +23 -0
  219. package/src/asset-workflow/project-asset-health.ts +139 -0
  220. package/src/asset-workflow/project-asset-operations.ts +154 -0
  221. package/src/asset-workflow/project-asset-roots.ts +68 -0
  222. package/src/asset-workflow/project-content.ts +288 -0
  223. package/src/asset-workflow/project-source-index.ts +550 -0
  224. package/src/asset-workflow/thumbnail-system.ts +256 -0
  225. package/src/authoring/active-adapter.ts +199 -0
  226. package/src/authoring/active-systems.ts +422 -0
  227. package/src/authoring/adapter-key.ts +18 -0
  228. package/src/authoring/authoring-asset-url.ts +27 -0
  229. package/src/authoring/bootstrap-state.ts +49 -0
  230. package/src/authoring/boundary-authoring-adapter.ts +184 -0
  231. package/src/authoring/breakpoint-state.ts +43 -0
  232. package/src/authoring/canvas-scene-guides.ts +76 -0
  233. package/src/authoring/component-instance-root.ts +171 -0
  234. package/src/authoring/composite-authoring-adapter.ts +2110 -0
  235. package/src/authoring/consumer-actions.ts +520 -0
  236. package/src/authoring/css-numeric-style.ts +97 -0
  237. package/src/authoring/design-time-layers.ts +849 -0
  238. package/src/authoring/design-time-mount-registry.ts +235 -0
  239. package/src/authoring/design-time-settle.ts +343 -0
  240. package/src/authoring/edit-mode-authoring.ts +619 -0
  241. package/src/authoring/eyedropper-session.ts +60 -0
  242. package/src/authoring/instance-source-menu-register.ts +13 -0
  243. package/src/authoring/instance-source-menu.ts +135 -0
  244. package/src/authoring/layered-pick.ts +183 -0
  245. package/src/authoring/live-gesture-lock.ts +50 -0
  246. package/src/authoring/live-object-transform.ts +62 -0
  247. package/src/authoring/mount-failure-report.ts +154 -0
  248. package/src/authoring/mounted-root-subjects.ts +144 -0
  249. package/src/authoring/no-authoring-adapter.ts +55 -0
  250. package/src/authoring/null-inspection-subjects.tsx +75 -0
  251. package/src/authoring/object3d-document-persistence.ts +106 -0
  252. package/src/authoring/object3d-document-session-registry.ts +120 -0
  253. package/src/authoring/object3d-document-session.ts +1326 -0
  254. package/src/authoring/object3d-gesture-controller.ts +113 -0
  255. package/src/authoring/panel-authoring.ts +121 -0
  256. package/src/authoring/prefab-instance-inspector-section.tsx +237 -0
  257. package/src/authoring/prefab-instance-section-model.ts +55 -0
  258. package/src/authoring/project-authoring-session.ts +105 -0
  259. package/src/authoring/provenance.ts +99 -0
  260. package/src/authoring/quarks-particle-systems.ts +19 -0
  261. package/src/authoring/react-canvas-navigation.ts +255 -0
  262. package/src/authoring/react-design-canvas-style.ts +20 -0
  263. package/src/authoring/react-story-board.ts +937 -0
  264. package/src/authoring/selection-scope.ts +195 -0
  265. package/src/authoring/shell-document-ops.ts +169 -0
  266. package/src/authoring/shell-object3d-document-write-policy.ts +108 -0
  267. package/src/authoring/shell-viewport-policy.ts +48 -0
  268. package/src/authoring/source-object3d-authoring-adapter.ts +526 -0
  269. package/src/authoring/stories-scope.ts +35 -0
  270. package/src/authoring/story-board-chrome-fit.ts +107 -0
  271. package/src/authoring/story-board-presentation.ts +111 -0
  272. package/src/authoring/three-projection-core.ts +226 -0
  273. package/src/authoring/viewport-pick-context.ts +40 -0
  274. package/src/authoring/viewport-raycast.ts +240 -0
  275. package/src/authoring/viewport-tool-context.ts +73 -0
  276. package/src/authoring/world-canvas-viewport-state.ts +35 -0
  277. package/src/authoring/world-hidden-viewport.ts +152 -0
  278. package/src/authoring/world-pan-state.ts +198 -0
  279. package/src/authoring/world-session-state.ts +101 -0
  280. package/src/authoring/write-pipe.ts +173 -0
  281. package/src/availability-tick.ts +66 -0
  282. package/src/bitmap-label.ts +120 -0
  283. package/src/blender-tab-metrics.ts +161 -0
  284. package/src/board-open-actions.ts +20 -0
  285. package/src/boot-routing.ts +391 -0
  286. package/src/build-identity.ts +15 -0
  287. package/src/bytes-codec.ts +62 -0
  288. package/src/camera-authoring.ts +175 -0
  289. package/src/cancellation-reason.ts +58 -0
  290. package/src/canvas-preview-frames.ts +482 -0
  291. package/src/capture-camera-pose.ts +77 -0
  292. package/src/chrome-registry.ts +159 -0
  293. package/src/chrome-slot-registry.ts +91 -0
  294. package/src/collaboration-client.ts +264 -0
  295. package/src/collaboration-presence.ts +41 -0
  296. package/src/command-dispatch.ts +19 -0
  297. package/src/command-listener.ts +2743 -0
  298. package/src/command-registry.ts +70 -0
  299. package/src/component-board-registry.ts +205 -0
  300. package/src/component-states-registry.ts +200 -0
  301. package/src/components/AgentPresentationNotice.tsx +75 -0
  302. package/src/components/AlignToolbar.tsx +204 -0
  303. package/src/components/AppRoot.tsx +394 -0
  304. package/src/components/ApplicationMenus.tsx +415 -0
  305. package/src/components/AssetBrowser.tsx +2843 -0
  306. package/src/components/AssetEditorShell.tsx +216 -0
  307. package/src/components/AssetInspectorToolSection.tsx +124 -0
  308. package/src/components/BoardRulers.tsx +354 -0
  309. package/src/components/CameraInfo.tsx +81 -0
  310. package/src/components/CanvasSceneViewport.tsx +556 -0
  311. package/src/components/CapabilityCoverageSection.tsx +185 -0
  312. package/src/components/CenterDocuments.tsx +22 -0
  313. package/src/components/ChromeSlot.tsx +20 -0
  314. package/src/components/CodeView.tsx +470 -0
  315. package/src/components/CollaborationHeaderControl.css +74 -0
  316. package/src/components/CompactInspectorCard.tsx +190 -0
  317. package/src/components/CompactInspectorShell.tsx +39 -0
  318. package/src/components/ConsolePanel.css +55 -0
  319. package/src/components/ConsolePanel.tsx +270 -0
  320. package/src/components/DefaultEditorLayout.tsx +106 -0
  321. package/src/components/DocumentHeaderStrip.tsx +96 -0
  322. package/src/components/DocumentShelfRail.tsx +45 -0
  323. package/src/components/DocumentThumbnail.tsx +345 -0
  324. package/src/components/EditorLeaseGuard.tsx +372 -0
  325. package/src/components/ErrorBanner.tsx +123 -0
  326. package/src/components/FolderPreviewTile.tsx +159 -0
  327. package/src/components/GameHierarchy.tsx +3842 -0
  328. package/src/components/GameplaySessionTimeline.tsx +295 -0
  329. package/src/components/InspectionProjection.tsx +968 -0
  330. package/src/components/Inspector.tsx +233 -0
  331. package/src/components/InspectorCanvasPreview.tsx +35 -0
  332. package/src/components/InspectorFieldsSection.tsx +290 -0
  333. package/src/components/InspectorObjectPreview.tsx +57 -0
  334. package/src/components/InspectorStoriesSection.tsx +92 -0
  335. package/src/components/InspectorToolSection.tsx +96 -0
  336. package/src/components/InspectorTransformSection.tsx +245 -0
  337. package/src/components/LightExplorerPanel.tsx +433 -0
  338. package/src/components/MediaProperties.tsx +145 -0
  339. package/src/components/NonThreeAuthoringBootstrap.tsx +19 -0
  340. package/src/components/Object3DDocumentToolbar.css +100 -0
  341. package/src/components/Object3DDocumentToolbar.tsx +454 -0
  342. package/src/components/Object3DDocumentViewport.tsx +58 -0
  343. package/src/components/ProjectHeader.css +617 -0
  344. package/src/components/ProjectHeader.tsx +328 -0
  345. package/src/components/ProjectLayout.tsx +249 -0
  346. package/src/components/ReactCanvasControls.tsx +284 -0
  347. package/src/components/RootSelectionOverlay.tsx +3162 -0
  348. package/src/components/RootTextEditor.tsx +79 -0
  349. package/src/components/SaveStatus.tsx +70 -0
  350. package/src/components/StageHost.tsx +1954 -0
  351. package/src/components/StageOverlays.tsx +21 -0
  352. package/src/components/StartupErrorScreen.tsx +76 -0
  353. package/src/components/StartupLoadingScreen.tsx +65 -0
  354. package/src/components/StatsOverlay.tsx +78 -0
  355. package/src/components/SurfaceStateOverlay.tsx +24 -0
  356. package/src/components/ToolContributionSurfaces.tsx +65 -0
  357. package/src/components/ToolHost.tsx +370 -0
  358. package/src/components/ToolObject3DPreview.tsx +39 -0
  359. package/src/components/Toolbar.tsx +595 -0
  360. package/src/components/TransientHint.tsx +44 -0
  361. package/src/components/TransportStrip.tsx +174 -0
  362. package/src/components/VersionControlSection.tsx +470 -0
  363. package/src/components/VgaiLogo.css +83 -0
  364. package/src/components/VgaiLogo.tsx +35 -0
  365. package/src/components/ViewportControlsHint.tsx +60 -0
  366. package/src/components/ViewportFurniture.tsx +401 -0
  367. package/src/components/ViewportOverlay.tsx +145 -0
  368. package/src/components/ViewportOverlaysMenu.tsx +153 -0
  369. package/src/components/ViewportShadingMenu.tsx +300 -0
  370. package/src/components/ViewportViewMenu.tsx +101 -0
  371. package/src/components/WorkspaceDocumentSurface.tsx +204 -0
  372. package/src/components/WorkspaceUtilitySurface.tsx +22 -0
  373. package/src/components/WorktreeSwitcher.css +239 -0
  374. package/src/components/WorktreeSwitcher.tsx +890 -0
  375. package/src/components/account-documents.tsx +1162 -0
  376. package/src/components/asset-documents.tsx +812 -0
  377. package/src/components/asset-editor-persistence.ts +216 -0
  378. package/src/components/asset-selection-section.tsx +535 -0
  379. package/src/components/asset-thumbnails.tsx +312 -0
  380. package/src/components/asset-viewers/AudioViewer.tsx +201 -0
  381. package/src/components/asset-viewers/EntityModelDocument.tsx +122 -0
  382. package/src/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  383. package/src/components/asset-viewers/GenericJsonViewer.tsx +102 -0
  384. package/src/components/asset-viewers/ImageViewer.tsx +300 -0
  385. package/src/components/asset-viewers/JsonAssetDocument.tsx +93 -0
  386. package/src/components/asset-viewers/LiveModuleDocument.tsx +417 -0
  387. package/src/components/asset-viewers/LutAssetDocument.tsx +444 -0
  388. package/src/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  389. package/src/components/asset-viewers/Object3DPreview.tsx +359 -0
  390. package/src/components/asset-viewers/OnlineAssetDetail.tsx +421 -0
  391. package/src/components/asset-viewers/PasteboardModuleDocument.tsx +67 -0
  392. package/src/components/asset-viewers/QuarksAssetDocument.tsx +526 -0
  393. package/src/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  394. package/src/components/asset-viewers/SourceAssetViewer.tsx +281 -0
  395. package/src/components/asset-viewers/SpritesheetSpriteView.tsx +102 -0
  396. package/src/components/asset-viewers/VideoViewer.tsx +101 -0
  397. package/src/components/asset-viewers/shader-source.ts +144 -0
  398. package/src/components/asset-workflow.css +731 -0
  399. package/src/components/board-guides.ts +150 -0
  400. package/src/components/compact-inspector.css +539 -0
  401. package/src/components/core-utilities.tsx +90 -0
  402. package/src/components/editor-notifications.css +40 -0
  403. package/src/components/engine-workspace.css +274 -0
  404. package/src/components/inspector-preview-section.tsx +238 -0
  405. package/src/components/inspector-property-grouping.ts +64 -0
  406. package/src/components/inspector-revert-label.ts +20 -0
  407. package/src/components/inspector-selection.ts +42 -0
  408. package/src/components/inspector-stories-gating.ts +171 -0
  409. package/src/components/inspector-transform-subject.ts +11 -0
  410. package/src/components/inspector-transform.ts +75 -0
  411. package/src/components/kind-documents.tsx +500 -0
  412. package/src/components/palette-action-publisher.tsx +140 -0
  413. package/src/components/primitives/DraftColorInput.tsx +74 -0
  414. package/src/components/product-shell.css +1829 -0
  415. package/src/components/project-tool-documents.tsx +410 -0
  416. package/src/components/scene-documents.tsx +223 -0
  417. package/src/components/stage-keyboard.tsx +37 -0
  418. package/src/components/stage-overlay-set.tsx +107 -0
  419. package/src/components/stage-presence-markers.ts +478 -0
  420. package/src/components/standard-viewport-dressing.ts +417 -0
  421. package/src/components/status-contributions.tsx +403 -0
  422. package/src/components/tool-documents.tsx +309 -0
  423. package/src/components/tool-schema-form.tsx +262 -0
  424. package/src/components/use-after-paint.ts +41 -0
  425. package/src/components/use-project-image-assets.ts +86 -0
  426. package/src/components/viewport-header-controls.css +90 -0
  427. package/src/components/viewport-surface-status.tsx +55 -0
  428. package/src/components/workspace-history.ts +32 -0
  429. package/src/components/workspace-static-panel-registry.tsx +152 -0
  430. package/src/components/workspace-surfaces.css +621 -0
  431. package/src/components/world-documents.tsx +586 -0
  432. package/src/components/world-overlay-gestures.ts +1694 -0
  433. package/src/composite-screenshot.ts +1641 -0
  434. package/src/console-sync.ts +131 -0
  435. package/src/constraint-helper.ts +338 -0
  436. package/src/content-entry-source-registry.ts +184 -0
  437. package/src/coverage/authoring-seam-evidence.ts +300 -0
  438. package/src/coverage/canvas-reveal.ts +192 -0
  439. package/src/coverage/design-time-surfaces.ts +101 -0
  440. package/src/coverage/live-seam-evidence.ts +11 -0
  441. package/src/coverage/ontology-invariants.ts +466 -0
  442. package/src/coverage/session-vitals.ts +501 -0
  443. package/src/coverage/system-seam-evidence.ts +72 -0
  444. package/src/crash-null-boundary.ts +36 -0
  445. package/src/creation-site-edit.ts +1479 -0
  446. package/src/creation-site-registry.ts +160 -0
  447. package/src/delegate-harness-registry.ts +143 -0
  448. package/src/document-context-registry.ts +177 -0
  449. package/src/document-open-registry.ts +200 -0
  450. package/src/document-preview-source.ts +20 -0
  451. package/src/document-renderer-session.ts +138 -0
  452. package/src/editor-api.ts +46 -0
  453. package/src/editor-chrome-capture.ts +139 -0
  454. package/src/editor-commands.ts +164 -0
  455. package/src/editor-console.ts +563 -0
  456. package/src/editor-current-view.ts +82 -0
  457. package/src/editor-document-probe.ts +881 -0
  458. package/src/editor-git-client.ts +115 -0
  459. package/src/editor-host-door.ts +462 -0
  460. package/src/editor-hotkeys.ts +842 -0
  461. package/src/editor-lease-view.ts +39 -0
  462. package/src/editor-lease.ts +415 -0
  463. package/src/editor-mode.ts +19 -0
  464. package/src/editor-notifications.ts +140 -0
  465. package/src/editor-presence.ts +563 -0
  466. package/src/editor-presentation-activity.ts +50 -0
  467. package/src/editor-presentation-notice.ts +42 -0
  468. package/src/editor-runtime.tsx +145 -0
  469. package/src/editor-server-response.ts +86 -0
  470. package/src/editor-session-attribution.ts +75 -0
  471. package/src/editor-session-mode.ts +54 -0
  472. package/src/editor-shell-store.ts +1304 -0
  473. package/src/editor-state-facets.ts +74 -0
  474. package/src/editor-styles.css +33 -0
  475. package/src/editor-view-presentation.ts +810 -0
  476. package/src/editor-viewport.ts +5302 -0
  477. package/src/entity-lod.ts +31 -0
  478. package/src/entity-object.ts +91 -0
  479. package/src/files/file-provider.ts +62 -0
  480. package/src/files/project-files.ts +264 -0
  481. package/src/finders/index.ts +136 -0
  482. package/src/finders/scenes-from-entrypoint-selection.ts +387 -0
  483. package/src/frame/bridge.tsx +1408 -0
  484. package/src/frame/product.ts +77 -0
  485. package/src/gameplay-dom-recording.ts +319 -0
  486. package/src/gameplay-export-state.ts +14 -0
  487. package/src/gameplay-replay.ts +417 -0
  488. package/src/gameplay-session-time.ts +9 -0
  489. package/src/gameplay-sessions.ts +204 -0
  490. package/src/harness-chat-types.ts +253 -0
  491. package/src/hierarchy-component-marks.ts +298 -0
  492. package/src/hierarchy-drop.ts +91 -0
  493. package/src/hierarchy-expansion-state.ts +80 -0
  494. package/src/hierarchy-header-slot.ts +52 -0
  495. package/src/hierarchy-internals.ts +197 -0
  496. package/src/hierarchy-kind-icon.ts +217 -0
  497. package/src/hierarchy-mark-reader.ts +73 -0
  498. package/src/hierarchy-menu-registry.ts +67 -0
  499. package/src/hierarchy-node-rows.ts +307 -0
  500. package/src/hierarchy-panel-view.ts +280 -0
  501. package/src/hierarchy-projection.ts +76 -0
  502. package/src/hierarchy-row-cache.ts +243 -0
  503. package/src/hierarchy-row-model.ts +308 -0
  504. package/src/hierarchy-rows.ts +11 -0
  505. package/src/hierarchy-walk.ts +86 -0
  506. package/src/history/editor-session.ts +25 -0
  507. package/src/history/history-commands.ts +147 -0
  508. package/src/history/history-delegate.ts +163 -0
  509. package/src/history/history-limit-notices.ts +43 -0
  510. package/src/history/history-service.ts +1173 -0
  511. package/src/history/persistence-coordinator.ts +35 -0
  512. package/src/history/project-file-history.ts +386 -0
  513. package/src/history/project-root-history-backends.ts +139 -0
  514. package/src/history/resource-registry.ts +209 -0
  515. package/src/history/snapshot-store.ts +103 -0
  516. package/src/history/source-history-backend.ts +546 -0
  517. package/src/history/types.ts +124 -0
  518. package/src/hmr-registration-group.ts +67 -0
  519. package/src/hmr-stable-react-context.ts +23 -0
  520. package/src/hotkeys.ts +188 -0
  521. package/src/inference-diagnostics.ts +69 -0
  522. package/src/initial-project.ts +80 -0
  523. package/src/inspection/active-subject.ts +578 -0
  524. package/src/inspection/active-surface.ts +142 -0
  525. package/src/inspection/compose.ts +1064 -0
  526. package/src/inspection/display.ts +170 -0
  527. package/src/inspection/document-subject.ts +109 -0
  528. package/src/inspection/game-subject.ts +85 -0
  529. package/src/inspection/model.ts +542 -0
  530. package/src/inspection/null-subject.ts +115 -0
  531. package/src/inspection/serialize.ts +357 -0
  532. package/src/inspection/use-active-inspection.ts +180 -0
  533. package/src/inspector-presentation.ts +201 -0
  534. package/src/inspector-section-registry.ts +221 -0
  535. package/src/instance-source-actions.ts +163 -0
  536. package/src/instanced-presentation.ts +164 -0
  537. package/src/js-heap.ts +71 -0
  538. package/src/key-actions.ts +91 -0
  539. package/src/keymap-presets.ts +401 -0
  540. package/src/layout-policy.ts +31 -0
  541. package/src/learn-links.ts +73 -0
  542. package/src/light-explorer-model.ts +134 -0
  543. package/src/live-canvas-frame.ts +55 -0
  544. package/src/live-document.ts +279 -0
  545. package/src/live-module-source.ts +230 -0
  546. package/src/live-session-registry.ts +220 -0
  547. package/src/live-transition.ts +633 -0
  548. package/src/manifest-project.ts +107 -0
  549. package/src/model-thumbnail.ts +523 -0
  550. package/src/native-selection-style.ts +202 -0
  551. package/src/object3d-document-write-policy.ts +137 -0
  552. package/src/packaged-runtime.ts +108 -0
  553. package/src/palettes/maya.palette.json +57 -0
  554. package/src/palettes/substance.palette.json +57 -0
  555. package/src/pasteboard-module.ts +81 -0
  556. package/src/performance-profiler.ts +367 -0
  557. package/src/performance-sources.ts +69 -0
  558. package/src/play-boot-phase.ts +145 -0
  559. package/src/presentation-surface.ts +248 -0
  560. package/src/project-adapter.ts +1143 -0
  561. package/src/project-asset-refresh.ts +26 -0
  562. package/src/project-declaration-refresh.ts +31 -0
  563. package/src/project-local-state.ts +118 -0
  564. package/src/project-manager.ts +243 -0
  565. package/src/project-module-changes.ts +163 -0
  566. package/src/project-module-split.ts +266 -0
  567. package/src/project-provenance.ts +115 -0
  568. package/src/project-ready.ts +42 -0
  569. package/src/project-session-reset.ts +67 -0
  570. package/src/project-shape.ts +68 -0
  571. package/src/project-tool-discovery.ts +66 -0
  572. package/src/project-tools.ts +107 -0
  573. package/src/project-work-types.ts +149 -0
  574. package/src/projection/three.ts +898 -0
  575. package/src/projection/types.ts +44 -0
  576. package/src/readiness.ts +113 -0
  577. package/src/reflection-probe-helper.ts +142 -0
  578. package/src/reported-play-state.ts +90 -0
  579. package/src/resolve-relative-specifier.ts +33 -0
  580. package/src/scene-document-plan.ts +315 -0
  581. package/src/scene-framing.ts +315 -0
  582. package/src/scene-live-open.ts +210 -0
  583. package/src/scene-view-fog.ts +89 -0
  584. package/src/scoped-game-css.ts +152 -0
  585. package/src/session-orphan-record.ts +193 -0
  586. package/src/session-tombstone.ts +126 -0
  587. package/src/settings/settings-provider.ts +82 -0
  588. package/src/settings-store.ts +345 -0
  589. package/src/shared-view-restore.ts +42 -0
  590. package/src/shell-store-door.ts +45 -0
  591. package/src/source-conflict.ts +122 -0
  592. package/src/spatial-handle-visuals.ts +332 -0
  593. package/src/stage-context.ts +341 -0
  594. package/src/stage-store-registry.ts +63 -0
  595. package/src/stale-chunk-recovery.ts +34 -0
  596. package/src/state-report-deferral.ts +73 -0
  597. package/src/storage/host-files-storage.ts +97 -0
  598. package/src/storage/http-storage.ts +174 -0
  599. package/src/storage/index.ts +75 -0
  600. package/src/storage/mem-storage.ts +149 -0
  601. package/src/storage/path-lock.ts +44 -0
  602. package/src/storage/paths.ts +26 -0
  603. package/src/storage/types.ts +116 -0
  604. package/src/stories/StoryComponentThumbnail.tsx +184 -0
  605. package/src/stories/StoryPreviewMount.tsx +306 -0
  606. package/src/stories/component-content-source.tsx +106 -0
  607. package/src/stories/component-states-source.ts +78 -0
  608. package/src/stories/compose-project-stories.ts +255 -0
  609. package/src/stories/pixi-story-model.ts +30 -0
  610. package/src/stories/prefabs-finder.ts +54 -0
  611. package/src/stories/prefabs-from-stories.ts +182 -0
  612. package/src/stories/project-story-discovery.ts +78 -0
  613. package/src/stories/project-story-regions.ts +24 -0
  614. package/src/stories/story-actions.ts +54 -0
  615. package/src/stories/story-capture-command.ts +124 -0
  616. package/src/stories/story-capture.ts +584 -0
  617. package/src/stories/story-declared-medium.ts +126 -0
  618. package/src/stories/story-discovery.ts +176 -0
  619. package/src/stories/story-document-openers.ts +36 -0
  620. package/src/stories/story-dom-runtime.ts +78 -0
  621. package/src/stories/story-grouping.ts +111 -0
  622. package/src/stories/story-lane.ts +88 -0
  623. package/src/stories/story-mount-turn.ts +27 -0
  624. package/src/stories/story-opener.ts +260 -0
  625. package/src/stories/story-pixi-preview.ts +408 -0
  626. package/src/stories/story-presentation.ts +215 -0
  627. package/src/stories/story-registry.ts +517 -0
  628. package/src/stories/story-three-preview.ts +807 -0
  629. package/src/stories/three-story-model.ts +96 -0
  630. package/src/story-three-preview-runtime.ts +56 -0
  631. package/src/surface-keyboard.ts +112 -0
  632. package/src/surface-state.ts +124 -0
  633. package/src/tab-bootstrap.js +628 -0
  634. package/src/tab-census.ts +198 -0
  635. package/src/tab-lifecycle-client.ts +196 -0
  636. package/src/theme-library.ts +844 -0
  637. package/src/theme-preference.ts +378 -0
  638. package/src/theme.css +3437 -0
  639. package/src/three-viewport/bone-selection-highlight.ts +119 -0
  640. package/src/three-viewport/camera-fit.ts +41 -0
  641. package/src/three-viewport/interactive-renderer.ts +132 -0
  642. package/src/three-viewport/selection-brackets.ts +310 -0
  643. package/src/three-viewport/selection-outline.ts +183 -0
  644. package/src/three-viewport/skeleton-helper.ts +61 -0
  645. package/src/three-viewport/source-color.ts +167 -0
  646. package/src/three-viewport/studio-environment.ts +96 -0
  647. package/src/three-viewport-presentation.ts +23 -0
  648. package/src/tool-contribution-play.ts +74 -0
  649. package/src/tool-loader.ts +1798 -0
  650. package/src/transform-mode-request.ts +57 -0
  651. package/src/transient-hint.ts +78 -0
  652. package/src/trigger-volume-helper.ts +116 -0
  653. package/src/ui-source/adapter-region-includes.ts +241 -0
  654. package/src/ui-source/ensure-import.ts +132 -0
  655. package/src/ui-source/file-region-resolver.ts +302 -0
  656. package/src/ui-source/inspect.ts +775 -0
  657. package/src/ui-source/oid-transform.ts +1845 -0
  658. package/src/ui-source/plan-csf-story.ts +196 -0
  659. package/src/ui-source/plan-extract-component.ts +421 -0
  660. package/src/ui-source/plan-fork-component.ts +621 -0
  661. package/src/ui-source/plan-named-style.ts +95 -0
  662. package/src/ui-source/plan-source-edit.ts +353 -0
  663. package/src/ui-source/r3f-contract-resolver.ts +327 -0
  664. package/src/ui-source/r3f-diagnostic-index.ts +165 -0
  665. package/src/ui-source/r3f-environment-binding.ts +133 -0
  666. package/src/ui-source/r3f-joint-binding.ts +251 -0
  667. package/src/ui-source/r3f-lod-binding.ts +91 -0
  668. package/src/ui-source/r3f-particle-binding.ts +235 -0
  669. package/src/ui-source/r3f-physics-binding.ts +483 -0
  670. package/src/ui-source/r3f-project-contracts.ts +444 -0
  671. package/src/ui-source/relative-import-specifier.ts +34 -0
  672. package/src/ui-source/reparent-guard.ts +544 -0
  673. package/src/ui-source/source-edit-request.ts +97 -0
  674. package/src/ui-source/source-write-backend.ts +618 -0
  675. package/src/ui-source/syntactic-prop-specs.ts +296 -0
  676. package/src/ui-source/tier-source-write-backend.ts +279 -0
  677. package/src/ui-source/ts-ast.ts +171 -0
  678. package/src/ui-source/utility-class-support.ts +169 -0
  679. package/src/ui-source/write-component-default.ts +133 -0
  680. package/src/ui-source/writer.ts +1989 -0
  681. package/src/viewport-activation-timings.ts +840 -0
  682. package/src/viewport-authoring-policy.ts +186 -0
  683. package/src/viewport-controls-hint.ts +57 -0
  684. package/src/viewport-door.ts +237 -0
  685. package/src/viewport-shading-boundary.ts +12 -0
  686. package/src/vite-error-surface.ts +141 -0
  687. package/src/wait-until.ts +37 -0
  688. package/src/workspace-areas.ts +156 -0
  689. package/src/workspace-aux-commands.ts +11 -0
  690. package/src/workspace-available-documents.ts +127 -0
  691. package/src/workspace-core-utilities.ts +31 -0
  692. package/src/workspace-document-ids.ts +59 -0
  693. package/src/workspace-document-registry.ts +612 -0
  694. package/src/workspace-document-restore.ts +145 -0
  695. package/src/workspace-host-commands.ts +141 -0
  696. package/src/workspace-persistence-gate.ts +40 -0
  697. package/src/workspace-play-utilities.ts +44 -0
  698. package/src/workspace-presets.ts +425 -0
  699. package/src/workspace-regions.ts +294 -0
  700. package/src/workspace-state-persistence.ts +536 -0
  701. package/src/workspace-static-panels.ts +73 -0
  702. package/src/workspace-status-registry.ts +121 -0
  703. package/src/workspace-style.ts +239 -0
  704. package/src/workspace-utility-commands.ts +74 -0
  705. package/src/workspace-utility-registry.ts +263 -0
  706. package/src/workspace-viewport-rect.ts +97 -0
  707. package/src/world-adoption.ts +115 -0
  708. package/src/world-document-routing.ts +104 -0
  709. package/vite-plugin-creation-site-write.ts +184 -0
  710. package/vite-plugin-creation-site.ts +80 -0
  711. package/vite-plugin-game-static.ts +303 -0
  712. package/vite-plugin-module-doorways.ts +336 -0
  713. package/vite-plugin-product-contributions.ts +197 -0
  714. package/vite-plugin-project-game-static.ts +125 -0
  715. package/vite-plugin-project-jsx-js.ts +65 -0
  716. package/vite-plugin-project-root-absolute-assets.ts +359 -0
  717. package/vite-plugin-shared-react.ts +420 -0
  718. package/vite-plugin-shared-three.ts +203 -0
  719. package/vite-plugin-ui-oid.ts +2106 -0
@@ -0,0 +1,2743 @@
1
+ import { isGameplayExportActive } from './gameplay-export-state';
2
+
3
+ /**
4
+ * Command listener — receives commands from the editor server via SSE
5
+ * and dispatches them to the EditorShellStore and play-mode functions.
6
+ *
7
+ * The server broadcasts `editor-command` events sent by the SDK/CLI.
8
+ * This module translates them into store method calls.
9
+ */
10
+
11
+ import type {
12
+ CaptureDimensions,
13
+ DocumentProbeStep,
14
+ EditorView,
15
+ InspectedHierarchy,
16
+ InspectedInspection,
17
+ InspectedWriteDestination,
18
+ HelperVisibility as SdkHelperVisibility,
19
+ } from '@volter/editor-sdk';
20
+ import { inspectorPreviewRendererCounts } from '@volter/editor-threejs/viewport/preview-renderer';
21
+ import { liveHostRendererCount } from '@volter/editor-threejs/viewport/renderer-ownership';
22
+ import type { ViewportShadingMode } from '@volter/editor-threejs/render/viewport-shading';
23
+ import { captureEntityComparePreview, captureModelComparePreview } from './asset-compare';
24
+ import { parseForwardVector } from './asset-compare-core';
25
+ import {
26
+ type AssetPreviewBackground,
27
+ captureGlbBytesAssetPreview,
28
+ captureModelAssetPreview,
29
+ captureObjectAssetPreview,
30
+ captureSceneStageAssetPreview,
31
+ captureShotSetAssetPreview,
32
+ captureShotSetGlbBytesPreview,
33
+ captureShotSetModelPreview,
34
+ captureSourceReviewShotSetAssetPreview,
35
+ captureSourceReviewShotSetModelPreview,
36
+ parseShotSetDefinition,
37
+ } from './asset-preview';
38
+ import { setSelectedAsset } from './asset-selection';
39
+ import { assetCapabilities, assetDocumentKind } from './asset-workflow/asset-capabilities';
40
+ import { systemsForInstance } from './authoring/active-systems';
41
+ import { getMountFailureReports } from './authoring/mount-failure-report';
42
+ import { object3DDocumentSession } from './authoring/object3d-document-session-registry';
43
+ import {
44
+ activeDocumentAuthoring,
45
+ activeHierarchyRows,
46
+ activeSaveDestination,
47
+ activeSaveState,
48
+ activeSelectionCreationSite,
49
+ activeSelectionIds,
50
+ activeSelectionWriteAnchorKind,
51
+ } from './authoring/shell-document-ops';
52
+ import { parseCameraChoice, parsePoseChoice } from './capture-camera-pose';
53
+ import { noteCommandDispatched } from './command-dispatch';
54
+ import { contributedCommand, contributedCommandDerivedRefresh } from './command-registry';
55
+ import {
56
+ isAssetDocumentId,
57
+ openAssetDocument,
58
+ waitForAssetDocumentInspector,
59
+ } from './components/asset-documents';
60
+ import { openSceneTableEntryWhenListed } from './components/scene-documents';
61
+ import { systemAdapterEpoch } from './coverage/system-seam-evidence';
62
+ import {
63
+ connectEvents,
64
+ reportCommandListener,
65
+ reportCommandReceived,
66
+ reportCommandResult,
67
+ reportEditorState,
68
+ reportPlayBootPhase,
69
+ } from './editor-api';
70
+ import { captureEditorChrome } from './editor-chrome-capture';
71
+ import type { ConsoleEntry } from './editor-console';
72
+ import { editorConsole } from './editor-console';
73
+ import { currentEditorView } from './editor-current-view';
74
+ import { editorIsPlaying } from './editor-session-mode';
75
+ import type {
76
+ AssetKind,
77
+ EditorShellStore,
78
+ HelperVisibility,
79
+ ViewportTab,
80
+ } from './editor-shell-store';
81
+ import { collectEditorStateFacets, reusableFacetKeys } from './editor-state-facets';
82
+ import { entityObject3D } from './entity-object';
83
+ import {
84
+ hierarchyPanelSnapshot,
85
+ nextHierarchyPanelSnapshot,
86
+ serializeHierarchyPanel,
87
+ } from './hierarchy-panel-view';
88
+ import type { HistoryCommands } from './history/history-commands';
89
+ import {
90
+ anyLiveSessionMounted,
91
+ anyLiveSessionPlaying,
92
+ dispatchLiveCommand,
93
+ liveRunWindow,
94
+ liveScenes,
95
+ remountLiveSelection,
96
+ subscribeLiveSessions,
97
+ } from './live-session-registry';
98
+ import { setPlayBootPhaseReporter } from './play-boot-phase';
99
+ import { projectAdapterFacet, subscribeProjectAdapter } from './project-adapter';
100
+ import { getProjectModuleSplitReports } from './project-module-split';
101
+ import { onSessionEndedChange, sessionEndedRefusal, sessionEndedState } from './session-tombstone';
102
+ import { focusedStageStore } from './stage-context';
103
+ import { scheduleDeferredFullReport } from './state-report-deferral';
104
+ import { interactiveViewportRendererCounts } from './three-viewport/interactive-renderer';
105
+ import {
106
+ subscribeViewportActivationTimings,
107
+ viewportActivationTimings,
108
+ } from './viewport-activation-timings';
109
+
110
+ type AssertSameKeys<P, Q> = [keyof P] extends [keyof Q]
111
+ ? [keyof Q] extends [keyof P]
112
+ ? true
113
+ : false
114
+ : false;
115
+ type RequireTrue<T extends true> = T;
116
+ /**
117
+ * Compile-time drift guard (W3a N1) — its consumer is `tsc` itself. The SDK
118
+ * republishes `HelperVisibility` for control-API clients, and the
119
+ * `{ ...store.helperVisibility }` spread in `collectState` below typechecks
120
+ * happily even when the SDK mirror is MISSING keys (spreads skip excess
121
+ * property checks) — exactly how `joints`/`lod` drifted out of the SDK
122
+ * pre-W3a. This alias fails `npm run typecheck` the moment either side gains
123
+ * a key the other lacks.
124
+ */
125
+ export type HelperVisibilityMirrorInSync = RequireTrue<
126
+ AssertSameKeys<HelperVisibility, SdkHelperVisibility>
127
+ >;
128
+
129
+ import {
130
+ type CommandResult,
131
+ isRelayCommandType,
132
+ type RelayCommandDerivedRefresh,
133
+ type RelayCommandType,
134
+ relayCommandDerivedRefresh,
135
+ } from '@volter/editor-sdk/session/command-table';
136
+ import { editorMaterials, isEditorMaterialId } from '@volter/editor-sdk/widgets';
137
+ import {
138
+ copyAuthoringNodes,
139
+ createAuthoringNode,
140
+ cutAuthoringNodes,
141
+ duplicateAuthoringNode,
142
+ groupAuthoringNodes,
143
+ pasteAuthoringNodes,
144
+ removeAuthoringNode,
145
+ removeManyAuthoringNodes,
146
+ reorderAuthoringNode,
147
+ reparentAuthoringNode,
148
+ ungroupAuthoringNode,
149
+ unwrapAuthoringNode,
150
+ wrapAuthoringNode,
151
+ } from './authoring/consumer-actions';
152
+ import { resolvePanelAuthoring } from './authoring/panel-authoring';
153
+ import { ontologyInvariantFacet } from './coverage/session-vitals';
154
+ import { documentContextFor, waitForDocumentContext } from './document-context-registry';
155
+ import { executeCommand, openCommandPalette } from './editor-commands';
156
+ import { runDocumentProbe } from './editor-document-probe';
157
+ import {
158
+ captureActiveEditorDocument,
159
+ presentEditorView,
160
+ revealStaticPanel,
161
+ } from './editor-view-presentation';
162
+ import { toolGameplaySessions } from './gameplay-sessions';
163
+ import {
164
+ InspectionRemovalUnavailableError,
165
+ inspectActiveSubject,
166
+ removeActiveInspectionField,
167
+ runActiveInspectionAction,
168
+ setActiveInspectionField,
169
+ } from './inspection/active-subject';
170
+ import { measuredReadinessWarning, readinessFacet } from './readiness';
171
+ import { deriveReportedPlayState } from './reported-play-state';
172
+ import { openLiveSceneEntry } from './scene-live-open';
173
+ import { editorMaterialSnapshot, setEditorMaterialPreference } from './theme-preference';
174
+ import { GAME_DOCUMENT_ID } from './workspace-document-ids';
175
+ import {
176
+ activateWorkspaceDocument,
177
+ activeWorkspaceDocumentId,
178
+ closeWorkspaceDocument,
179
+ openWorkspaceDocuments,
180
+ } from './workspace-document-registry';
181
+ import { activeWorkspaceUtility } from './workspace-host-commands';
182
+ import {
183
+ activeEditorWorkspace,
184
+ editorWorkspaceIds,
185
+ isEditorWorkspaceId,
186
+ setEditorWorkspace,
187
+ whenEditorWorkspaceApplied,
188
+ } from './workspace-presets';
189
+ import { activeWorkspaceStyleId, applyWorkspaceStyle, workspaceStyles } from './workspace-style';
190
+ import { toggleConsoleUtility } from './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: EditorShellStore, 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
+ if (entityObject3D(adapter, store.objectMap, id) !== null) return null;
328
+ return (
329
+ `select: no entity with id "${id}" — neither the active authoring adapter's ` +
330
+ `hierarchy nor the live object index owns it, so there is nothing to select. ` +
331
+ `Read the ids that exist with editor.hierarchy() (the rows the panel is ` +
332
+ `rendering) or editor.status().entities.`
333
+ );
334
+ }
335
+
336
+ function applyControlSelection(
337
+ store: EditorShellStore,
338
+ ids: readonly string[],
339
+ options: CommandHandlingOptions | undefined,
340
+ ): void {
341
+ // Object3D documents own selection outside the scene store. This is also
342
+ // the route used by human hierarchy clicks and present-view, and is what
343
+ // lets semantic Asset Lab subjects (bones, physics bodies, joints) update
344
+ // their native highlight and Inspector state.
345
+ const documentSession = activeObject3DDocumentSession();
346
+ if (documentSession) {
347
+ documentSession.select(ids);
348
+ return;
349
+ }
350
+ const present = store.applySelectionBeforePresentation(ids);
351
+ if (options?.deferPresentation) options.deferPresentation(present);
352
+ else setTimeout(present, 0);
353
+ }
354
+
355
+ /** Live viewport camera pose of the FOCUSED stage — undefined when nothing has
356
+ * bound a camera yet (see `EditorShellStore.cameraPose`'s doc comment).
357
+ * A camera is a per-stage fact, so the reader follows focus the way the
358
+ * panels do (`focusedStageStore`, ARCHITECTURE-CORE §One stage unit 4):
359
+ * before this, `vgai status` reported the world's camera while the person
360
+ * was looking through a model document's. */
361
+ function collectCamera(store: EditorShellStore):
362
+ | {
363
+ position: { x: number; y: number; z: number };
364
+ target: { x: number; y: number; z: number };
365
+ fov: number;
366
+ }
367
+ | undefined {
368
+ const pose = focusedStageStore(store).cameraPose;
369
+ if (!pose) return undefined;
370
+ return {
371
+ position: { x: pose.position.x, y: pose.position.y, z: pose.position.z },
372
+ target: { x: pose.target.x, y: pose.target.y, z: pose.target.z },
373
+ fov: pose.fov,
374
+ };
375
+ }
376
+
377
+ /** #145 — what the human's tab is actually showing right now: page
378
+ * visibility + window focus. `null` outside a real browser (the relay's
379
+ * node-side tests). The agent reads this to know whether the user is
380
+ * LOOKING at the shared session before deciding how to narrate/verify;
381
+ * `connectCommandListener` re-reports state on visibilitychange/focus/blur
382
+ * so the server's `/__editor/state` snapshot stays current between
383
+ * commands.
384
+ *
385
+ * P21 — `reportedAt` is stamped HERE, at read time, and it is the field that
386
+ * makes the other two honest. A CLI banner that says "the tab is hidden" is
387
+ * reading a snapshot with no age on it; the owner was told that repeatedly
388
+ * while looking straight at the foregrounded tab. With an age attached, the
389
+ * reader can see the difference between a reading taken 40ms ago and one
390
+ * taken 40 seconds ago, and no downstream surface has to invent a story to
391
+ * fill the gap. */
392
+ function collectPresence(): {
393
+ visibility: DocumentVisibilityState;
394
+ focused: boolean;
395
+ reportedAt: number;
396
+ } | null {
397
+ if (typeof document === 'undefined') return null;
398
+ return {
399
+ visibility: document.visibilityState,
400
+ focused: document.hasFocus(),
401
+ reportedAt: Date.now(),
402
+ };
403
+ }
404
+
405
+ /**
406
+ * The facets `collectState` will REUSE from a previous snapshot when it is
407
+ * given one — the hierarchy walk and the capability GRADING families. They are
408
+ * the whole measured cost of a collect (see `state-report-deferral.ts`), and
409
+ * none of them can change without a mount or an edit, which the deferred full
410
+ * collect that always follows a reusing one picks up.
411
+ *
412
+ * Everything NOT named here is derived fresh on every single report, including
413
+ * the fields a reader needs the instant they change: play state, loop
414
+ * liveness, selection, active document, save state, presence, and every error
415
+ * and warning channel.
416
+ *
417
+ * THE HOST'S HALF ONLY. The four capability-GRADING families
418
+ * (`rootCoverage`, `systemCoverage`, `projectCoverage`, `authoringCoverage`)
419
+ * used to be listed here; they are `@vgai/game`'s now
420
+ * (`contributions/coverage.service.ts`) and declare their own reusable keys
421
+ * on `session.reportFacet`, which {@link reusableFacetKeys} reads back. Both
422
+ * blockers P5b measured against that move are gone: the facet registry
423
+ * carries reusable key names and hands the collect its `reuse` snapshot, and
424
+ * the vitals' union call is now a subscription to the SAME five-second sample
425
+ * (`coverage/session-vitals.ts`'s `onSessionSample`) rather than a second
426
+ * timer. What is left in the host is the question the grade was asked ABOUT —
427
+ * `authoring/mounted-root-subjects.ts` — which was never a grade.
428
+ */
429
+ export const REUSABLE_DERIVED_FACETS = ['entities', 'entityCount', 'ontologyInvariants'] as const;
430
+
431
+ /** The immediate interaction report is a PATCH over the last full state. The
432
+ * server already owns that full snapshot; resending these unchanged,
433
+ * tree-scale facets on every runtime structure/store notification turns a
434
+ * large Play world into megabytes of duplicate control traffic per frame.
435
+ *
436
+ * A contributed facet's own reusable keys are stripped the same way — read
437
+ * live, because a contribution pass adds and removes facets while the session
438
+ * runs. */
439
+ function currentStatePatch(state: Record<string, unknown>): Record<string, unknown> {
440
+ const patch = { ...state };
441
+ for (const facet of REUSABLE_DERIVED_FACETS) delete patch[facet];
442
+ for (const facet of reusableFacetKeys()) delete patch[facet];
443
+ return { ...patch, _statePatch: true };
444
+ }
445
+
446
+ export function collectState(
447
+ store: EditorShellStore,
448
+ /**
449
+ * A previous full snapshot whose {@link REUSABLE_DERIVED_FACETS} this collect
450
+ * may copy instead of re-deriving, or `null`/omitted for a full collect.
451
+ *
452
+ * This is the interaction path's escape from a 76ms-to-1.3s synchronous
453
+ * derivation on every store notification. It is deliberately a caller's
454
+ * choice rather than an internal cache: only the caller knows whether it is
455
+ * on a user's critical path, and only the caller can promise the deferred
456
+ * full collect that makes the reused halves current again.
457
+ */
458
+ reuse: Record<string, unknown> | null = null,
459
+ ): Record<string, unknown> {
460
+ // S-1 (the SimCity ledger's false-alive ingest status): `store.playState` is
461
+ // editor UI STATE — ingest and module mode both write 'playing' into it
462
+ // without ever owning the `play-mode.ts` session the whole debug seam gates
463
+ // on, and ingest writes it BEFORE its mount is even attempted. Reporting it
464
+ // verbatim is how a session printed `playState: "playing"` in the same breath
465
+ // as `game.state()` refusing with "not in play mode", and how a mount that
466
+ // threw still reported itself alive. Derive it from what is actually running.
467
+ const mountFailures = getMountFailureReports();
468
+ const activeDocumentId = activeWorkspaceDocumentId();
469
+ const documentPresentation = activeDocumentId
470
+ ? object3DDocumentSession(activeDocumentId)?.presentation()
471
+ : undefined;
472
+ const selectedIds = activeSelectionIds(store);
473
+ const gameplaySessions = toolGameplaySessions.getSnapshot();
474
+ // ONE walk, read by both `entityCount` and `entities` below. It used to be
475
+ // called once for each, and the walk is the expensive half of this whole
476
+ // function (23ms of a 76ms collect at 251 nodes, measured 2026-08-19) — two
477
+ // identical breadth-first traversals of the same tree in the same tick.
478
+ const hierarchyRows =
479
+ (reuse?.['entities'] as ReturnType<typeof activeHierarchyRows> | undefined) ??
480
+ activeHierarchyRows(store);
481
+ return {
482
+ playState: deriveReportedPlayState({
483
+ storePlayState: store.playState,
484
+ liveMounted: anyLiveSessionMounted(),
485
+ livePlaying: anyLiveSessionPlaying(),
486
+ mountFailures,
487
+ }),
488
+ // Every world whose mount FAILED, with the error that killed it. `[]` on a
489
+ // healthy session; non-empty with `ingest: null` is what a dead game looks
490
+ // like from the control API, instead of a silent "playing". (`ingest` and
491
+ // `ingestCaptureWait` are the ingest lane's own facets, registered by it.)
492
+ mountFailures: mountFailures.map((r) => ({ ...r })),
493
+ // PD-3 — every project module that was evaluated more than once during
494
+ // the current mount, i.e. every module whose module-level state the roots
495
+ // no longer share. `[]` on a healthy mount. A split does not fail the
496
+ // mount (both copies run), so this is the ONLY machine-readable signal
497
+ // that it happened at all.
498
+ moduleSplits: getProjectModuleSplitReports().map((s) => ({ path: s.path, urls: [...s.urls] })),
499
+ // The project's own ADAPTER, resolved (project-adapter.ts): which module
500
+ // supplied the binding table (`vgai.adapter.ts`, or the declared native
501
+ // default), the regions derived for it, and its scene table. `null` means
502
+ // NOBODY HAS LOOKED YET — deliberately distinct from a loaded adapter with
503
+ // an empty table, which is a real (and gradable) answer.
504
+ adapter: projectAdapterFacet(),
505
+ // DECLARED READINESS, per root (`readiness.ts`). One row per mounted root
506
+ // saying WHO answers "is this ready" — the host's own completed mount, the
507
+ // game's `window.vgaiGame.ready`, or a host-side measured wait. `[]` means
508
+ // nothing has mounted, which is not "nothing is ready".
509
+ readiness: readinessFacet().map((entry) => ({ ...entry })),
510
+ // The ONE sentence for a project whose readiness is entirely measured. It
511
+ // is the visibility half of the declared-vs-measured rule: an undeclared
512
+ // game still works, it just stops being silent about it. `null` when
513
+ // nothing is mounted or at least one root declared.
514
+ readinessWarning: measuredReadinessWarning(),
515
+ // WHICH WORLD the capture adopted for a self-booting root and how
516
+ // (`declared` from the game's contract, or the trap's measured
517
+ // first-non-host-render), plus every DISTINCT world that rendered
518
+ // afterwards. The adoption itself is unchanged; this is the reader that
519
+ // used to not exist for a permanent, silent choice.
520
+ worldAdoption: worldAdoptionFacet().map((entry) => ({ ...entry })),
521
+ presence: collectPresence(),
522
+ // Target-blaster friction #3 (#146 ledger): the play-verify loop's error
523
+ // channel — the same current-run-fenced uncaught-error list the relay
524
+ // snapshot carries (see collectPlayRunPageErrors), so `vgai status` answers
525
+ // "did anything go wrong since play started" without a second command.
526
+ // [] while stopped or when nothing threw.
527
+ pageErrors: collectPlayRunPageErrors(),
528
+ // The OTHER half of the same question, and the half that was missing: an
529
+ // otherwise-HEALTHY run that logged errors. `pageErrors` above is the
530
+ // 'runtime' slice (uncaught error/unhandledrejection) and is rendered only
531
+ // by the play-FAILED path, so a game that threw five `console.error`s
532
+ // during a fine-looking play run said nothing to any CLI reader — the same
533
+ // "already in the JSON and invisible in practice" failure PD-13 describes.
534
+ // Disjoint from `pageErrors` by construction (see collectConsoleErrors), so
535
+ // the two counts sum.
536
+ consoleErrors: collectConsoleErrors(),
537
+ // The SESSION-lifetime half — everything the two play-fenced facets above
538
+ // do not claim, which before `installEditorConsoleCapture()` was
539
+ // everything an editor frame logged while play was stopped and reached no
540
+ // reader at all (see `collectSessionErrors`). Disjoint from both by
541
+ // construction, so all three counts sum.
542
+ sessionErrors: collectSessionErrors(),
543
+ // Warnings had NO CLI-facing facet whatsoever until this one.
544
+ sessionWarnings: collectSessionWarnings(),
545
+ // GPU ownership is measured independently from DOM canvas attachment.
546
+ // These live counters make context-budget regressions observable without
547
+ // waiting for the browser to evict the oldest viewport.
548
+ rendererResources: {
549
+ hostLive: liveHostRendererCount(),
550
+ interactive: interactiveViewportRendererCounts(),
551
+ inspectorPreview: inspectorPreviewRendererCounts(),
552
+ },
553
+ // The ontology's LIVE invariants, re-derived on read. Every row is present
554
+ // every time — including the ones this session cannot measure, which say so
555
+ // rather than vanishing (`coverage/ontology-invariants.ts`).
556
+ ontologyInvariants:
557
+ reuse?.['ontologyInvariants'] ?? ontologyInvariantFacet().map((row) => ({ ...row })),
558
+ // What only a running lane knows — its loop's time scale and liveness,
559
+ // its seed, a pending restart, and the four capability-GRADING families
560
+ // (`editor-state-facets.ts`). `reuse` rides through: a facet that declared
561
+ // reusable keys serves them from the snapshot instead of re-deriving.
562
+ ...collectEditorStateFacets(reuse),
563
+ selectedEntityId: selectedIds[0] ?? null,
564
+ selectedEntityIds: selectedIds,
565
+ // The machine door onto the creation-site index: the source
566
+ // location that constructed the SELECTED object, or the named reason there
567
+ // isn't one. `null` only when nothing is selected or the active adapter
568
+ // indexes no creation sites.
569
+ selectedCreationSite: activeSelectionCreationSite(store),
570
+ // The WRITE-side half of the same answer: which lane an edit to the
571
+ // selection would take (`WriteAnchorKind`). A sweep needs it to find one
572
+ // representative subject per lane — the anchor above cannot distinguish a
573
+ // JSX prop from a construction literal, nor either from a body-placed
574
+ // spawn, and those have different correctness contracts.
575
+ selectedWriteAnchorKind: activeSelectionWriteAnchorKind(store),
576
+ activeViewportTab: store.activeViewportTab,
577
+ activeDocumentId,
578
+ activeUtilityId: activeWorkspaceUtility(),
579
+ gameplaySession: {
580
+ selectedSessionId: gameplaySessions.selectedSessionId,
581
+ latestSessionId: gameplaySessions.sessions[0]?.id ?? null,
582
+ selectedStatus: gameplaySessions.selectedSession?.status ?? null,
583
+ cursorMs: gameplaySessions.cursorMs,
584
+ liveEdgeMs: gameplaySessions.liveEdgeMs,
585
+ },
586
+ // The registry is the authority for which center subjects EXIST. Doctor
587
+ // uses this to photograph the project's actual component boards instead
588
+ // of spending long activation windows guessing every medium-specific id.
589
+ openDocumentIds: openWorkspaceDocuments().map((document) => document.descriptor.id),
590
+ // W2: asset viewers are center workspace documents now; this facet keeps
591
+ // its legacy key vocabulary for control-API consumers — the active ASSET
592
+ // document's key, or the '__inspector__' sentinel. The sentinel does NOT
593
+ // imply a visible Inspector: that surface is selection-owned.
594
+ activeTabKey: (() => {
595
+ const activeId = activeWorkspaceDocumentId();
596
+ return activeId && isAssetDocumentId(activeId) ? activeId : '__inspector__';
597
+ })(),
598
+ showGrid: documentPresentation?.grid ?? store.showGrid,
599
+ // Per-STAGE view options, reported for the stage the person is looking at
600
+ // — the same store the `set-helpers`/`set-stats` verbs write and the
601
+ // focused stage's own viewport reads (ARCHITECTURE-CORE §One stage unit 4).
602
+ showHelpers: focusedStageStore(store).showHelpers,
603
+ showStats: focusedStageStore(store).showStats,
604
+ shadingMode: documentPresentation?.mode ?? store.shadingMode,
605
+ helperVisibility: {
606
+ ...focusedStageStore(store).helperVisibility,
607
+ ...(documentPresentation
608
+ ? {
609
+ bounds: documentPresentation.bounds,
610
+ skeletons: documentPresentation.skeleton,
611
+ }
612
+ : {}),
613
+ },
614
+ // THE ARMED TOOL IS THE ACTIVE DOCUMENT'S STAGE'S, the same door
615
+ // `showHelpers`/`showStats` above already read. Every stage owns an
616
+ // `EditorShellStore` (`stage-store-registry.ts`) and the shelf's four
617
+ // tools write the one the person is looking at; reading the SHELL's here
618
+ // reported `combined` for every tool on any document with a stage of its
619
+ // own — measured on the Model document at WALK 5 row 4b, where the shelf
620
+ // was lit on Select Box and this line said `combined`. The world root's
621
+ // stage runs on the shell store itself, so the Scene document's answer is
622
+ // unchanged.
623
+ transformMode: focusedStageStore(store).transformMode,
624
+ transformSpace: store.transformSpace,
625
+ snapEnabled: store.snapEnabled,
626
+ entityCount: hierarchyRows.length,
627
+ // Persistence truth comes from the ACTIVE adapter's PersistenceProvider —
628
+ // never a per-format store field.
629
+ savePath: activeSaveDestination(store),
630
+ // SDK clients need the same persistence truth the editor UI exposes.
631
+ saveState: activeSaveState(store),
632
+ // Camera facet (B3-followup): real pose when a viewport is bound, absent
633
+ // otherwise — never a fabricated value (see EditorShellStore.cameraPose).
634
+ camera: collectCamera(store),
635
+ // Hierarchy facet (B3-followup): the real node tree, flattened to
636
+ // id/name/childIds rows (same shape editor.hierarchy.inspect declares),
637
+ // read from the ACTIVE authoring adapter.
638
+ entities: hierarchyRows,
639
+ // Design-surface first-frame stamps. Doctor `--timings` subtracts these
640
+ // from its `active-tab` ack so a tab flip is a measured wait, not a
641
+ // blank canvas with no number.
642
+ viewportActivationTimings: viewportActivationTimings(),
643
+ };
644
+ }
645
+
646
+ /**
647
+ * The answer a caller gets when a command HANDLER threw instead of returning.
648
+ *
649
+ * Two things must happen and neither used to: the caller is told what killed
650
+ * its command (rather than timing out against a message about the tab), and
651
+ * the failure is logged so it reaches `editorConsole` — which is what
652
+ * `collectSessionErrors` reads, and therefore what `vgai status` prints. The
653
+ * browser's own `unhandledrejection` path did the second job only for the
654
+ * FIRST occurrence, because the console capture dedupes an identical message.
655
+ */
656
+ export function commandThrewResult(cmd: EditorCommand, error: unknown): CommandResult {
657
+ const message = error instanceof Error ? error.message : String(error);
658
+ const text = `editor command "${String(cmd['type'])}" threw: ${message}`;
659
+ // biome-ignore lint/suspicious/noConsole: this IS the loud leg — editorConsole is fed by the console, and it is what `vgai status` prints.
660
+ console.error(text, error);
661
+ return { ok: false, error: text };
662
+ }
663
+
664
+ /**
665
+ * The dimensions a capture verb was asked for, or the refusal that names the
666
+ * value it was handed.
667
+ *
668
+ * SQUARE IS THE DEFAULT and `size` is how you ask for one. `width`+`height`
669
+ * together ask for a SHAPED frame — a video-aspect look that needs no crop
670
+ * afterwards — and are bounded per side and in TOTAL by the same relay budget
671
+ * the square ceiling comes from. Mixing the two forms is refused rather than
672
+ * silently resolved: a caller who sent both does not know which one they meant.
673
+ *
674
+ * `cmd` is WIRE INPUT — a JSON object from another process — so `cmd['size']`
675
+ * had never been anything but a cast (`as number | undefined`). A caller that
676
+ * sent a non-number got that value multiplied by 2 deep inside
677
+ * `_renderViewportImage` and `createImageData(NaN, NaN)` threw
678
+ * `TypeError: Value is not of type 'long'` from the middle of the render path;
679
+ * because the dispatcher's `.then()` had no rejection leg, the throw answered
680
+ * nobody and the caller waited out its whole budget for
681
+ * "the tab is present … and did not respond" — a message about the TAB for a
682
+ * defect in the argument. Measured on a racing-game ingest mount, 2026-08-15.
683
+ */
684
+ export function captureSizeFromCommand(
685
+ cmd: EditorCommand,
686
+ ): { size?: CaptureDimensions } | { error: string } {
687
+ const raw = cmd['size'];
688
+ const rawWidth = cmd['width'];
689
+ const rawHeight = cmd['height'];
690
+ const shaped = rawWidth !== undefined || rawHeight !== undefined;
691
+ if (shaped) {
692
+ if (raw !== undefined && raw !== null) {
693
+ return {
694
+ error:
695
+ `${String(cmd['type'])}: pass "size" (a square) OR "width"+"height" (a shaped frame), ` +
696
+ 'never both.',
697
+ };
698
+ }
699
+ const dimension = (name: string, value: unknown): number | string => {
700
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
701
+ return (
702
+ `${String(cmd['type'])}: "${name}" must be a whole number — got ` +
703
+ `${typeof value} ${JSON.stringify(value) ?? String(value)}.`
704
+ );
705
+ }
706
+ if (value < MIN_CAPTURE_DIMENSION || value > MAX_CAPTURE_DIMENSION) {
707
+ return `${String(cmd['type'])}: "${name}" must be ${MIN_CAPTURE_DIMENSION}-${MAX_CAPTURE_DIMENSION} — ${CAPTURE_BUDGET_REASON}`;
708
+ }
709
+ return value;
710
+ };
711
+ const width = dimension('width', rawWidth);
712
+ if (typeof width === 'string') return { error: width };
713
+ const height = dimension('height', rawHeight);
714
+ if (typeof height === 'string') return { error: height };
715
+ if (width * height > MAX_CAPTURE_DIMENSION * MAX_CAPTURE_DIMENSION) {
716
+ return {
717
+ error:
718
+ `${String(cmd['type'])}: ${width}x${height} is ${width * height} pixels, past the ` +
719
+ `${MAX_CAPTURE_DIMENSION}x${MAX_CAPTURE_DIMENSION} total — ${CAPTURE_BUDGET_REASON}`,
720
+ };
721
+ }
722
+ return { size: { width, height } };
723
+ }
724
+ if (raw === undefined || raw === null) return {};
725
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 1) {
726
+ return {
727
+ error:
728
+ `${String(cmd['type'])}: "size" must be a finite number >= 1 — got ` +
729
+ `${typeof raw} ${JSON.stringify(raw) ?? String(raw)}.`,
730
+ };
731
+ }
732
+ return { size: raw };
733
+ }
734
+
735
+ /**
736
+ * The editor's own capture ceiling, and WHY it is this number.
737
+ *
738
+ * 64..1024 per side is `asset-preview.ts`'s `MIN_SIZE`/`MAX_SIZE`: the pixels
739
+ * cross the editor relay as base64 JSON, and 1024 is where even incompressible
740
+ * RGBA still fits its 50 MB request limit. A shaped frame is held to the same
741
+ * BUDGET rather than a laxer one — its total may not exceed a 1024 square —
742
+ * because the budget is about bytes on the wire, not about shape.
743
+ */
744
+ const MIN_CAPTURE_DIMENSION = 64;
745
+ const MAX_CAPTURE_DIMENSION = 1024;
746
+ const CAPTURE_BUDGET_REASON =
747
+ "the editor's own ceiling: the pixels cross the relay as base64 JSON, and 1024 is where even " +
748
+ 'incompressible RGBA still fits its 50 MB request limit. For more picture, take more views, ' +
749
+ 'not bigger ones.';
750
+
751
+ function activeObject3DDocumentSession() {
752
+ const documentId = activeWorkspaceDocumentId();
753
+ return documentId ? object3DDocumentSession(documentId) : null;
754
+ }
755
+
756
+ /** THE ONE PLAY-RUN FENCE all four error facets below split on: `true` when a
757
+ * console entry logged at `timestamp` belongs to the most recent play run.
758
+ *
759
+ * Closed at BOTH ends (the live registry's newest run window), which is
760
+ * what makes the play facets and the session facets a genuine partition of the
761
+ * session's error entries — every entry belongs to exactly one side, so the
762
+ * counts sum with nothing double-counted and nothing dropped. With an
763
+ * open-ended window a single play run captured the rest of the session: every
764
+ * later editor error read as "during the play run" and was reported only by a
765
+ * banner that renders while play is live. */
766
+ function inPlayRun(timestamp: number): boolean {
767
+ const window = liveRunWindow();
768
+ if (!window || timestamp < window.startedAt) return false;
769
+ return window.endedAt === null || timestamp <= window.endedAt;
770
+ }
771
+
772
+ /** #146 — uncaught page errors from the CURRENT play run, for `vgai status`
773
+ * and for `@vgai/game`'s `bridge-call` snapshot, which imports it from here
774
+ * (the two must report the same set; the facet moves when Play does).
775
+ * Reads the editor console's 'runtime'-source error entries
776
+ * (fed by `installEditorConsoleCapture`'s window error/unhandledrejection
777
+ * capture — the same events, same message format as `window.__vgai`'s own
778
+ * pageErrors ring buffer), fenced to the play run. Same 100-cap as the bridge
779
+ * (`PAGE_ERROR_CAP`), keeping the most recent. */
780
+ export function collectPlayRunPageErrors(): string[] {
781
+ return editorConsole
782
+ .getEntries()
783
+ .filter((e) => e.level === 'error' && e.source === 'runtime' && inPlayRun(e.timestamp))
784
+ .slice(-100)
785
+ .map((e) => (e.count > 1 ? `${e.message} (×${e.count})` : e.message));
786
+ }
787
+
788
+ /** How many of the newest console-error messages the summary carries, and how
789
+ * far each is truncated. A banner that reprints every message on a noisy run
790
+ * stops being readable, which is the failure mode this exists to fix (same
791
+ * reasoning as `authoringWarningsWarning`'s per-file summary in the CLI); the
792
+ * full text is in the editor console and the run's `logs/play-*.jsonl`. */
793
+ const CONSOLE_ERROR_SAMPLE = 3;
794
+ const CONSOLE_ERROR_MESSAGE_CAP = 200;
795
+
796
+ /** Error-level editor-console entries from the CURRENT play run that
797
+ * `collectPlayRunPageErrors` above does NOT already report — i.e. everything except
798
+ * the 'runtime' source. That exclusion is what makes the two facets disjoint,
799
+ * so a reader can add the counts without double-counting one error.
800
+ *
801
+ * The dominant member is source 'game': `play-mode.ts`'s console patch funnels
802
+ * the running game's own `console.error` here, and that channel reached no CLI
803
+ * reader at all. `count` totals OCCURRENCES (the console store collapses
804
+ * identical consecutive messages into one entry with a `count`), so a loop
805
+ * erroring every frame reports the real number rather than 1. Fenced to
806
+ * the live run window exactly like `collectPlayRunPageErrors`, and `{count: 0,
807
+ * recent: []}` while stopped. */
808
+ function collectConsoleErrors(): { count: number; recent: string[] } {
809
+ return summarizeEntries(
810
+ editorConsole
811
+ .getEntries()
812
+ .filter((e) => e.level === 'error' && e.source !== 'runtime' && inPlayRun(e.timestamp)),
813
+ );
814
+ }
815
+
816
+ /** The shared `{count, recent}` shape: `count` totals OCCURRENCES (the console
817
+ * store collapses identical consecutive messages into one entry carrying a
818
+ * `count`), `recent` is the newest few, source-tagged and truncated. */
819
+ function summarizeEntries(entries: readonly ConsoleEntry[]): { count: number; recent: string[] } {
820
+ const count = entries.reduce((sum, e) => sum + e.count, 0);
821
+ const recent = entries.slice(-CONSOLE_ERROR_SAMPLE).map((e) => {
822
+ const source = e.source ? `[${e.source}] ` : '';
823
+ const repeats = e.count > 1 ? ` (×${e.count})` : '';
824
+ const message =
825
+ e.message.length > CONSOLE_ERROR_MESSAGE_CAP
826
+ ? `${e.message.slice(0, CONSOLE_ERROR_MESSAGE_CAP)}…`
827
+ : e.message;
828
+ return `${source}${message}${repeats}`;
829
+ });
830
+ return { count, recent };
831
+ }
832
+
833
+ /** Error-level entries from the whole EDITOR SESSION that the two play-fenced
834
+ * facets above do NOT report — i.e. everything logged outside the most recent
835
+ * play run's window (`inPlayRun`). That exclusion is what keeps all three
836
+ * disjoint, exactly as `collectConsoleErrors` excludes source 'runtime'.
837
+ *
838
+ * Measured defect this closes: a human watching the editor's browser console
839
+ * saw real errors — Content-tab story previews throwing `useRapier must be
840
+ * used within <Physics>` — while `vgai status` reported `consoleErrors:
841
+ * {count: 0}` and `pageErrors: []`. Nothing was wrong with either facet: both
842
+ * are fenced to a play run, and the ONLY funnel from a raw `console.error`
843
+ * into the store was play-mode's patch, installed at play start and removed at
844
+ * play stop. Outside play, an editor-frame error existed in the browser
845
+ * console and nowhere else. `installEditorConsoleCapture()` (editor boot) now
846
+ * feeds the store for the whole session, and this is the facet that reports
847
+ * it — including uncaught page errors ('runtime') thrown outside a play run,
848
+ * which `collectPlayRunPageErrors` deliberately still does not claim.
849
+ *
850
+ * Fence: since editor page load (the store starts empty at boot), NOT since
851
+ * play start. */
852
+ function collectSessionErrors(): { count: number; recent: string[] } {
853
+ return summarizeEntries(
854
+ editorConsole.getEntries().filter((e) => e.level === 'error' && !inPlayRun(e.timestamp)),
855
+ );
856
+ }
857
+
858
+ /** Warn-level entries from the whole editor session. No play-fenced facet
859
+ * reports warnings at ALL — `console.warn` had no CLI-facing channel of any
860
+ * kind — so this one is not narrowed to outside-play: narrowing it would
861
+ * simply re-hide every warning a play run emits. Same `{count, recent}`
862
+ * discipline as the error facets; the `[source]` tag on each sample is what
863
+ * tells a reader whether a warning came from the game, the editor, or the
864
+ * server. */
865
+ function collectSessionWarnings(): { count: number; recent: string[] } {
866
+ return summarizeEntries(editorConsole.getEntries().filter((e) => e.level === 'warn'));
867
+ }
868
+
869
+ /**
870
+ * `document-script` relay op — `editor.document.run(ctx => …)`. The same
871
+ * wire contract as `page-script` (a step's own source, reconstructed here;
872
+ * closures do not survive), bound not to a page shim but to the object the
873
+ * ACTIVE document published as its context. Refuses by name when no document
874
+ * is active or the active one published nothing, so an agent is never told
875
+ * a step ran against a document that has no session to run it on.
876
+ */
877
+ async function handleDocumentScript(cmd: EditorCommand): Promise<CommandResult> {
878
+ const src = cmd['src'];
879
+ if (typeof src !== 'string') {
880
+ return { ok: false, error: 'document-script requires a string "src" (the step\'s toString())' };
881
+ }
882
+ const documentId = activeWorkspaceDocumentId();
883
+ if (!documentId) {
884
+ return {
885
+ ok: false,
886
+ error: 'document-script: no document is active',
887
+ data: { code: 'DOCUMENT_SCRIPT_UNAVAILABLE' },
888
+ };
889
+ }
890
+ await waitForDocumentContext(documentId);
891
+ const context = documentContextFor(documentId);
892
+ if (activeWorkspaceDocumentId() !== documentId) {
893
+ return {
894
+ ok: false,
895
+ error: 'document-script: the active document changed while its context was loading',
896
+ data: { code: 'DOCUMENT_SCRIPT_UNAVAILABLE', documentId },
897
+ };
898
+ }
899
+ if (context === undefined) {
900
+ return {
901
+ ok: false,
902
+ error:
903
+ `document-script: the active document (${documentId}) publishes no context to run ` +
904
+ 'against — a document opts in through its `publishContext` prop (the mesh document ' +
905
+ 'publishes its session).',
906
+ data: { code: 'DOCUMENT_SCRIPT_UNAVAILABLE', documentId },
907
+ };
908
+ }
909
+ let step: (ctx: unknown, info: { documentId: string }) => unknown;
910
+ try {
911
+ step = new Function('ctx', 'info', `return (${src})(ctx, info)`) as typeof step;
912
+ } catch (err) {
913
+ return {
914
+ ok: false,
915
+ error: `document-script: failed to reconstruct the step function from source — ${
916
+ err instanceof Error ? err.message : String(err)
917
+ }`,
918
+ data: { code: 'DOCUMENT_SCRIPT_ERROR' },
919
+ };
920
+ }
921
+ try {
922
+ const result = await step(context, { documentId });
923
+ return { ok: true, data: { result: result === undefined ? null : result } };
924
+ } catch (err) {
925
+ return {
926
+ ok: false,
927
+ error: `document-script: step threw — ${err instanceof Error ? err.message : String(err)}`,
928
+ data: { code: 'DOCUMENT_SCRIPT_ERROR' },
929
+ };
930
+ }
931
+ }
932
+
933
+ async function handleAssetPreviewCommand(
934
+ store: EditorShellStore,
935
+ cmd: EditorCommand,
936
+ ): Promise<CommandResult> {
937
+ const assetPath = cmd['assetPath'];
938
+ const entityId = cmd['entityId'];
939
+ // The THIRD source: a GLB that travels IN the command rather than being
940
+ // fetched or looked up — the module-look lane (`project.bake.preview`)
941
+ // builds an Object3D in Node, where there is no GPU, and hands the editor
942
+ // the exported bytes. Counted rather than XOR-ed because there are now more
943
+ // than two sources and "exactly one" has to stay exactly one.
944
+ const glbBase64 = cmd['glbBase64'];
945
+ const sourceCount =
946
+ Number(typeof assetPath === 'string') +
947
+ Number(typeof entityId === 'string') +
948
+ Number(typeof glbBase64 === 'string');
949
+ if (sourceCount !== 1) {
950
+ return {
951
+ ok: false,
952
+ error: 'capture-asset-preview requires exactly one of assetPath, entityId or glbBase64.',
953
+ };
954
+ }
955
+ const width = cmd['width'];
956
+ const height = cmd['height'];
957
+ const background = cmd['background'];
958
+ const shots = cmd['shots'];
959
+ const shotSet = cmd['shotSet'];
960
+ const forward = cmd['forward'];
961
+ const compare = cmd['compare'];
962
+ const stage = cmd['stage'];
963
+ const camera = cmd['camera'];
964
+ const pose = cmd['pose'];
965
+ if (
966
+ (width !== undefined && typeof width !== 'number') ||
967
+ (height !== undefined && typeof height !== 'number') ||
968
+ (background !== undefined && background !== 'neutral' && background !== 'transparent') ||
969
+ (shots !== undefined && shots !== 'source')
970
+ ) {
971
+ return { ok: false, error: 'Invalid asset preview dimensions, background, or shots mode.' };
972
+ }
973
+ if (stage !== undefined && stage !== 'lab' && stage !== 'scene') {
974
+ return {
975
+ ok: false,
976
+ error: `capture-asset-preview stage must be "lab" or "scene", got ${String(stage)}.`,
977
+ };
978
+ }
979
+ if (shotSet !== undefined && shots !== undefined) {
980
+ return {
981
+ ok: false,
982
+ error: 'capture-asset-preview cannot combine a shotSet definition with a shots mode.',
983
+ };
984
+ }
985
+ // The free capture camera and clip pose (`--azimuth/--elevation/--distance`,
986
+ // `--clip/--time`) belong to the plain Asset Lab legs: a shot set / source
987
+ // review / compare each stage their own cameras and poses, and the scene
988
+ // stage photographs an entity where it stands. Refused by name, never
989
+ // silently ignored.
990
+ const cameraChoice = parseCameraChoice('capture-asset-preview', camera);
991
+ if (typeof cameraChoice === 'string') return { ok: false, error: cameraChoice };
992
+ const posedClip = parsePoseChoice('capture-asset-preview', pose);
993
+ if (typeof posedClip === 'string') return { ok: false, error: posedClip };
994
+ if (
995
+ (cameraChoice || posedClip) &&
996
+ (shots !== undefined || shotSet !== undefined || compare !== undefined || stage === 'scene')
997
+ ) {
998
+ return {
999
+ ok: false,
1000
+ error:
1001
+ 'capture-asset-preview cannot combine camera/pose with a shots mode, a shotSet ' +
1002
+ 'definition, compare, or stage "scene" — those legs stage their own cameras and poses.',
1003
+ };
1004
+ }
1005
+ // A project-defined labeled shot set travels WITH the command (the CLI
1006
+ // resolves `--shots <set>` through the registered
1007
+ // `project.<set>.previewShots` tool); validate the untrusted
1008
+ // definition at the relay boundary so a malformed contribution fails with
1009
+ // a named reason instead of a deep three.js error.
1010
+ let shotSetDefinition: ReturnType<typeof parseShotSetDefinition> | undefined;
1011
+ if (shotSet !== undefined) {
1012
+ try {
1013
+ shotSetDefinition = parseShotSetDefinition(shotSet);
1014
+ } catch (err) {
1015
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
1016
+ }
1017
+ }
1018
+ const parsedForward = parseForwardVector(forward);
1019
+ if (shots === 'source' && parsedForward === null) {
1020
+ return {
1021
+ ok: false,
1022
+ error: 'capture-asset-preview --shots source requires a non-degenerate forward [x,y,z].',
1023
+ };
1024
+ }
1025
+ if (compare !== undefined) {
1026
+ // B8.4 — the compare mode (`vgai screenshot <model.glb> --compare <ref.glb>`).
1027
+ // Validated here at the relay boundary so a malformed payload fails with
1028
+ // a named reason instead of a deep three.js error.
1029
+ if (shots !== undefined || shotSetDefinition !== undefined) {
1030
+ return { ok: false, error: 'capture-asset-preview cannot combine compare with shots.' };
1031
+ }
1032
+ if (
1033
+ typeof compare !== 'object' ||
1034
+ compare === null ||
1035
+ typeof (compare as Record<string, unknown>)['glbBase64'] !== 'string' ||
1036
+ ((compare as Record<string, unknown>)['forward'] !== undefined &&
1037
+ parseForwardVector((compare as Record<string, unknown>)['forward']) === null)
1038
+ ) {
1039
+ return {
1040
+ ok: false,
1041
+ error:
1042
+ 'capture-asset-preview compare requires { glbBase64: string, forward?: [x, y, z] } ' +
1043
+ 'with a non-degenerate ground-plane forward.',
1044
+ };
1045
+ }
1046
+ }
1047
+ // Wire-carried GLB bytes stand NOWHERE, so they take no scene stage and no
1048
+ // compare (whose reference is named some other way). An explicit `shotSet`
1049
+ // definition is a different matter and is served: a shot set stages the
1050
+ // subject itself, which is what lets `vgai screenshot <module> --orbit <n>`
1051
+ // circle a model that only ever existed as bytes. `shots: 'source'` stays
1052
+ // out — it is the asset-path review set, keyed to a stored forward vector.
1053
+ if (typeof glbBase64 === 'string') {
1054
+ if (shots !== undefined || compare !== undefined) {
1055
+ return {
1056
+ ok: false,
1057
+ error: 'capture-asset-preview cannot combine glbBase64 with a named shots mode or compare.',
1058
+ };
1059
+ }
1060
+ if (stage === 'scene') {
1061
+ return {
1062
+ ok: false,
1063
+ error:
1064
+ 'capture-asset-preview stage "scene" photographs a live scene entity — glbBase64 bytes stand nowhere in the scene.',
1065
+ };
1066
+ }
1067
+ }
1068
+ // `stage: 'scene'` photographs a LIVE entity in the live scene (see
1069
+ // `captureSceneStageAssetPreview`), so it is meaningful only for the plain
1070
+ // four-view entity capture: a model loaded from `assetPath` stands nowhere,
1071
+ // and the shot-set/source/compare modes each stage their own subject.
1072
+ // Refused by name at this boundary rather than silently downgraded to the
1073
+ // lab stage — a caller who asked for the scene and got the studio would
1074
+ // never know.
1075
+ const liveScene = stage === 'scene' ? store.scene : null;
1076
+ if (stage === 'scene') {
1077
+ if (typeof entityId !== 'string') {
1078
+ return {
1079
+ ok: false,
1080
+ error:
1081
+ 'capture-asset-preview stage "scene" photographs a live scene entity — pass entityId, not assetPath.',
1082
+ };
1083
+ }
1084
+ if (shots !== undefined || shotSetDefinition !== undefined || compare !== undefined) {
1085
+ return {
1086
+ ok: false,
1087
+ error:
1088
+ 'capture-asset-preview cannot combine stage "scene" with a shots mode, a shotSet definition or compare.',
1089
+ };
1090
+ }
1091
+ if (!liveScene) {
1092
+ return {
1093
+ ok: false,
1094
+ error:
1095
+ 'capture-asset-preview stage "scene" requires a bound viewport scene; none is bound yet.',
1096
+ };
1097
+ }
1098
+ }
1099
+ // Resolve the screenshot subject through the SAME active-document adapter
1100
+ // that produced `status.entities`, drives Hierarchy/Inspector selection and
1101
+ // answers `frame-entity`. An adopted Play scene can own a stable OID in its
1102
+ // projection without publishing that object through the shell's edit-mode
1103
+ // `objectMap`; checking the map alone made the final door reject an id every
1104
+ // preceding door had just accepted.
1105
+ const entityObject =
1106
+ typeof entityId === 'string'
1107
+ ? entityObject3D(activeDocumentAuthoring(store), store.objectMap, entityId)
1108
+ : null;
1109
+ if (typeof entityId === 'string' && !entityObject) {
1110
+ return { ok: false, error: `Entity not found: ${entityId}` };
1111
+ }
1112
+ try {
1113
+ const options = {
1114
+ ...(typeof width === 'number' ? { width } : {}),
1115
+ ...(typeof height === 'number' ? { height } : {}),
1116
+ ...(background ? { background: background as AssetPreviewBackground } : {}),
1117
+ ...(cameraChoice ? { camera: cameraChoice } : {}),
1118
+ ...(posedClip ? { pose: posedClip } : {}),
1119
+ };
1120
+ if (compare !== undefined) {
1121
+ const compareRecord = compare as { glbBase64: string; forward?: unknown };
1122
+ const refForward = parseForwardVector(compareRecord.forward);
1123
+ const compareOptions = {
1124
+ ...(typeof width === 'number' ? { width } : {}),
1125
+ ...(typeof height === 'number' ? { height } : {}),
1126
+ ...(refForward ? { refForward } : {}),
1127
+ };
1128
+ const capture =
1129
+ typeof assetPath === 'string'
1130
+ ? await captureModelComparePreview(assetPath, compareRecord.glbBase64, compareOptions)
1131
+ : await captureEntityComparePreview(
1132
+ entityObject!,
1133
+ compareRecord.glbBase64,
1134
+ compareOptions,
1135
+ );
1136
+ return { ok: true, data: { ...capture } };
1137
+ }
1138
+ if (shotSetDefinition !== undefined) {
1139
+ const capture =
1140
+ typeof glbBase64 === 'string'
1141
+ ? await captureShotSetGlbBytesPreview(glbBase64, shotSetDefinition, options)
1142
+ : typeof assetPath === 'string'
1143
+ ? await captureShotSetModelPreview(assetPath, shotSetDefinition, options)
1144
+ : captureShotSetAssetPreview(entityObject!, shotSetDefinition, options);
1145
+ return { ok: true, data: { ...capture } };
1146
+ }
1147
+ if (shots === 'source') {
1148
+ const capture =
1149
+ typeof assetPath === 'string'
1150
+ ? await captureSourceReviewShotSetModelPreview(assetPath, parsedForward!, options)
1151
+ : captureSourceReviewShotSetAssetPreview(entityObject!, parsedForward!, options);
1152
+ return { ok: true, data: { ...capture } };
1153
+ }
1154
+ if (liveScene) {
1155
+ const capture = captureSceneStageAssetPreview(entityObject!, liveScene, options);
1156
+ return { ok: true, data: { ...capture } };
1157
+ }
1158
+ if (typeof glbBase64 === 'string') {
1159
+ const capture = await captureGlbBytesAssetPreview(glbBase64, options);
1160
+ return { ok: true, data: { ...capture } };
1161
+ }
1162
+ const capture =
1163
+ typeof assetPath === 'string'
1164
+ ? await captureModelAssetPreview(assetPath, options)
1165
+ : captureObjectAssetPreview(entityObject!, options);
1166
+ return { ok: true, data: { ...capture } };
1167
+ } catch (err) {
1168
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
1169
+ }
1170
+ }
1171
+
1172
+ /**
1173
+ * Exported (alongside `collectState` above) so unit tests can dispatch
1174
+ * commands directly against a headless `EditorShellStore`, with no SSE/server
1175
+ * round-trip — see `packages/editor/test/command-listener.test.ts`, notably
1176
+ * the false-ack regression test: an unrecognized command must return
1177
+ * `{ok:false}`, never fall through to a fabricated `{ok:true}`.
1178
+ */
1179
+ export async function handleCommand(
1180
+ store: EditorShellStore,
1181
+ cmd: EditorCommand,
1182
+ options?: CommandHandlingOptions,
1183
+ ): Promise<CommandResult> {
1184
+ // EVERY relayed command is observed (`command-dispatch.ts`): Play's idle
1185
+ // watchdog reads it, and a stamp filed per-case would quietly exclude
1186
+ // whichever case someone forgot.
1187
+ noteCommandDispatched(cmd['type'] as string);
1188
+ // A video export OWNS the paused run it is stepping frame by frame, so no
1189
+ // other command may touch it mid-export. `stop` is the one exception and
1190
+ // falls THROUGH: the export's cancel handle lives with the verb that
1191
+ // started it (`@vgai/game`'s `play.command.ts`), and its `stop` handler
1192
+ // aborts the controller before tearing the run down. The FLAG stays host
1193
+ // state (`gameplay-export-state.ts`) because two surfaces outside that verb
1194
+ // read it — this prologue and the PlayBar's transport ownership.
1195
+ if (isGameplayExportActive() && cmd['type'] !== 'stop') {
1196
+ return {
1197
+ ok: false,
1198
+ error: 'Video export owns this paused run. Stop it to cancel before another command.',
1199
+ };
1200
+ }
1201
+ // A running lane answers its own play verbs first (`LiveSession.command`,
1202
+ // the live registry): an ingested game owns its mount, so the first-party
1203
+ // boot must never be handed its container.
1204
+ const answered = dispatchLiveCommand(cmd as { type: string });
1205
+ if (answered) return answered;
1206
+ if (cmd['type'] === 'play' || cmd['type'] === 'restart') {
1207
+ // NO live ingest, but a mount FAILED — refuse with the failure instead of
1208
+ // letting `enterPlayMode` boot a first-party composition over an ingest
1209
+ // manifest. It cannot: `resolveRootBinding` throws for every ingest
1210
+ // identity by construction, and the sentence it throws is about
1211
+ // `ThreeHostContext` not carrying an `EditorStore` — true, internal, and
1212
+ // about a mechanism the reader was never using. MEASURED on the
1213
+ // bubbo-bubbo canvas ingest: the real cause was a capture window spent on
1214
+ // a hidden tab, and `vgai play` reported the resolver's contract note,
1215
+ // naming neither the game nor the reason. A failed mount is the answer to
1216
+ // "why can't I play this", whichever lane failed.
1217
+ const failures = getMountFailureReports();
1218
+ if (failures.length > 0) {
1219
+ return {
1220
+ ok: false,
1221
+ error:
1222
+ `this project's ${failures.length === 1 ? 'root' : 'roots'} did not mount, so there ` +
1223
+ `is nothing to ${cmd['type'] === 'play' ? 'play' : 'restart'}: ` +
1224
+ failures.map((f) => `"${f.worldId}" (${f.identity}) — ${f.message}`).join('; '),
1225
+ };
1226
+ }
1227
+ }
1228
+ // The dispatch keys off the TABLE's union, not off a raw string. Two things
1229
+ // follow, and they are the whole point of `command-table.ts`: a `case` whose
1230
+ // label is not a row does not compile, and a row with no `case` fails the
1231
+ // exhaustiveness check in the `default` below. The runtime guard in front of
1232
+ // it is what makes the narrowing honest — an unknown string from an older
1233
+ // CLI still reaches the "unknown command type" answer.
1234
+ // A PACKAGE'S verb (`@volter/editor-sdk/commands`, `command-registry.ts`):
1235
+ // answered by its own handler, through the same relay, ack and derivation
1236
+ // as the host's table below. Asked first so a contributed verb never
1237
+ // reads as "editor page predates this CLI".
1238
+ const contributed = contributedCommand(cmd['type']);
1239
+ if (contributed) {
1240
+ try {
1241
+ const answer = await contributed.handle(cmd);
1242
+ return {
1243
+ ok: answer.ok,
1244
+ ...(answer.error !== undefined ? { error: answer.error } : {}),
1245
+ ...(answer.data !== undefined ? { data: answer.data } : {}),
1246
+ };
1247
+ } catch (error) {
1248
+ return commandThrewResult(cmd, error);
1249
+ }
1250
+ }
1251
+ if (!isRelayCommandType(cmd['type'])) {
1252
+ return {
1253
+ ok: false,
1254
+ error:
1255
+ `unknown command type "${cmd['type']}" — not one of the editor's own, and no package this ` +
1256
+ 'project declares contributes it (or the editor page predates this CLI)',
1257
+ data: { code: 'UNKNOWN_COMMAND_TYPE' },
1258
+ };
1259
+ }
1260
+ const commandType: RelayCommandType = cmd['type'];
1261
+ switch (commandType) {
1262
+ // Selection
1263
+ case 'select': {
1264
+ const id = (cmd['id'] as string | null) ?? null;
1265
+ if (id !== null) {
1266
+ const refusal = unresolvedSelectionRefusal(store, id);
1267
+ if (refusal) return { ok: false, error: refusal };
1268
+ }
1269
+ applyControlSelection(store, id ? [id] : [], options);
1270
+ break;
1271
+ }
1272
+ case 'select-multiple': {
1273
+ const ids = cmd['ids'] as string[];
1274
+ // All-or-nothing: a partial selection silently dropping the id the caller
1275
+ // cared about is the same fabrication in a quieter form.
1276
+ for (const id of ids) {
1277
+ const refusal = unresolvedSelectionRefusal(store, id);
1278
+ if (refusal) return { ok: false, error: refusal };
1279
+ }
1280
+ applyControlSelection(store, ids, options);
1281
+ break;
1282
+ }
1283
+ case 'select-all': {
1284
+ applyControlSelection(
1285
+ store,
1286
+ activeHierarchyRows(store).map((row) => row.id),
1287
+ options,
1288
+ );
1289
+ break;
1290
+ }
1291
+
1292
+ // Viewport
1293
+ case 'focus-entity':
1294
+ if (!activeObject3DDocumentSession()?.frameIds([cmd['id'] as string])) {
1295
+ store.focusOnEntity(cmd['id'] as string);
1296
+ }
1297
+ break;
1298
+ // The STRICT entity-targeted sibling of `focus-entity`: the same edit
1299
+ // viewport framing, but an id nothing in the scene answers to is a named
1300
+ // refusal rather than the silent no-op `focus-entity` keeps (its viewport
1301
+ // action simply finds no object in `objectMap` and does nothing —
1302
+ // world-root-stage.ts). That matters for a scripted flow that frames an
1303
+ // entity and then photographs it: framing that quietly did nothing hands
1304
+ // back a confident picture of whatever the camera happened to be on.
1305
+ case 'frame-entity': {
1306
+ const id = cmd['id'];
1307
+ if (typeof id !== 'string' || id.length === 0) {
1308
+ return { ok: false, error: 'frame-entity requires a string "id" (the entity to frame).' };
1309
+ }
1310
+ const documentSession = activeObject3DDocumentSession();
1311
+ if (documentSession) {
1312
+ if (!documentSession.frameIds([id])) {
1313
+ return {
1314
+ ok: false,
1315
+ error: `Entity not found: ${id}`,
1316
+ data: { code: 'ENTITY_NOT_FOUND' },
1317
+ };
1318
+ }
1319
+ break;
1320
+ }
1321
+ // A Canvas Scene has no Object3D by design. Its native framing answer is
1322
+ // the adapter-owned screen rect consumed by CanvasSceneControls. The
1323
+ // provider may temporarily return null while Pixi is between layouts;
1324
+ // strict existence is therefore the owned hierarchy node + the rect
1325
+ // capability, not one timing-sensitive measurement.
1326
+ // Resolve against the active document just like Hierarchy/Inspector do.
1327
+ // The global edit-mode composite intentionally does not merge 2D rect
1328
+ // providers; the open Canvas Scene publishes its own native adapter.
1329
+ const activeAdapter = activeDocumentAuthoring(store);
1330
+ if (activeAdapter.hierarchy.node(id) !== null && activeAdapter.rects) {
1331
+ store.focusOnEntity(id);
1332
+ break;
1333
+ }
1334
+ // The SAME resolver the framing itself uses (`entity-object.ts`). Gating
1335
+ // on `store.objectMap` alone refused every entity of a running game: an
1336
+ // adopted play scene's nodes are the adapter's, not the shell map's.
1337
+ if (!entityObject3D(activeAdapter, store.objectMap, id)) {
1338
+ return { ok: false, error: `Entity not found: ${id}`, data: { code: 'ENTITY_NOT_FOUND' } };
1339
+ }
1340
+ store.focusOnEntity(id);
1341
+ break;
1342
+ }
1343
+ case 'focus-selection':
1344
+ if (!activeObject3DDocumentSession()?.frameSelection()) store.focusOnSelection();
1345
+ break;
1346
+ case 'view-preset': {
1347
+ // The cast below is the ONLY thing between the relay and a raw table
1348
+ // lookup, so an absent or unknown preset has to be refused BY NAME here.
1349
+ // Unguarded it reached `cameraPresetDirection` and threw
1350
+ // `directions[preset] is not iterable (cannot read property undefined)`
1351
+ // into the session console — a TypeError that names neither the command
1352
+ // nor the vocabulary (measured live, 2026-09-18, driving `editor.view`
1353
+ // through `vgai eval` on a game project). Same shape as `set-camera`
1354
+ // below: state what is required, list what is accepted.
1355
+ const requested = cmd['preset'];
1356
+ const presets = ['top', 'front', 'right', 'perspective'] as const;
1357
+ if (typeof requested !== 'string' || !(presets as readonly string[]).includes(requested)) {
1358
+ return {
1359
+ ok: false,
1360
+ error: `view-preset requires one of ${presets.join(', ')}, got ${
1361
+ requested === undefined ? 'nothing' : JSON.stringify(requested)
1362
+ }.`,
1363
+ };
1364
+ }
1365
+ const preset = requested as (typeof presets)[number];
1366
+ const documentSession = activeObject3DDocumentSession();
1367
+ if (documentSession) {
1368
+ documentSession.setViewPreset(preset === 'perspective' ? 'isometric' : preset);
1369
+ } else store.setViewPreset(preset);
1370
+ break;
1371
+ }
1372
+ case 'set-camera': {
1373
+ const position = cmd['position'] as { x: number; y: number; z: number } | undefined;
1374
+ const target = cmd['target'] as { x: number; y: number; z: number } | undefined;
1375
+ const fov = cmd['fov'] as number | undefined;
1376
+ if (
1377
+ !position ||
1378
+ !target ||
1379
+ typeof position.x !== 'number' ||
1380
+ typeof position.y !== 'number' ||
1381
+ typeof position.z !== 'number' ||
1382
+ typeof target.x !== 'number' ||
1383
+ typeof target.y !== 'number' ||
1384
+ typeof target.z !== 'number'
1385
+ ) {
1386
+ return { ok: false, error: 'set-camera requires numeric {x,y,z} position and target.' };
1387
+ }
1388
+ const documentSession = activeObject3DDocumentSession();
1389
+ if (documentSession) documentSession.setCameraPose(position, target, fov);
1390
+ else store.setCameraPose(position, target, fov);
1391
+ break;
1392
+ }
1393
+
1394
+ // THE AGENT'S LOOKING, AS A WATCHABLE ACT. These three drive the OPEN
1395
+ // Object3D document's own camera — the one the human is looking through —
1396
+ // and orbit/turntable ack only when the animated move ends, so a scripted
1397
+ // "walk around the model" is something a person sees happen rather than a
1398
+ // jump cut between two poses. They are document verbs by construction:
1399
+ // there is no Scene fallback, because the Scene viewport's camera answers
1400
+ // to `view-preset`/`set-camera` and has no framed subject to circle.
1401
+ case 'document-orbit':
1402
+ case 'document-turntable': {
1403
+ const documentSession = activeObject3DDocumentSession();
1404
+ if (!documentSession) {
1405
+ return {
1406
+ ok: false,
1407
+ error:
1408
+ `${cmd['type']} needs an Object3D document open and active (a model, a live ` +
1409
+ 'module, an entity model). Open one with `editor.openAsset(<path>)` first.',
1410
+ data: { code: 'NO_ACTIVE_OBJECT3D_DOCUMENT' },
1411
+ };
1412
+ }
1413
+ const seconds = cmd['type'] === 'document-orbit' ? cmd['duration'] : cmd['seconds'];
1414
+ if (
1415
+ seconds !== undefined &&
1416
+ (typeof seconds !== 'number' || !(seconds >= 0 && seconds <= 60))
1417
+ ) {
1418
+ return {
1419
+ ok: false,
1420
+ error: `${cmd['type']}: the move's length must be a number of seconds in 0..60.`,
1421
+ };
1422
+ }
1423
+ const outcome =
1424
+ cmd['type'] === 'document-orbit'
1425
+ ? await documentSession.orbit({
1426
+ ...(typeof cmd['azimuth'] === 'number' ? { azimuth: cmd['azimuth'] } : {}),
1427
+ ...(typeof cmd['elevation'] === 'number' ? { elevation: cmd['elevation'] } : {}),
1428
+ ...(typeof seconds === 'number' ? { duration: seconds } : {}),
1429
+ })
1430
+ : await documentSession.turntable({
1431
+ ...(typeof seconds === 'number' ? { seconds } : {}),
1432
+ ...(typeof cmd['revolutions'] === 'number'
1433
+ ? { revolutions: cmd['revolutions'] }
1434
+ : {}),
1435
+ });
1436
+ return { ok: true, data: { ...outcome } };
1437
+ }
1438
+ case 'document-frame': {
1439
+ const documentSession = activeObject3DDocumentSession();
1440
+ if (!documentSession) {
1441
+ return {
1442
+ ok: false,
1443
+ error:
1444
+ 'document-frame needs an Object3D document open and active. For the Scene ' +
1445
+ 'viewport use `frame-entity`/`focus-selection`.',
1446
+ data: { code: 'NO_ACTIVE_OBJECT3D_DOCUMENT' },
1447
+ };
1448
+ }
1449
+ const fit = cmd['fit'];
1450
+ if (fit !== undefined && (typeof fit !== 'number' || !(fit >= 0.1 && fit <= 10))) {
1451
+ return { ok: false, error: 'document-frame: "fit" must be a number in 0.1..10.' };
1452
+ }
1453
+ if (!documentSession.frame(typeof fit === 'number' ? fit : 1)) {
1454
+ return {
1455
+ ok: false,
1456
+ error: 'Nothing to frame: the document subject has no measurable bounds.',
1457
+ data: { code: 'EMPTY_FRAME_BOUNDS' },
1458
+ };
1459
+ }
1460
+ return { ok: true, data: { ...documentSession.cameraPose() } };
1461
+ }
1462
+
1463
+ // Panels
1464
+ case 'viewport-tab': {
1465
+ // The tabs are Edit · Play. `'scene'`/`'game'` are REMOVED, and
1466
+ // a removed value rejects loudly naming the replacement rather than
1467
+ // silently doing nothing (this command is driven by the CLI/SDK, where a
1468
+ // no-op reads as "the editor ignored me").
1469
+ const tab = cmd['tab'];
1470
+ const removed: Record<string, ViewportTab> = { scene: 'edit', game: 'play' };
1471
+ if (typeof tab === 'string' && removed[tab]) {
1472
+ return {
1473
+ ok: false,
1474
+ error:
1475
+ `viewport-tab: "${tab}" was REMOVED — the viewport tabs are ` +
1476
+ `Edit · Play. Fix: use "${removed[tab]}".`,
1477
+ };
1478
+ }
1479
+ if (tab !== 'edit' && tab !== 'play') {
1480
+ return { ok: false, error: `viewport-tab requires "edit" or "play", got ${String(tab)}.` };
1481
+ }
1482
+ store.setActiveViewportTab(tab);
1483
+ break;
1484
+ }
1485
+ // FOCUS A PANEL. The vocabulary is the static-panel REGISTRY, resolved by
1486
+ // `revealStaticPanel` at call time — the same resolution and the same
1487
+ // refusal a view's `panel` gets, and the same reveal the Window menu's own
1488
+ // items use. This handler recognises no panel by name.
1489
+ case 'show-panel': {
1490
+ try {
1491
+ const panel = cmd['panel'];
1492
+ if (typeof panel !== 'string' || panel.trim() === '') {
1493
+ return { ok: false, error: 'show-panel requires a non-empty "panel".' };
1494
+ }
1495
+ return { ok: true, data: { panel: await revealStaticPanel(panel) } };
1496
+ } catch (error) {
1497
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1498
+ }
1499
+ }
1500
+ // W2: the legacy right-rail tab commands keep their key vocabulary but
1501
+ // now drive the CENTER workspace documents (`asset-documents.tsx`).
1502
+ // '__inspector__' ("show the Inspector") is a no-op now: the Inspector is
1503
+ // selection-owned, so a command cannot force it open without a subject.
1504
+ case 'active-tab': {
1505
+ const key = cmd['key'] as string;
1506
+ // The ack must MEAN the document is in front. This handler used to
1507
+ // discard `activateWorkspaceDocument`'s boolean, so asking for a
1508
+ // document that does not exist acked `ok` while the workspace kept
1509
+ // showing whatever tab was already there — and every caller that
1510
+ // photographs, inspects or drives "the active document" then read a
1511
+ // surface it never asked for and had no way to notice (doctor's walk
1512
+ // photographed the UI board and filed it as the Scene). The refusal
1513
+ // lists what IS open, because a wrong id is nearly always a stale or
1514
+ // misspelled one and the registry already knows the real set.
1515
+ if (key !== '__inspector__' && !activateWorkspaceDocument(key)) {
1516
+ const open = openWorkspaceDocuments().map((d) => d.descriptor.id);
1517
+ return {
1518
+ ok: false,
1519
+ error:
1520
+ `No open workspace document with id "${key}" — nothing was activated. ` +
1521
+ (open.length > 0
1522
+ ? `Open documents: ${open.join(', ')}.`
1523
+ : 'No documents are open in this workspace.'),
1524
+ };
1525
+ }
1526
+ break;
1527
+ }
1528
+ case 'select-asset': {
1529
+ // The OTHER half of the browser's selection-vs-open contract
1530
+ // (`asset-selection.ts`): a single click SELECTS an asset and fills the
1531
+ // Inspector; a double click OPENS its document. Only `open` had a verb,
1532
+ // so every `asset.inspector` contribution — a project's own Inspector
1533
+ // door — was reachable by mouse alone.
1534
+ const path = cmd['path'];
1535
+ if (typeof path !== 'string' || path.trim() === '') {
1536
+ return { ok: false, error: 'select-asset requires a non-empty "path".' };
1537
+ }
1538
+ // No existence check, for the same reason `open-asset-tab` has none:
1539
+ // the sections that match report their own failures (the Edit Mesh
1540
+ // door says "exports no build()"), and a second file-exists door here
1541
+ // would answer for a tier it cannot see (a hosted project's source has
1542
+ // no `public/` listing). An unknown path selects and the Inspector
1543
+ // shows nothing matched, which is the same answer the browser gives.
1544
+ const clean = path.replace(/^\/+/, '');
1545
+ const name = clean.split('/').pop() ?? clean;
1546
+ const capability = assetCapabilities(name);
1547
+ setSelectedAsset({
1548
+ path,
1549
+ name,
1550
+ kind: assetDocumentKind(capability) ?? 'unknown',
1551
+ capabilities: capability,
1552
+ origin: 'project',
1553
+ });
1554
+ break;
1555
+ }
1556
+ case 'open-asset-tab': {
1557
+ const kind = cmd['kind'] as AssetKind;
1558
+ const documentId = openAssetDocument(store, cmd['path'] as string, kind);
1559
+ if (!(await waitForAssetDocumentInspector(documentId, kind))) {
1560
+ return {
1561
+ ok: false,
1562
+ error: `Asset document did not finish mounting its Inspector: ${documentId}`,
1563
+ };
1564
+ }
1565
+ break;
1566
+ }
1567
+ case 'close-asset-tab': {
1568
+ // Legacy key vocabulary: the deleted store-era `closeAssetTab` only
1569
+ // ever acted on asset tabs and no-op'd for anything else (scene/game/
1570
+ // story keys included). `closeWorkspaceDocument` itself has no such
1571
+ // guard — it closes ANY registered id, pinned or not (`closeable:
1572
+ // false` is only a UI-affordance rule, not a registry invariant; see
1573
+ // workspace-document-registry.ts) — so this command must keep the
1574
+ // no-op itself. `isAssetDocumentId` is exactly the legacy asset key
1575
+ // vocabulary check (project asset path / `asset-editor:entity:<id>` /
1576
+ // `online:<source>:<id>` — see asset-documents.tsx), so gating on it
1577
+ // blocks the pinned `workspace:scene`/`workspace:game` ids (and any
1578
+ // story `story:<path>#<name>` id) while preserving the real behavior
1579
+ // for actual asset tabs.
1580
+ const key = cmd['key'] as string;
1581
+ if (isAssetDocumentId(key)) closeWorkspaceDocument(key);
1582
+ break;
1583
+ }
1584
+ case 'toggle-command-palette':
1585
+ // The palette is the workbench's; the frame hands over the opener that
1586
+ // shows it (`editor-commands.ts`).
1587
+ openCommandPalette();
1588
+ break;
1589
+ case 'toggle-console':
1590
+ toggleConsoleUtility();
1591
+ break;
1592
+ // RELOAD THIS PAGE — `@vgai/live`'s `page.reload()` and P20's prescribed
1593
+ // recovery. Deliberately NOT routed through `page-script`: that verb is
1594
+ // gated on a mounted game surface, and the one thing a reload has to fix —
1595
+ // a page whose module-scope loaders and page-lifetime asset caches hold
1596
+ // bytes that have since changed on disk — is just as real with play
1597
+ // stopped, and just as real in a product that has no game at all.
1598
+ //
1599
+ // It is the HOST's for that last reason. It was a `@vgai/game` command
1600
+ // contribution until walk 5, so `page.reload()` answered `unknown command
1601
+ // type "page-reload"` in the model editor, which declares no `@vgai/game`.
1602
+ //
1603
+ // Scheduled for the NEXT task rather than run inline, so this handler can
1604
+ // return and the caller's ack can travel before the navigation tears the
1605
+ // channel down: the client acks the ORDER here and waits for the new page
1606
+ // load on the server's own tab table.
1607
+ case 'page-reload':
1608
+ setTimeout(() => {
1609
+ window.location.reload();
1610
+ }, 0);
1611
+ return { ok: true, data: { scheduled: true } };
1612
+ // NAMED WORKSPACES (ARCHITECTURE-CORE §Editor chrome) — the session
1613
+ // operation `editor.workspace(id)`, the third of the ruling's three
1614
+ // switching doors beside `Window → Workspace` and the registered actions.
1615
+ // A wrong id refuses and NAMES the vocabulary: this is a fixed registry,
1616
+ // so the refusal can be complete.
1617
+ case 'set-workspace': {
1618
+ const id = cmd['workspace'];
1619
+ if (!isEditorWorkspaceId(id)) {
1620
+ return {
1621
+ ok: false,
1622
+ error: `set-workspace requires one of ${editorWorkspaceIds().join(', ')}, got ${String(id)}.`,
1623
+ };
1624
+ }
1625
+ if (activeEditorWorkspace() === id)
1626
+ return { ok: true, data: { workspace: id, applied: true } };
1627
+ // Registered BEFORE the store flip — the dock's rebuild is what resolves
1628
+ // it, and that can land before this handler's next await point. Bounded,
1629
+ // because the store flip is real whether or not a dock is mounted to
1630
+ // follow it: a hung wait would otherwise be reported as "the editor did
1631
+ // not respond", which names the wrong thing.
1632
+ const rebuilt = whenEditorWorkspaceApplied().then(() => true);
1633
+ setEditorWorkspace(id);
1634
+ const applied = await Promise.race([
1635
+ rebuilt,
1636
+ new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 3000)),
1637
+ ]);
1638
+ // NOTHING REVEALS A DRAWER UTILITY HERE ANY MORE. `drawerUtility` was
1639
+ // this line's reason and it is retired: a Blender editor AREA is an
1640
+ // EDITOR GROUP, not a drawer view (orchestrator ruling 2026-09-19), so
1641
+ // the node editor and the UV editor are `workspace.document`
1642
+ // contributions the workspace opens into `vgai:area:<id>`
1643
+ // (`workspace-areas.ts`) and the drawer keeps only the utilities that
1644
+ // are not Blender areas.
1645
+ return { ok: true, data: { workspace: id, applied } };
1646
+ }
1647
+ case 'set-style': {
1648
+ const id = cmd['style'];
1649
+ const ids = workspaceStyles().map((bundle) => bundle.id);
1650
+ if (typeof id !== 'string' || !ids.includes(id)) {
1651
+ return {
1652
+ ok: false,
1653
+ error: `set-style requires one of ${ids.join(', ')}, got ${String(id)}.`,
1654
+ };
1655
+ }
1656
+ applyWorkspaceStyle(id);
1657
+ // Reported from the axes, not echoed: a bundle applies through the
1658
+ // settings layer that DECLARES each axis (`updatePreferenceSettings`),
1659
+ // so what the chrome wears is what this answers.
1660
+ const applied = activeWorkspaceStyleId();
1661
+ if (applied !== id) {
1662
+ return {
1663
+ ok: false,
1664
+ error: `set-style applied "${id}" but the editor is wearing ${applied === null ? 'a custom mix of axes' : `"${applied}"`}.`,
1665
+ data: { style: applied },
1666
+ };
1667
+ }
1668
+ return { ok: true, data: { style: id } };
1669
+ }
1670
+ case 'set-appearance': {
1671
+ // The MATERIAL apart from the bundle that usually carries it.
1672
+ // Appearance is palette × material by ruling (ARCHITECTURE-CORE
1673
+ // §Editor chrome), so nothing through the session could otherwise ask
1674
+ // "is it the blur or the palette?" about a stall a style switch
1675
+ // produces. This is the door that measures the axes apart; it applies
1676
+ // through the same preference writer the menu uses, so what the chrome
1677
+ // wears afterwards is what it answers.
1678
+ const material = cmd['material'];
1679
+ if (material === undefined) {
1680
+ return { ok: false, error: 'set-appearance needs a `material` to set.' };
1681
+ }
1682
+ if (!isEditorMaterialId(material)) {
1683
+ const ids = editorMaterials().map((choice) => choice.id);
1684
+ return {
1685
+ ok: false,
1686
+ error: `set-appearance material must be one of ${ids.join(', ')}, got ${String(material)}.`,
1687
+ };
1688
+ }
1689
+ setEditorMaterialPreference(material);
1690
+ return {
1691
+ ok: true,
1692
+ data: { material: editorMaterialSnapshot(), style: activeWorkspaceStyleId() },
1693
+ };
1694
+ }
1695
+ case 'present-view': {
1696
+ try {
1697
+ const presented = await presentEditorView(store, cmd['view'] as EditorView);
1698
+ return { ok: true, data: { ...presented } };
1699
+ } catch (error) {
1700
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1701
+ }
1702
+ }
1703
+ case 'current-view':
1704
+ return { ok: true, data: { view: currentEditorView(store) } };
1705
+ case 'inspect': {
1706
+ // `editor.inspect` — the SERIALIZED projection of the inspection model
1707
+ // (`inspection/serialize.ts`), composed from the same live state, by the
1708
+ // same composer, as the column and the compact card
1709
+ // (`inspection/active-subject.ts`). The `InspectedInspection` annotation
1710
+ // is the drift check: the SDK's wire mirror and the editor's own
1711
+ // serialized shape are structurally compared by `tsc` on every build —
1712
+ // including its `{none:true}` arm, which is what a human seeing no
1713
+ // inspector at all serializes to.
1714
+ const subject: InspectedInspection = inspectActiveSubject(store);
1715
+ return { ok: true, data: { subject } };
1716
+ }
1717
+ case 'run-inspection-action': {
1718
+ try {
1719
+ const actionId = cmd['actionId'];
1720
+ if (typeof actionId !== 'string' || actionId.trim() === '') {
1721
+ throw new Error('run-inspection-action requires a non-empty actionId.');
1722
+ }
1723
+ const subject: InspectedInspection = await runActiveInspectionAction(store, actionId);
1724
+ return { ok: true, data: { subject } };
1725
+ } catch (error) {
1726
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1727
+ }
1728
+ }
1729
+ case 'run-command': {
1730
+ // `editor.command(id, args)` — the ONE door to a command by id (U8's
1731
+ // ruling 1: "so `vgai eval` reaches it through the frame's command
1732
+ // service"). Under the frame that IS `ICommandService`; standalone it is
1733
+ // the views registry, and `editor-commands.ts` owns both arms plus
1734
+ // the refusal that names the id shape that would have worked.
1735
+ try {
1736
+ const commandId = cmd['commandId'];
1737
+ if (typeof commandId !== 'string' || commandId.trim() === '') {
1738
+ throw new Error('run-command requires a non-empty commandId.');
1739
+ }
1740
+ const result = await executeCommand(commandId, cmd['args']);
1741
+ // The command's own answer, as far as it survives the wire: a view
1742
+ // verb answers with its state, a workbench command usually with
1743
+ // nothing. `undefined` is not JSON, so it is reported as null rather
1744
+ // than dropping the key and making "ran, said nothing" look like a
1745
+ // malformed reply.
1746
+ return { ok: true, data: { result: result === undefined ? null : result } };
1747
+ } catch (error) {
1748
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1749
+ }
1750
+ }
1751
+ case 'hierarchy': {
1752
+ // `editor.hierarchy` — the hierarchy panel's OWN rendered row tree,
1753
+ // serialized. Not a fresh walk of the adapter: the panel publishes the
1754
+ // rows and predicates it rendered with and this serializes those, so the
1755
+ // door cannot report a tree the human is not looking at
1756
+ // (`hierarchy-panel-view.ts` header). `editor.status().entities` answers a
1757
+ // deliberately DIFFERENT question — the raw adapter tree, unprojected.
1758
+ const snapshot = hierarchyPanelSnapshot();
1759
+ if (snapshot === null) {
1760
+ return {
1761
+ ok: false,
1762
+ error:
1763
+ '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.',
1764
+ };
1765
+ }
1766
+ const hierarchy: InspectedHierarchy = serializeHierarchyPanel(snapshot, {
1767
+ playState: store.playState,
1768
+ activeViewportTab: store.activeViewportTab,
1769
+ });
1770
+ return { ok: true, data: { hierarchy } };
1771
+ }
1772
+ // EXPAND/COLLAPSE ALL, and they ship as a pair for a reason. The machine
1773
+ // door uses the panel's own mutations. Reading the raw adapter hierarchy
1774
+ // here would fabricate rows the panel has not rendered and would make
1775
+ // collapsed-branch certification meaningless; and expanding without a way
1776
+ // back is a ONE-WAY door — the fold is written to this project's persisted
1777
+ // preference, and a chevron's own click is not drivable through the
1778
+ // control API (`editor.document.click` refuses editor chrome by name), so
1779
+ // for as long as `expand-hierarchy-all` stood alone, no reader that used
1780
+ // it could ever see the tree's REST STATE again. Both call the toolbar's
1781
+ // own actions, so the human and machine paths cannot diverge. The ack
1782
+ // resolves on the panel's NEXT PUBLISHED SNAPSHOT, so a `hierarchy()` in
1783
+ // the same breath reads the mutated tree rather than the one before it.
1784
+ case 'expand-hierarchy-all':
1785
+ case 'collapse-hierarchy-all': {
1786
+ const expanding = cmd['type'] === 'expand-hierarchy-all';
1787
+ const snapshot = hierarchyPanelSnapshot();
1788
+ if (snapshot === null) {
1789
+ return {
1790
+ ok: false,
1791
+ 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.`,
1792
+ };
1793
+ }
1794
+ const committed = nextHierarchyPanelSnapshot();
1795
+ if (expanding) snapshot.expandAll();
1796
+ else snapshot.collapseAll();
1797
+ try {
1798
+ await committed;
1799
+ } catch (error) {
1800
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1801
+ }
1802
+ return { ok: true, data: {} };
1803
+ }
1804
+ // UNDO/REDO over the control API. The keyboard shortcut and the command
1805
+ // palette have always had this; an agent authoring through the editor did
1806
+ // not — and for an ingest root, whose only authoring surface IS the editor,
1807
+ // that left an edit with no way back. Same queue, same guards, same
1808
+ // per-transaction semantics as the key press.
1809
+ case 'undo':
1810
+ case 'redo': {
1811
+ const history = options?.history;
1812
+ if (!history) {
1813
+ return {
1814
+ ok: false,
1815
+ error: `${String(cmd['type'])}: this editor has no history session attached, so there is nothing to undo.`,
1816
+ };
1817
+ }
1818
+ const moved = cmd['type'] === 'undo' ? await history.undo() : await history.redo();
1819
+ const snapshot = history.getSnapshot();
1820
+ return {
1821
+ ok: true,
1822
+ data: {
1823
+ moved,
1824
+ canUndo: snapshot.canUndo,
1825
+ canRedo: snapshot.canRedo,
1826
+ undoLabel: snapshot.undoLabel,
1827
+ redoLabel: snapshot.redoLabel,
1828
+ },
1829
+ };
1830
+ }
1831
+ case 'set-inspection-field': {
1832
+ try {
1833
+ const path = cmd['path'];
1834
+ if (typeof path !== 'string' || path.trim() === '') {
1835
+ throw new Error('set-inspection-field requires a non-empty field path.');
1836
+ }
1837
+ // THE ACK NAMES ITS DESTINATION. A write with no persistence route
1838
+ // still ACKs `ok` — it lands on the live object — so without `write`
1839
+ // the caller cannot tell a persisted edit from a vanished one, and
1840
+ // `scripts/doctor-walk.ts` graded a healthy consent-off session as a
1841
+ // silent no-op on exactly that ambiguity.
1842
+ const written: { subject: InspectedInspection; write: InspectedWriteDestination } =
1843
+ await setActiveInspectionField(store, path, cmd['value']);
1844
+ return { ok: true, data: { subject: written.subject, write: written.write } };
1845
+ } catch (error) {
1846
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1847
+ }
1848
+ }
1849
+ // THE OTHER HALF OF THE WRITE DOOR, and the only one that can express
1850
+ // byte-ABSENCE. `set-inspection-field` writes a VALUE, so reverting a prop
1851
+ // an authoring gesture APPENDED leaves an explicit `[0, 0, 0]` where the
1852
+ // source carried nothing — the file ends one attribute heavier than it
1853
+ // started and no byte-level round trip can close. The human revert arrow
1854
+ // has reached `io.remove` since it shipped; the control API had no door at
1855
+ // all, which made every agent-driven edit/revert unverifiable at byte
1856
+ // level on lanes that were in fact healthy.
1857
+ //
1858
+ // Same io, same persistence pipe, same awaited per-edit ack as `set`.
1859
+ case 'remove-inspection-field': {
1860
+ try {
1861
+ const path = cmd['path'];
1862
+ if (typeof path !== 'string' || path.trim() === '') {
1863
+ throw new Error('remove-inspection-field requires a non-empty field path.');
1864
+ }
1865
+ const removed: { subject: InspectedInspection; write: InspectedWriteDestination } =
1866
+ await removeActiveInspectionField(store, path);
1867
+ return { ok: true, data: { subject: removed.subject, write: removed.write } };
1868
+ } catch (error) {
1869
+ // A LANE WITH NO REMOVAL DOOR IS NOT A FAILED REMOVAL, and the caller
1870
+ // has to be able to tell them apart without matching prose: an
1871
+ // instrument grades the first UNVERIFIABLE (an unreached seam) and the
1872
+ // second FAILED (a removal that ran and left the bytes changed).
1873
+ if (error instanceof InspectionRemovalUnavailableError) {
1874
+ return { ok: false, error: error.message, data: { code: 'REMOVAL_UNAVAILABLE' } };
1875
+ }
1876
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
1877
+ }
1878
+ }
1879
+ // THE STRUCTURE OPS over the control API — the same operations the
1880
+ // hierarchy row's context menu performs, on the same
1881
+ // `authoring/consumer-actions.ts` helpers, so there is one implementation
1882
+ // and not a second that can disagree with the menu.
1883
+ //
1884
+ // A COMMAND VERB rather than inspector `quickActions` deliberately:
1885
+ // `inspect().quickActions` reports the verbs a human sees on the
1886
+ // inspector's identity row, and a dozen structure icons there would be
1887
+ // either a UI redesign or a list of actions nobody can see — both worse
1888
+ // than transcribing what the component verbs already established for
1889
+ // exactly this gap (`@vgai/game/contributions/component-verbs.command.ts`,
1890
+ // which is where `extract-component`/`fork-component` live now).
1891
+ //
1892
+ // `id`/`ids` default to the current selection, the menu's own subject. An
1893
+ // op the active adapter does not provide answers `ok: false` naming it,
1894
+ // never a silent no-op.
1895
+ case 'structure-op': {
1896
+ try {
1897
+ const op = String(cmd['op'] ?? '');
1898
+ // THE ACTIVE DOCUMENT'S ADAPTER AND ITS SELECTION, which is the pair
1899
+ // every other consumer of this seam already reads (`editor-hotkeys.ts`'s
1900
+ // `actionSelectionIds`, the hierarchy panel, `editor.status()`). This
1901
+ // case read `getActiveAuthoring` + the SHELL store instead, so on a
1902
+ // document whose adapter owns its own selection — the Blender Model
1903
+ // document, whose Outliner publishes Blender row ids — it refused with
1904
+ // "the active authoring adapter exposes no structure provider" while
1905
+ // the document's own adapter had one and the panel was drawing its
1906
+ // selection (measured 2026-09-21: `editor.structure('duplicate')` on a
1907
+ // selected Torus, B6's last named leftover).
1908
+ const adapter = resolvePanelAuthoring(store).adapter;
1909
+ const selected = activeSelectionIds(store);
1910
+ const ids = Array.isArray(cmd['ids'])
1911
+ ? (cmd['ids'] as unknown[]).filter((v): v is string => typeof v === 'string')
1912
+ : typeof cmd['id'] === 'string' && cmd['id'].trim() !== ''
1913
+ ? [cmd['id']]
1914
+ : selected;
1915
+ const first = ids[0];
1916
+ const needsId = (): string => {
1917
+ if (!first) throw new Error(`structure-op "${op}" needs an id or a selected row.`);
1918
+ return first;
1919
+ };
1920
+ const optional = (key: string): string | undefined =>
1921
+ typeof cmd[key] === 'string' && (cmd[key] as string).trim() !== ''
1922
+ ? (cmd[key] as string)
1923
+ : undefined;
1924
+ const structure = adapter.structure;
1925
+ if (!structure) {
1926
+ return {
1927
+ ok: false,
1928
+ error: 'structure-op: the active authoring adapter exposes no structure provider.',
1929
+ };
1930
+ }
1931
+ switch (op) {
1932
+ case 'create': {
1933
+ const kind = optional('kind');
1934
+ if (!kind) return { ok: false, error: 'structure-op "create" needs a `kind`.' };
1935
+ const created = createAuthoringNode(adapter, kind, optional('parentId'));
1936
+ return { ok: true, data: { id: created.id, write: await created.ack } };
1937
+ }
1938
+ case 'delete': {
1939
+ const write =
1940
+ ids.length > 1 && structure.removeMany
1941
+ ? await removeManyAuthoringNodes(adapter, ids)
1942
+ : await removeAuthoringNode(adapter, needsId());
1943
+ return { ok: true, data: { write } };
1944
+ }
1945
+ case 'duplicate': {
1946
+ const copy = duplicateAuthoringNode(adapter, needsId());
1947
+ return { ok: true, data: { id: copy.id, write: await copy.ack } };
1948
+ }
1949
+ case 'reparent': {
1950
+ const parentId = optional('parentId') ?? null;
1951
+ return {
1952
+ ok: true,
1953
+ data: { write: await reparentAuthoringNode(adapter, needsId(), parentId) },
1954
+ };
1955
+ }
1956
+ case 'reorder': {
1957
+ if (!structure.reorder) {
1958
+ return { ok: false, error: 'structure-op: this adapter has no `reorder`.' };
1959
+ }
1960
+ const before = optional('beforeSiblingId') ?? null;
1961
+ return {
1962
+ ok: true,
1963
+ data: { write: await reorderAuthoringNode(adapter, needsId(), before) },
1964
+ };
1965
+ }
1966
+ case 'wrap': {
1967
+ if (!structure.wrap) {
1968
+ return { ok: false, error: 'structure-op: this adapter has no `wrap`.' };
1969
+ }
1970
+ return {
1971
+ ok: true,
1972
+ data: { write: await wrapAuthoringNode(adapter, needsId(), optional('tag')) },
1973
+ };
1974
+ }
1975
+ case 'unwrap': {
1976
+ if (!structure.unwrap) {
1977
+ return { ok: false, error: 'structure-op: this adapter has no `unwrap`.' };
1978
+ }
1979
+ return { ok: true, data: { write: await unwrapAuthoringNode(adapter, needsId()) } };
1980
+ }
1981
+ case 'group': {
1982
+ if (!structure.group) {
1983
+ return { ok: false, error: 'structure-op: this adapter has no `group`.' };
1984
+ }
1985
+ const grouped = groupAuthoringNodes(adapter, ids);
1986
+ return { ok: true, data: { id: grouped.id, write: await grouped.ack } };
1987
+ }
1988
+ case 'ungroup': {
1989
+ if (!structure.ungroup) {
1990
+ return { ok: false, error: 'structure-op: this adapter has no `ungroup`.' };
1991
+ }
1992
+ const ungrouped = ungroupAuthoringNode(adapter, needsId());
1993
+ return { ok: true, data: { ids: ungrouped.ids, write: await ungrouped.ack } };
1994
+ }
1995
+ case 'copy': {
1996
+ if (!structure.copy) {
1997
+ return { ok: false, error: 'structure-op: this adapter has no `copy`.' };
1998
+ }
1999
+ return { ok: true, data: { copied: await copyAuthoringNodes(adapter, ids) } };
2000
+ }
2001
+ case 'cut': {
2002
+ if (!structure.cut) {
2003
+ return { ok: false, error: 'structure-op: this adapter has no `cut`.' };
2004
+ }
2005
+ const outcome = await cutAuthoringNodes(adapter, ids);
2006
+ return outcome === false
2007
+ ? { ok: false, error: 'structure-op "cut" was refused by the adapter.' }
2008
+ : { ok: true, data: { write: outcome } };
2009
+ }
2010
+ case 'paste': {
2011
+ if (!structure.paste) {
2012
+ return { ok: false, error: 'structure-op: this adapter has no `paste`.' };
2013
+ }
2014
+ const parentId =
2015
+ optional('parentId') ??
2016
+ (first ? (adapter.hierarchy.node(first)?.parentId ?? null) : null);
2017
+ const outcome = await pasteAuthoringNodes(adapter, parentId);
2018
+ return outcome === false
2019
+ ? { ok: false, error: 'structure-op "paste" was refused by the adapter.' }
2020
+ : { ok: true, data: { write: outcome } };
2021
+ }
2022
+ default:
2023
+ return {
2024
+ ok: false,
2025
+ error:
2026
+ `structure-op: unknown op ${JSON.stringify(op)}. Known ops: create, delete, ` +
2027
+ 'duplicate, reparent, reorder, wrap, unwrap, group, ungroup, copy, cut, paste.',
2028
+ };
2029
+ }
2030
+ } catch (error) {
2031
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
2032
+ }
2033
+ }
2034
+ case 'document-probe': {
2035
+ // `editor.document.*` — the scoped editor-chrome door. Every refusal
2036
+ // (no active document, unmounted surface, target outside the scope)
2037
+ // comes back as the step's own honest error text, because the scope
2038
+ // NAME is the useful half of the answer
2039
+ // (`editor-document-probe.ts`'s header).
2040
+ try {
2041
+ const result = await runDocumentProbe(cmd['step'] as DocumentProbeStep);
2042
+ return { ok: true, data: { ...result } };
2043
+ } catch (error) {
2044
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
2045
+ }
2046
+ }
2047
+ case 'capture-active-document': {
2048
+ const requested = captureSizeFromCommand(cmd);
2049
+ if ('error' in requested) return { ok: false, error: requested.error };
2050
+ try {
2051
+ if (cmd['view']) await presentEditorView(store, cmd['view'] as EditorView);
2052
+ const capture = await captureActiveEditorDocument(store, requested.size);
2053
+ return { ok: true, data: { ...capture } };
2054
+ } catch (error) {
2055
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
2056
+ }
2057
+ }
2058
+ case 'capture-editor-chrome': {
2059
+ // The editor PAGE itself — see `editor-chrome-capture.ts`. The page is
2060
+ // photographed at its own LAYOUT; `scale` chooses only how many output
2061
+ // pixels one CSS pixel becomes, defaulting to the display's own ratio.
2062
+ // A stroke weight compared against a 2x reference needs `scale: 2`.
2063
+ const scale = cmd['scale'];
2064
+ if (scale !== undefined && (typeof scale !== 'number' || !(scale > 0) || scale > 4)) {
2065
+ return {
2066
+ ok: false,
2067
+ error:
2068
+ 'capture-editor-chrome: "scale" must be a number greater than 0 and no more than 4 ' +
2069
+ '(output pixels per CSS pixel; the frame crosses the relay as base64 PNG). ' +
2070
+ "It defaults to the page's own devicePixelRatio.",
2071
+ };
2072
+ }
2073
+ try {
2074
+ const capture = await captureEditorChrome(
2075
+ store,
2076
+ scale === undefined ? undefined : { scale },
2077
+ );
2078
+ return { ok: true, data: { ...capture } };
2079
+ } catch (error) {
2080
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
2081
+ }
2082
+ }
2083
+ case 'capture-viewport': {
2084
+ // Fresh, unthrottled on-demand capture (see EditorShellStore.captureViewportImage's
2085
+ // doc comment) — distinct from the periodic autosave thumbnail the
2086
+ // project-thumbnail snapshot path serves. `size` is optional; defaults
2087
+ // to the store's standard thumbnail size.
2088
+ const requested = captureSizeFromCommand(cmd);
2089
+ if ('error' in requested) return { ok: false, error: requested.error };
2090
+ const dataUrl = store.captureViewportImage(requested.size);
2091
+ if (!dataUrl) {
2092
+ // The refusal names the MECHANISM and the door that does answer.
2093
+ // "Viewport is not bound yet" alone was true and useless over a live
2094
+ // canvas or ingest session: this door photographs the editor's own
2095
+ // THREE viewport, which a canvas-surface world never binds — so the
2096
+ // reader waited for a binding that was never coming instead of
2097
+ // reaching for the capture that was already available.
2098
+ return {
2099
+ ok: false,
2100
+ error:
2101
+ "This door photographs the editor's own three.js Scene viewport, and nothing has bound one " +
2102
+ '(no renderer/scene/camera). A canvas-surface world (a first-party canvas root, or a ' +
2103
+ 'PixiJS/Phaser/Babylon ingest) never binds it — it draws on its own canvas in the Game ' +
2104
+ 'document. Capture that through `capture-active-document` (`editor.captureActiveDocument()`) ' +
2105
+ 'or the running game through `bridge-screenshot` (`vgai screenshot`).',
2106
+ };
2107
+ }
2108
+ const comma = dataUrl.indexOf(',');
2109
+ const base64 = comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl;
2110
+ return { ok: true, data: { base64, mimeType: 'image/png' } };
2111
+ }
2112
+ case 'capture-asset-preview':
2113
+ return handleAssetPreviewCommand(store, cmd);
2114
+ /**
2115
+ * OPEN one piece of the adapter's scene table by id — the table's ONE
2116
+ * `open` verb (ARCHITECTURE-CORE §Editor). One verb for a scene, a prefab,
2117
+ * or a story state, because the table makes them siblings: they differ only
2118
+ * in instance site.
2119
+ *
2120
+ * TWO CLIENTS, one verb. With a game LIVE in this session the live half
2121
+ * answers first (`scene-live-open.ts`): the running game IS the surface, so
2122
+ * a `game-contract` scene is opened by asking the game to navigate and a
2123
+ * `root-mount` scene by showing the document it draws into. It answers
2124
+ * `null` for the reaches that are the Edit workspace's
2125
+ * (`components/scene-documents.tsx`), which then runs unchanged.
2126
+ *
2127
+ * Both halves answer from the SAME declared field — the live half switches
2128
+ * on `entry.reach.kind`, and the Edit half's `liveReach` is derived from
2129
+ * `entry.reach` alone (`scene-document-plan.ts`'s `liveReachOf`).
2130
+ * `authorable` decides the Edit DOCUMENT and nothing else — it is not asked
2131
+ * here, because whether a running game can be sent to one of its own screens
2132
+ * is the game's contract to answer, not the host's opinion of whether that
2133
+ * screen is authorable.
2134
+ *
2135
+ * The two halves agree about every reach the live half REACHES:
2136
+ * `liveReach` is `'game-contract' | 'root-mount' | 'entrypoint-selection'`,
2137
+ * exactly the set that ends `ok: true` there when the session can honour
2138
+ * it, so with nothing running both refuse as
2139
+ * `SCENE_NAVIGATION_NOT_RUNNING` rather than as a dead end.
2140
+ * A session with no remount seam still refuses `entrypoint-selection`
2141
+ * as `SCENE_NOT_OPENABLE_LIVE` — that is a session fact, not a reach
2142
+ * fact.
2143
+ *
2144
+ * Every refusal is CODED, because the classes are graded differently and
2145
+ * prose cannot separate them: `SCENE_NOT_FOUND` names the ids that DO
2146
+ * exist, `SCENE_NOT_OPENABLE` quotes the adapter's own declared reason
2147
+ * verbatim rather than paraphrasing a claim about someone else's game, and
2148
+ * `SCENE_NAVIGATION_NOT_RUNNING` says the scene is live-only rather than
2149
+ * unreachable.
2150
+ */
2151
+ // The document table the host resolved (ARCHITECTURE-CORE §The project
2152
+ // model, "Documents, not scenes"), as the wire projection `getState`
2153
+ // already carries. A COMMAND rather than a state read so a standing
2154
+ // document that lists the table (a production's Shots bin) is driven the
2155
+ // same way every other surface is.
2156
+ case 'document-table': {
2157
+ const facet = projectAdapterFacet();
2158
+ if (!facet) {
2159
+ return {
2160
+ ok: false,
2161
+ error: 'The project adapter has not resolved yet — no document table to list.',
2162
+ data: { code: 'SCENE_TABLE_UNAVAILABLE' },
2163
+ };
2164
+ }
2165
+ return {
2166
+ ok: true,
2167
+ data: {
2168
+ default: facet.scenes.default ?? null,
2169
+ entries: facet.scenes.entries,
2170
+ pending: facet.documentsPending === true,
2171
+ },
2172
+ };
2173
+ }
2174
+
2175
+ case 'open': {
2176
+ const id = cmd['id'];
2177
+ if (typeof id !== 'string' || id.trim() === '') {
2178
+ return { ok: false, error: 'open requires a non-empty scene-table entry id.' };
2179
+ }
2180
+ // Play's `open()` is live navigation. Edit's `open()` is the tab row —
2181
+ // an ingest mount is not Play (ARCHITECTURE-CORE: every authorable scene
2182
+ // is an Edit document). Routing ingest through the live half is how
2183
+ // opening GameScreen put the Play document up.
2184
+ // A deferred ingest run is a real Play SESSION even though it does not
2185
+ // allocate `play-mode.ts`'s first-party session object. Use the shared
2186
+ // mode predicate so opening an adapter scene reaches that running game's
2187
+ // contract instead of silently falling back to its Edit document.
2188
+ const liveGame = editorIsPlaying();
2189
+ const entry = liveGame
2190
+ ? projectAdapterFacet()?.scenes.entries.find((candidate) => candidate.id === id)
2191
+ : undefined;
2192
+ // The running lane's own remount (Play), asked of the live
2193
+ // registry so this verb names no lane.
2194
+ const remountSelection = liveGame
2195
+ ? async (args: { selection: string; key: string; regionId: string }) =>
2196
+ (await remountLiveSelection(args)) ?? {
2197
+ ok: false as const,
2198
+ error: 'No running lane can remount with a selection.',
2199
+ }
2200
+ : undefined;
2201
+ const live = entry
2202
+ ? await openLiveSceneEntry(entry, {
2203
+ scenes: liveScenes,
2204
+ activateGameDocument: () => activateWorkspaceDocument(GAME_DOCUMENT_ID),
2205
+ gameDocumentId: GAME_DOCUMENT_ID,
2206
+ ...(remountSelection ? { remountSelection } : {}),
2207
+ })
2208
+ : null;
2209
+ const opened = live ?? (await openSceneTableEntryWhenListed(store, id));
2210
+ return opened.ok
2211
+ ? {
2212
+ ok: true,
2213
+ data: {
2214
+ documentId: opened.documentId,
2215
+ title: opened.title,
2216
+ ...('scene' in opened && opened.scene ? { scene: opened.scene } : {}),
2217
+ ...('restart' in opened && opened.restart ? { restart: true } : {}),
2218
+ },
2219
+ }
2220
+ : {
2221
+ ok: false,
2222
+ error: opened.error,
2223
+ data: {
2224
+ code: opened.code,
2225
+ ...(opened.known === undefined ? {} : { known: [...opened.known] }),
2226
+ },
2227
+ };
2228
+ }
2229
+
2230
+ // Display — set semantics (only toggle when value differs)
2231
+ case 'set-grid': {
2232
+ const documentSession = activeObject3DDocumentSession();
2233
+ if (documentSession) documentSession.setGrid(cmd['enabled'] as boolean);
2234
+ else if (store.showGrid !== (cmd['enabled'] as boolean)) store.toggleGrid();
2235
+ break;
2236
+ }
2237
+ // Helpers and the stats tile are per-STAGE view options, read by the
2238
+ // focused stage's own viewport and its overlay set (ARCHITECTURE-CORE
2239
+ // §One stage unit 4) — the same reason `set-grid` above already asks the
2240
+ // active document's session. A verb that wrote the shell's copy while the
2241
+ // person was looking at a model document would toggle nothing they can see.
2242
+ case 'set-helpers': {
2243
+ const stage = focusedStageStore(store);
2244
+ if (stage.showHelpers !== (cmd['enabled'] as boolean)) stage.toggleHelpers();
2245
+ break;
2246
+ }
2247
+ case 'set-stats': {
2248
+ const stage = focusedStageStore(store);
2249
+ if (stage.showStats !== (cmd['enabled'] as boolean)) stage.toggleStats();
2250
+ break;
2251
+ }
2252
+ case 'set-shading-mode':
2253
+ if (activeObject3DDocumentSession()) {
2254
+ activeObject3DDocumentSession()?.setMode(cmd['mode'] as ViewportShadingMode);
2255
+ } else {
2256
+ store.setShadingMode(cmd['mode'] as ViewportShadingMode);
2257
+ }
2258
+ break;
2259
+ case 'set-helper-type': {
2260
+ const stage = focusedStageStore(store);
2261
+ const helperType = cmd['helperType'] as keyof HelperVisibility;
2262
+ const documentSession = activeObject3DDocumentSession();
2263
+ if (documentSession && helperType === 'bounds') {
2264
+ documentSession.setBounds(cmd['enabled'] as boolean);
2265
+ break;
2266
+ }
2267
+ if (documentSession && helperType === 'skeletons') {
2268
+ documentSession.setSkeleton(cmd['enabled'] as boolean);
2269
+ break;
2270
+ }
2271
+ if (stage.helperVisibility[helperType] !== (cmd['enabled'] as boolean)) {
2272
+ stage.toggleHelperType(helperType);
2273
+ }
2274
+ break;
2275
+ }
2276
+
2277
+ // Transform tools — set semantics
2278
+ case 'set-transform-mode':
2279
+ store.setTransformMode(cmd['mode'] as 'combined' | 'translate' | 'rotate' | 'scale');
2280
+ break;
2281
+ case 'set-transform-space':
2282
+ store.setTransformSpace(cmd['space'] as 'world' | 'local');
2283
+ break;
2284
+ case 'set-snap':
2285
+ if (store.snapEnabled !== (cmd['enabled'] as boolean)) store.toggleSnap();
2286
+ break;
2287
+
2288
+ // The REPL door over the ACTIVE document's published context — Edit mode,
2289
+ // no play gate (`document-context-registry.ts`).
2290
+ case 'document-script':
2291
+ return handleDocumentScript(cmd);
2292
+
2293
+ // Version-skew honesty (the false-ack fix):
2294
+ // every unrecognized command type used to fall through to `return {ok:
2295
+ // true}` below — a silent lie: the browser did NOTHING, but the SDK/CLI
2296
+ // caller was told it succeeded. Reporting a structured failure must hold
2297
+ // for ANY unrecognized `type`, not just the ones known about today. The
2298
+ // `data.code` marker is what the SDK maps to its `*_UNSUPPORTED` codes;
2299
+ // the prose is for humans only.
2300
+ default: {
2301
+ // Unreachable: `isRelayCommandType` above already answered for anything
2302
+ // outside the table. This assignment is the EXHAUSTIVENESS CHECK — it
2303
+ // compiles only while every row of `RELAY_COMMANDS` has a case here.
2304
+ const unhandled: never = commandType;
2305
+ return {
2306
+ ok: false,
2307
+ error: `unknown command type "${String(unhandled)}" — editor page predates this CLI`,
2308
+ data: { code: 'UNKNOWN_COMMAND_TYPE' },
2309
+ };
2310
+ }
2311
+ }
2312
+
2313
+ return { ok: true };
2314
+ }
2315
+
2316
+ /**
2317
+ * Connect the command listener to the editor server's SSE stream.
2318
+ * Dispatches incoming commands to the store and play-mode functions.
2319
+ * Reports state after each command and on initial connect.
2320
+ */
2321
+ export function connectCommandListener(
2322
+ store: EditorShellStore,
2323
+ /**
2324
+ * The session's own undo/redo queue — the SAME object the keyboard shortcut
2325
+ * and the command palette drive, so a relayed undo is the user's undo and not
2326
+ * a second path into history. Omitted by the isolated component tests that
2327
+ * only need the listener's transport half; the verbs then refuse by name
2328
+ * rather than reaching for `store.projectHistory` behind the queue's back.
2329
+ */
2330
+ history?: HistoryCommands,
2331
+ ): () => void {
2332
+ // Browser mode (Phase A2): the `vgai` CLI control channel is server-only (it
2333
+ // is an SSE command stream + state POSTs to `/__editor/*`, which do not exist
2334
+ // without a Node server). Skip it — otherwise the EventSource retry-loops
2335
+ // against a 404 and every `reportEditorState` POSTs into the void.
2336
+ const source = connectEvents();
2337
+
2338
+ // The last FULL snapshot this page sent. Every report on a user's critical
2339
+ // path reuses its derived facets rather than re-deriving them — see
2340
+ // `state-report-deferral.ts` for the measurement that bought this.
2341
+ let lastFullState: Record<string, unknown> | null = null;
2342
+ /**
2343
+ * The store's `contentVersion` at the moment `lastFullState` was collected.
2344
+ *
2345
+ * The facets `collectState` is allowed to REUSE are the tree-scale ones — the
2346
+ * hierarchy rows and the capability grading. When the store notifies with
2347
+ * nothing but a selection change, `contentVersion` holds still, and every
2348
+ * reused facet in `lastFullState` is therefore already current: scheduling a
2349
+ * full re-derivation to "make it current again" re-derives an answer it
2350
+ * already has. Measured at N=20000 on the canvas lane as a 54-70ms
2351
+ * `IdleRequestCallback @ command-listener.ts` block after EVERY click.
2352
+ */
2353
+ let lastFullContentVersion = -1;
2354
+ // One entry per read-only debug proof in the CURRENT adapter epoch. The epoch is in the key, so
2355
+ // remounts naturally pay for (and publish) their own first proof while repeated clock/snapshot
2356
+ // polls reuse the full report that already contains the identical verdict.
2357
+ const reportedDebugReadProofs = new Set<string>();
2358
+ /**
2359
+ * A Play scene transition is a burst, not one store notification. The
2360
+ * translated FPS measured 4,523 hierarchy objects and several asynchronous
2361
+ * React commits between `unity.load_scene.MainScene` returning and the tree
2362
+ * becoming quiescent. Re-deriving on every notification caused 325-430ms
2363
+ * main-thread blocks and eventually starved the command relay; deriving on
2364
+ * the command's first notification left the cached hierarchy at IntroMenu.
2365
+ *
2366
+ * Arm exactly one refresh for the burst and move it behind a short quiet
2367
+ * window. Once it lands, ordinary runtime structure, input, state polling,
2368
+ * presence and store reports cannot arm another one. A later explicit game
2369
+ * command/read or Play boundary can arm the next transition honestly.
2370
+ */
2371
+ let playFullReportArmed = false;
2372
+ let playSettleTimer: ReturnType<typeof setTimeout> | null = null;
2373
+ let playCommandContentVersion: number | null = null;
2374
+ let playCommandContentTimer: ReturnType<typeof setTimeout> | null = null;
2375
+ // A tombstone stops POSTing snapshots too. `vgai status` reads the server's
2376
+ // last snapshot, so a corpse that kept reporting would keep MINTING
2377
+ // fresh-looking state for a session that no longer exists — the exact
2378
+ // impersonation the tombstone latch exists to end.
2379
+ const reportLatestState = () => {
2380
+ if (sessionEndedState() !== null) return;
2381
+ lastFullState = collectState(store);
2382
+ lastFullContentVersion = store.contentVersion;
2383
+ void reportEditorState(lastFullState);
2384
+ };
2385
+ let cancelDeferredFullReport: (() => void) | null = null;
2386
+ const cancelPlaySettledFullReport = () => {
2387
+ if (playSettleTimer !== null) {
2388
+ clearTimeout(playSettleTimer);
2389
+ playSettleTimer = null;
2390
+ }
2391
+ cancelDeferredFullReport?.();
2392
+ cancelDeferredFullReport = null;
2393
+ };
2394
+ const clearPlayCommandContentCandidate = () => {
2395
+ playCommandContentVersion = null;
2396
+ if (playCommandContentTimer !== null) clearTimeout(playCommandContentTimer);
2397
+ playCommandContentTimer = null;
2398
+ };
2399
+ const schedulePlaySettledFullReport = () => {
2400
+ clearPlayCommandContentCandidate();
2401
+ playFullReportArmed = true;
2402
+ cancelPlaySettledFullReport();
2403
+ playSettleTimer = setTimeout(() => {
2404
+ playSettleTimer = null;
2405
+ cancelDeferredFullReport = scheduleDeferredFullReport(() => {
2406
+ cancelDeferredFullReport = null;
2407
+ playFullReportArmed = false;
2408
+ reportLatestState();
2409
+ });
2410
+ }, 250);
2411
+ };
2412
+ const armPlayCommandContentCandidate = (baseline: number) => {
2413
+ if (store.contentVersion !== baseline) {
2414
+ schedulePlaySettledFullReport();
2415
+ return;
2416
+ }
2417
+ clearPlayCommandContentCandidate();
2418
+ playCommandContentVersion = baseline;
2419
+ playCommandContentTimer = setTimeout(clearPlayCommandContentCandidate, 1_000);
2420
+ };
2421
+ const editorIsActivelyPlaying = () => editorIsPlaying() && store.playState === 'playing';
2422
+ const settleUnscopedPlayCommandRefresh = (commandStartContentVersion: number | null) => {
2423
+ if (commandStartContentVersion !== null) {
2424
+ // A successful relayed command whose declared policy still owes a
2425
+ // derived refresh must not disappear merely because it does not carry
2426
+ // one of the special debug-invoke transition scopes above. Read commands
2427
+ // reach here only when contentVersion moved; `always` mutation/boundary
2428
+ // commands reach here by declaration. Ordinary store notifications have
2429
+ // no command baseline and remain cheap during Play.
2430
+ schedulePlaySettledFullReport();
2431
+ } else if (
2432
+ playCommandContentVersion !== null &&
2433
+ store.contentVersion !== playCommandContentVersion
2434
+ ) {
2435
+ schedulePlaySettledFullReport();
2436
+ } else if (playFullReportArmed && store.contentVersion !== lastFullContentVersion) {
2437
+ // A transition already earned one refresh. Keep moving that ONE
2438
+ // refresh behind the mount burst; this does not arm periodic work.
2439
+ schedulePlaySettledFullReport();
2440
+ }
2441
+ };
2442
+ const handledByPlayReportGate = (
2443
+ playFullReport: CompletedCommandRefresh['playFullReport'],
2444
+ commandStartContentVersion: number | null,
2445
+ derivedRefresh: RelayCommandDerivedRefresh,
2446
+ ): boolean => {
2447
+ if (!editorIsActivelyPlaying()) return false;
2448
+ if (
2449
+ !playCommandOwesDerivedRefresh(
2450
+ derivedRefresh,
2451
+ playFullReport,
2452
+ store.contentVersion,
2453
+ lastFullContentVersion,
2454
+ )
2455
+ )
2456
+ return true;
2457
+ if (playFullReport === 'explicit') {
2458
+ schedulePlaySettledFullReport();
2459
+ } else if (playFullReport === 'if-content-changed') {
2460
+ armPlayCommandContentCandidate(commandStartContentVersion ?? store.contentVersion);
2461
+ } else settleUnscopedPlayCommandRefresh(commandStartContentVersion);
2462
+ return true;
2463
+ };
2464
+ /**
2465
+ * Report NOW with everything cheap fresh, and make the expensive halves
2466
+ * current in the background.
2467
+ *
2468
+ * This is the path every interaction takes. The immediate POST is what keeps
2469
+ * the "UI Play/Stop is visible to `vgai status` immediately" contract and
2470
+ * every other same-tick freshness promise in this file — playState, loop
2471
+ * liveness, selection, save state, presence and the error channels are all
2472
+ * derived fresh here. What it does NOT do is re-walk the hierarchy and
2473
+ * re-grade every capability on the frame the user clicked; a single deferred
2474
+ * full collect does that once, however many changes arrived in the burst.
2475
+ */
2476
+ const reportCurrentState = (
2477
+ /**
2478
+ * `'if-content-changed'` skips the deferred full re-derivation when the
2479
+ * store's `contentVersion` has not moved since the last full collect —
2480
+ * see `lastFullContentVersion`. ONLY the store-notification path may ask
2481
+ * for it: every other trigger here (restart-required, the ingest capture
2482
+ * wait, the adapter load, focus/blur, a completed command) can change a
2483
+ * reused facet WITHOUT any store notification at all, and the store's
2484
+ * version knows nothing about them.
2485
+ */
2486
+ derivedRefresh: RelayCommandDerivedRefresh = 'always',
2487
+ playFullReport: CompletedCommandRefresh['playFullReport'] = 'none',
2488
+ commandStartContentVersion: number | null = null,
2489
+ ) => {
2490
+ if (sessionEndedState() !== null) return;
2491
+ if (lastFullState === null) {
2492
+ // Nothing to reuse yet — the honest floor is to pay for a real collect
2493
+ // rather than report a fabricated or empty derivation.
2494
+ reportLatestState();
2495
+ return;
2496
+ }
2497
+ const state = collectState(store, lastFullState);
2498
+ lastFullState = state;
2499
+ void reportEditorState(currentStatePatch(state));
2500
+ if (derivedRefresh === 'none') return;
2501
+ if (handledByPlayReportGate(playFullReport, commandStartContentVersion, derivedRefresh)) return;
2502
+ // Pause/stop leave a complete, stable surface and must publish it. Any
2503
+ // pending Play refresh is superseded by this non-presenting full report.
2504
+ cancelPlaySettledFullReport();
2505
+ clearPlayCommandContentCandidate();
2506
+ playFullReportArmed = false;
2507
+ if (
2508
+ derivedRefresh === 'if-content-changed' &&
2509
+ store.contentVersion === lastFullContentVersion
2510
+ ) {
2511
+ return;
2512
+ }
2513
+ if (cancelDeferredFullReport !== null) return;
2514
+ cancelDeferredFullReport = scheduleDeferredFullReport(() => {
2515
+ cancelDeferredFullReport = null;
2516
+ reportLatestState();
2517
+ });
2518
+ };
2519
+ let storeReportScheduled = false;
2520
+ /** A burst that included any trigger OTHER than a store notify takes the
2521
+ * deferred full refresh unconditionally — see `reportCurrentState`'s
2522
+ * `derivedRefresh` argument for why the store's version cannot speak for
2523
+ * those. Widening, never narrowing: one such trigger in a burst is enough. */
2524
+ let storeReportNeedsFullRefresh = false;
2525
+ let reportedStorePlayState = store.playState;
2526
+ const scheduleStateReport = (needsFullRefresh: boolean) => {
2527
+ // One editor action commonly emits several store notifications. Collapse
2528
+ // that synchronous burst into one current snapshot without delaying it a
2529
+ // frame — UI Play/Stop must be visible to `vgai status` immediately even
2530
+ // though no relayed command caused the transition.
2531
+ if (needsFullRefresh) storeReportNeedsFullRefresh = true;
2532
+ if (storeReportScheduled) return;
2533
+ storeReportScheduled = true;
2534
+ queueMicrotask(() => {
2535
+ storeReportScheduled = false;
2536
+ const forceRefresh = storeReportNeedsFullRefresh;
2537
+ storeReportNeedsFullRefresh = false;
2538
+ const playBoundary = reportedStorePlayState !== store.playState;
2539
+ reportedStorePlayState = store.playState;
2540
+ reportCurrentState(
2541
+ forceRefresh ? 'always' : 'if-content-changed',
2542
+ playBoundary && store.playState === 'playing' ? 'explicit' : 'none',
2543
+ );
2544
+ });
2545
+ };
2546
+ const reportStoreChange = () => scheduleStateReport(false);
2547
+ const reportExternalChange = () => scheduleStateReport(true);
2548
+ const unsubscribeStoreReport = store.subscribe(reportStoreChange);
2549
+ // R1 — restart-required transitions don't flow through the store (they
2550
+ // have their own listener set in play-mode.ts), but `vgai status` readers
2551
+ // need `restartRequired` fresh even when NO editor command caused the
2552
+ // change (an external agent editing an R3F entry mid-play is exactly the
2553
+ // silent-staleness case R1 closes). Re-POST state on every transition.
2554
+ const unsubscribeRestartReport = subscribeLiveSessions(reportExternalChange);
2555
+ // Same reason, for the ingest capture wait: it starts and ends outside any
2556
+ // store notification (a mount awaiting its game's first frame), and on a
2557
+ // hidden tab it can hold for as long as the human is away. Without this the
2558
+ // server's snapshot would predate the wait entirely, so `vgai status` would
2559
+ // answer "nothing is ingested" for a mount that is very much in flight. The
2560
+ // wait's OTHER transition — parked↔running as the tab hides and shows —
2561
+ // already re-POSTs through `reportPresence`'s `visibilitychange` listener.
2562
+ // Same reason again, for the project's ADAPTER: it loads asynchronously at
2563
+ // editor init and on project switches, outside any store notification. The
2564
+ // adapter facet is the proof that a project's `vgai.adapter.ts` (or the
2565
+ // declared native default) loaded at all, so a snapshot that predates the
2566
+ // load would answer "no adapter" for one that is loaded and live.
2567
+ const unsubscribeAdapterReport = subscribeProjectAdapter(reportExternalChange);
2568
+ // A viewport's first completed frame also lands outside the store. Report
2569
+ // it proactively so remote readers can poll the server-held state instead
2570
+ // of injecting no-op `active-tab` commands while a large board is rendering.
2571
+ const unsubscribeViewportActivationReport =
2572
+ subscribeViewportActivationTimings(reportExternalChange);
2573
+
2574
+ // Report initial state
2575
+ reportLatestState();
2576
+ // …and the standing fact the state snapshot cannot carry: this page is now
2577
+ // running a command listener. Before this, a page that beat but never got
2578
+ // here was indistinguishable from a healthy one until somebody sent a
2579
+ // command and watched it hang. See `reportCommandListener`.
2580
+ void reportCommandListener(true);
2581
+ // The other standing fact of that shape: which step of a play boot this page
2582
+ // is inside. Wired HERE because this is the module that owns "facts this page
2583
+ // reports upstream", and because a phase is only useful to a reader who can
2584
+ // also see the command it explains. See `play-boot-phase.ts`.
2585
+ setPlayBootPhaseReporter(reportPlayBootPhase);
2586
+
2587
+ // And again on every RE-open. The server drops a tab's health snapshot
2588
+ // when its connection goes, and a tab with no health is deliberately never
2589
+ // chosen as the command controller (`editor-sse.ts`'s `selectedController`:
2590
+ // an identified tab that has not reported yet is connected but not
2591
+ // command-ready). Without this, a reconnect — the control socket's backoff
2592
+ // after any blip — left the tab uncontrollable until the next store
2593
+ // change, presence event, or command happened to fire.
2594
+ source.addEventListener('open', () => {
2595
+ // A reconnect is transport state, not a project mutation. In Play, a full
2596
+ // reconnect collect was enough to stall a large world and provoke another
2597
+ // disconnect; reuse the last honest derived facets and publish the cheap
2598
+ // liveness/presence fields immediately.
2599
+ reportCurrentState('if-content-changed');
2600
+ // Listener readiness belongs to the PAGE, but the server deliberately
2601
+ // drops its cached proof when this page has no connection left. Re-prove
2602
+ // it on the successor connection instead of leaving status at
2603
+ // `not attached` until some unrelated state transition happens.
2604
+ void reportCommandListener(sessionEndedState() === null);
2605
+ });
2606
+
2607
+ // A page whose session has ENDED must leave the bijection's account of live
2608
+ // tabs rather than sit in it answering probes. Reporting the listener
2609
+ // detached is exactly the fact `server/server-utils.ts`'s
2610
+ // `commandListenerHealth` already prints per tab (`not attached`), so the
2611
+ // server's own tab table names the corpse without a second liveness notion
2612
+ // beside it. The `resume` branch of the lease's recovery re-attaches.
2613
+ const unsubscribeTombstone = onSessionEndedChange((state) => {
2614
+ void reportCommandListener(state === null);
2615
+ });
2616
+
2617
+ source.addEventListener('editor-command', (e: MessageEvent) => {
2618
+ try {
2619
+ const cmd = JSON.parse(e.data as string) as EditorCommand;
2620
+ const requestId = cmd._requestId;
2621
+ const commandStartContentVersion = store.contentVersion;
2622
+ // A tombstone answers, and what it answers is a REFUSAL naming its state.
2623
+ // Silence here would be worse than the defect: the caller would wait out
2624
+ // its whole budget and then be told the tab did not respond — a sentence
2625
+ // about a healthy tab, for a page that is dead.
2626
+ const tombstone = sessionEndedState();
2627
+ if (tombstone !== null) {
2628
+ const error = sessionEndedRefusal(tombstone, String(cmd['type']));
2629
+ if (requestId) void reportCommandResult(requestId, false, error);
2630
+ return;
2631
+ }
2632
+ // Receipt FIRST, before any work: it answers "this tab's command
2633
+ // listener is running", which is the one thing the relay cannot observe
2634
+ // and the thing a long-budget command (`play`'s 120s) otherwise spends
2635
+ // its whole budget failing to learn. Not awaited — the work must not
2636
+ // queue behind it. See `server/server-utils.ts`'s `RELAY_DELIVERY_ACK_MS`.
2637
+ if (requestId) void reportCommandReceived(requestId);
2638
+ let present: (() => void) | undefined;
2639
+ handleCommand(store, cmd, {
2640
+ deferPresentation: (effect) => {
2641
+ present = effect;
2642
+ },
2643
+ ...(history ? { history } : {}),
2644
+ })
2645
+ // A handler that THROWS must still answer its caller. Without this leg
2646
+ // the rejection reached only the browser's `unhandledrejection`
2647
+ // channel: the relay's caller got nothing and timed out into
2648
+ // "the tab is present … and did not respond" — which blames the tab
2649
+ // for a defect in the command — and the console capture DEDUPES a
2650
+ // repeated message, so the SECOND occurrence of the same failure left
2651
+ // the session journal completely silent. Measured 2026-08-15 on a
2652
+ // racing-game ingest mount (`capture-viewport` with a non-number
2653
+ // `size`; see `captureSizeFromCommand`).
2654
+ .catch((error: unknown) => commandThrewResult(cmd, error))
2655
+ .then((result) => {
2656
+ // Report command result back to server (so SDK/CLI gets the response)
2657
+ if (requestId) {
2658
+ const ack = reportCommandResult(
2659
+ requestId,
2660
+ result.ok,
2661
+ result.error,
2662
+ result.data,
2663
+ present !== undefined,
2664
+ );
2665
+ if (present) {
2666
+ // Do not merely queue behind the result send in the same task: wait until the original
2667
+ // caller's response has finished. Only then may a 500-row hierarchy reveal or inspector
2668
+ // preview monopolize the page's main thread. Presentation still runs after the bounded
2669
+ // receipt wait if the server vanished.
2670
+ const schedule = () => window.setTimeout(present!, 0);
2671
+ void ack.then(schedule, schedule);
2672
+ }
2673
+ } else if (present) {
2674
+ window.setTimeout(present, 0);
2675
+ }
2676
+ // The selection SET is already current even when its React presentation is deferred, so
2677
+ // this snapshot reports the applied truth without waiting for hierarchy/Inspector work
2678
+ // — nor, now, for a re-walk and re-grade of the whole project (`reportCurrentState`).
2679
+ const refresh = completedCommandDerivedRefresh(cmd, result.ok, reportedDebugReadProofs);
2680
+ reportCurrentState(refresh.derived, refresh.playFullReport, commandStartContentVersion);
2681
+ });
2682
+ } catch {
2683
+ /* ignore malformed events */
2684
+ }
2685
+ });
2686
+
2687
+ // When the server switches projects, reload to pick up the new project's files
2688
+ source.addEventListener('project-changed', () => {
2689
+ window.location.reload();
2690
+ });
2691
+
2692
+ // #145 — presence freshness: the state snapshot the server holds is only
2693
+ // re-POSTed after commands, so visibility/focus changes between commands
2694
+ // would go stale. Report on the three events that change presence.
2695
+ //
2696
+ // Through `reportCurrentState`, never a full collect: a focus/blur used to
2697
+ // re-derive the entire status surface on the event's own frame, which was
2698
+ // measured at 1221ms (`DOMWindow.onfocus`) and 1306ms (`DOMWindow.onblur`)
2699
+ // of main-thread block on a game-heavy project. Tabbing into the editor
2700
+ // froze it for over a second, every time, and nothing a focus changes is in
2701
+ // the expensive half.
2702
+ // Wrapped, never passed directly: this is an EVENT listener, and handing the
2703
+ // browser's `Event` straight into `reportCurrentState`'s argument would make
2704
+ // the refresh policy depend on an accident of the DOM signature.
2705
+ const reportPresence = (): void => reportCurrentState();
2706
+ document.addEventListener('visibilitychange', reportPresence);
2707
+ window.addEventListener('focus', reportPresence);
2708
+ window.addEventListener('blur', reportPresence);
2709
+
2710
+ // pageErrors freshness (target-blaster friction #3): a runtime error
2711
+ // between commands must reach the server snapshot too, or `vgai status`
2712
+ // reads stale-clean. Deferred a tick so the boot-installed error-capture
2713
+ // listener (`installEditorConsoleCapture`, which feeds editorConsole — the
2714
+ // list collectPlayRunPageErrors/collectSessionErrors read) runs FIRST regardless of
2715
+ // registration order.
2716
+ const reportAfterError = () => setTimeout(reportPresence, 0);
2717
+ window.addEventListener('error', reportAfterError);
2718
+ window.addEventListener('unhandledrejection', reportAfterError);
2719
+
2720
+ return () => {
2721
+ // Reported BEFORE the socket closes, so it still has a channel to travel
2722
+ // on. A teardown that also loses the connection is reported by the
2723
+ // server's own `close` handler; this is the case where the page keeps its
2724
+ // channel and stops listening.
2725
+ void reportCommandListener(false);
2726
+ cancelDeferredFullReport?.();
2727
+ cancelDeferredFullReport = null;
2728
+ if (playSettleTimer !== null) clearTimeout(playSettleTimer);
2729
+ playSettleTimer = null;
2730
+ clearPlayCommandContentCandidate();
2731
+ unsubscribeTombstone();
2732
+ unsubscribeStoreReport();
2733
+ unsubscribeRestartReport();
2734
+ unsubscribeAdapterReport();
2735
+ unsubscribeViewportActivationReport();
2736
+ document.removeEventListener('visibilitychange', reportPresence);
2737
+ window.removeEventListener('focus', reportPresence);
2738
+ window.removeEventListener('blur', reportPresence);
2739
+ window.removeEventListener('error', reportAfterError);
2740
+ window.removeEventListener('unhandledrejection', reportAfterError);
2741
+ source.close();
2742
+ };
2743
+ }