@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,1811 @@
1
+ /**
2
+ * THE CONTROL PLANE — everything between a tab and this server that is not a
3
+ * file read or a file write.
4
+ *
5
+ * One subsystem, two HTTP surfaces. The tab routes (`routes/session-tabs.ts`)
6
+ * and the relay routes (`routes/relay.ts`) are registered from their own
7
+ * modules, but both drive THIS: the pending-command table, the four upstream
8
+ * control-message handlers, the SSE event stream, the duplex control socket,
9
+ * and the command relay itself. They could not be split further without
10
+ * inventing a hand-off wider than the subsystem.
11
+ *
12
+ * The invariant the whole file exists for: COMMANDS ARE ADDRESSED TO A TAB,
13
+ * NOT TO A SOCKET. A socket is a per-page-load thing that comes and goes under
14
+ * an ordinary reload; the tab is what the caller means. So the relay asks the
15
+ * TABLE which tab is blessed, then looks up whatever channel that tab holds —
16
+ * and if it holds none this instant, it WAITS inside the command's own budget
17
+ * rather than answering "disconnected" about a tab that is provably here.
18
+ *
19
+ * The second invariant: a tab reports four facts upstream (it picked a command
20
+ * up, the command finished, its state snapshot, which surface it is showing)
21
+ * over its control socket OR as a POST, and BOTH land on the same handler
22
+ * here, so the two transports cannot answer differently.
23
+ */
24
+
25
+ import { randomUUID } from 'node:crypto';
26
+ import type { Server as HttpServer } from 'node:http';
27
+ import {
28
+ EDITOR_CONTROL_LIFECYCLE_VERSION,
29
+ type EditorControlLifecycle,
30
+ editorControlLifecycleMismatch,
31
+ parseEditorControlLifecycle,
32
+ } from '@volter/editor-sdk/session/editor-control-lifecycle';
33
+ import { COMMAND_RESULT_RECEIPT_EVENT } from '@volter/editor-sdk/session/editor-control-protocol';
34
+ import type { Request, Response } from 'express';
35
+ import {
36
+ ABNORMAL_SOCKET_CLOSE,
37
+ CONTROL_ECHO_TIMEOUT_MS,
38
+ createEditorControlSocket,
39
+ type EditorControlSocketServer,
40
+ } from '../editor-control-socket';
41
+ import { type EditorServerRouter, headerValue, noEditorConnectedMessage } from '../editor-server';
42
+ import {
43
+ addClient,
44
+ clientControlHealthFor,
45
+ clientCount,
46
+ clientIdForResponse,
47
+ closeClient,
48
+ commandListenerFactsFor,
49
+ type EditorEventClient,
50
+ isClientAlive,
51
+ isEditorSocketClient,
52
+ noteCommandListenerAttached,
53
+ noteCommandReceipt,
54
+ noteCommandRelay,
55
+ removeClient,
56
+ sendToClientHandle,
57
+ updateClientControlHealth,
58
+ } from '../editor-sse';
59
+ import { playStallConsoleMessage, playStallDiagnosis } from '../play-stall';
60
+ import {
61
+ CONTROLLER_DISCONNECTED_MESSAGE,
62
+ type CommandListenerHealth,
63
+ commandListenerHealth,
64
+ DESKTOP_FRAME_ORIGIN,
65
+ isAllowedEditorOrigin,
66
+ RELAY_DELIVERY_ACK_MS,
67
+ RELAY_DELIVERY_MAX_WAIT_MS,
68
+ type RelayedCommandResult,
69
+ relayCommandAckDeadlineMs,
70
+ relayCommandTimeoutMs,
71
+ unacknowledgedCommandMessage,
72
+ } from '../server-utils';
73
+ import { processSessionId } from '../session-registry';
74
+ import type { TabLifecycleController } from '../tab-lifecycle';
75
+ import {
76
+ type TabPresenceReport,
77
+ type TabSurface,
78
+ tabAbsenceMessage,
79
+ tabUnresponsiveMessage,
80
+ tabWaitingMessage,
81
+ } from '../tab-presence';
82
+ import type { ControlOutcome, RouteContext, TrustedShareIdentity } from './context';
83
+
84
+ /** How many distinct pre-listener page errors one page-load may file. Enough
85
+ * for a cause plus a little of its cascade; a page in a rejection loop must
86
+ * not grow this without bound. */
87
+ const PAGE_ERRORS_PER_CLIENT = 8;
88
+ /** Per error. A stack is what makes the line actionable, so this is generous;
89
+ * it is a bound, not a summary. */
90
+ const PAGE_ERROR_MAX_CHARS = 2_000;
91
+ /** How many distinct CLIENT IDS may hold page errors at once. The per-client
92
+ * teardown only fires for clients that really connected, but the POST twin
93
+ * accepts a self-reported id — so without this bound a writer inventing
94
+ * fresh ids would grow the map (and spam the journal) forever. A session has
95
+ * a handful of live page-loads; this is an order of magnitude past that. */
96
+ const PAGE_ERROR_CLIENTS_MAX = 32;
97
+
98
+ export interface SettledCommandResult {
99
+ result: RelayedCommandResult;
100
+ /** Sent only after the original command caller's response finishes. */
101
+ callerReceipt?: () => void;
102
+ }
103
+
104
+ export interface OpenGameplayRecording {
105
+ readonly purpose?: 'export';
106
+ readonly format: 'composite-webm' | 'canvas-dom';
107
+ readonly path: string;
108
+ readonly replayPath: string | null;
109
+ nextSequence: number;
110
+ nextDomSequence: number;
111
+ domEventCount: number;
112
+ domEventsStarted: boolean;
113
+ readonly replayAssets: Set<string>;
114
+ }
115
+
116
+ export interface PresentedControlLifecycle {
117
+ readonly status: 'aligned' | 'awaiting-heartbeat' | 'unconfirmed' | 'mismatch';
118
+ readonly serverGeneration8: string;
119
+ readonly connectionGeneration8: string;
120
+ readonly pageGeneration8: string;
121
+ readonly clientId8: string;
122
+ }
123
+
124
+ /** What the two route families read off the control plane. */
125
+ export interface ControlPlane {
126
+ readonly handleCommandReceived: (payload: Record<string, unknown>) => ControlOutcome;
127
+ readonly handleCommandListener: (payload: Record<string, unknown>) => ControlOutcome;
128
+ readonly handlePlayPhase: (payload: Record<string, unknown>) => ControlOutcome;
129
+ readonly handleConsoleEntries: (payload: Record<string, unknown>) => ControlOutcome;
130
+ readonly handleConsoleResolved: (payload: Record<string, unknown>) => ControlOutcome;
131
+ readonly handlePageError: (payload: Record<string, unknown>) => ControlOutcome;
132
+ /** The page's contributed command rows (`/__editor/contributed-commands`). */
133
+ readonly handleContributedCommands: (payload: Record<string, unknown>) => ControlOutcome;
134
+ readonly handleCommandResult: (payload: Record<string, unknown>) => ControlOutcome;
135
+ readonly handleEditorState: (reported: Record<string, unknown>) => ControlOutcome;
136
+ readonly handleTabRoute: (
137
+ body: Record<string, unknown>,
138
+ trusted: TrustedShareIdentity | null,
139
+ ) => ControlOutcome;
140
+ readonly decorateWithCommandListener: (
141
+ rows: readonly TabPresenceReport[],
142
+ table: TabLifecycleController | null,
143
+ now: number,
144
+ ) => Array<
145
+ TabPresenceReport & {
146
+ commandListener?: CommandListenerHealth;
147
+ pageErrors?: string[];
148
+ controlLifecycles?: PresentedControlLifecycle[];
149
+ }
150
+ >;
151
+ readonly tabTableFor: (participantId: string | null) => TabLifecycleController | null;
152
+ readonly clientsInDeliveryOrder: (
153
+ tabId: string,
154
+ table: TabLifecycleController | null,
155
+ ) => string[];
156
+ readonly relayCommand: (
157
+ body: Record<string, unknown>,
158
+ requestedParticipantId?: string,
159
+ ) => Promise<SettledCommandResult>;
160
+ readonly relayCommandResult: (body: Record<string, unknown>) => Promise<RelayedCommandResult>;
161
+ readonly openGameplayRecording: (id: unknown) => OpenGameplayRecording | null;
162
+ /** The open recordings themselves — the chunk/finish/abort routes mutate them. */
163
+ readonly openGameplayRecordings: Map<string, OpenGameplayRecording>;
164
+ /** Close the duplex control socket. Part of the server's teardown. */
165
+ readonly close: () => void;
166
+ readonly MAX_RECORDING_CHUNK_BYTES: number;
167
+ }
168
+
169
+ export function createControlPlane(router: EditorServerRouter, ctx: RouteContext): ControlPlane {
170
+ const {
171
+ clientIdsForTab,
172
+ clientTabIds,
173
+ clientTabLifecycles,
174
+ consoleLedger,
175
+ currentCollaboration,
176
+ currentCollaborationLocation,
177
+ defaultCollaborationRole,
178
+ editorStatesByClient,
179
+ heartbeat,
180
+ hostTabLifecycle,
181
+ journalEvent,
182
+ localParticipantIds,
183
+ onTabReloaded,
184
+ options,
185
+ pageErrorsByClient,
186
+ participantConnections,
187
+ participantLeaveTimers,
188
+ participantTabLifecycles,
189
+ short,
190
+ tabIdForClient,
191
+ tabLifecycleForParticipant,
192
+ trustedShareIdentity,
193
+ } = ctx;
194
+ /**
195
+ * One accepted generation per event-stream connection, plus the current
196
+ * generation for each page id. The connection map validates socket frames;
197
+ * the page map validates POST fallbacks and is compare-deleted so a late
198
+ * close from a superseded socket cannot erase its successor.
199
+ */
200
+ const controlLifecycleByConnection = new Map<EditorEventClient, EditorControlLifecycle>();
201
+ const controlLifecycleByClientId = new Map<string, EditorControlLifecycle>();
202
+ const currentConnectionByClientId = new Map<string, EditorEventClient>();
203
+ const confirmedLifecycleConnections = new Set<EditorEventClient>();
204
+
205
+ /**
206
+ * THE THING THE LEDGER CANNOT OTHERWISE TELL APART: a page with no errors,
207
+ * and a page that is not reporting its errors. Both are an empty ledger.
208
+ *
209
+ * Measured 2026-09-19 (U6b's Model-workspace walk): the fork's bridge boots
210
+ * the editor without `installConsoleSync()`, so 44 React errors filled that
211
+ * page's console and the bottom bar's counter while `vgai console` printed a
212
+ * clean session for the length of a walk. The page-side fix is one door that
213
+ * cannot be half-called (`src/console-sync.ts`); this is the half that
214
+ * NOTICES when some future host gets it wrong anyway, because a convention
215
+ * whose failure is silent is not enforced by anything.
216
+ *
217
+ * A reporting page announces itself with an empty batch the moment it
218
+ * installs. A load that proved it is running the document and has still said
219
+ * nothing after the grace window becomes an error in the ledger itself —
220
+ * named, in the one place the convention says to look.
221
+ */
222
+ const consoleReporterWatch = new Map<string, ReturnType<typeof setTimeout>>();
223
+ const consoleReportingLoads = new Set<string>();
224
+ const CONSOLE_REPORTER_GRACE_MS = 30_000;
225
+
226
+ function armConsoleReporterWatch(clientId: string): void {
227
+ if (consoleReportingLoads.has(clientId) || consoleReporterWatch.has(clientId)) return;
228
+ const timer = setTimeout(() => {
229
+ consoleReporterWatch.delete(clientId);
230
+ if (consoleReportingLoads.has(clientId)) return;
231
+ consoleLedger.observe({
232
+ severity: 'error',
233
+ source: 'console-reporting',
234
+ message:
235
+ 'This editor page is not reporting its console. Its errors and warnings reach the ' +
236
+ "page's own console and the editor's bottom-bar counter and STOP THERE, so an empty " +
237
+ '`volter-editor console` says nothing about this session. The host that booted this page must ' +
238
+ 'call `installEditorConsoleReporting()` (src/console-sync.ts), never ' +
239
+ '`installEditorConsoleCapture()` alone.',
240
+ loadId: clientId,
241
+ });
242
+ }, CONSOLE_REPORTER_GRACE_MS);
243
+ timer.unref?.();
244
+ consoleReporterWatch.set(clientId, timer);
245
+ }
246
+
247
+ function noteConsoleReporter(clientId: string): void {
248
+ consoleReportingLoads.add(clientId);
249
+ const timer = consoleReporterWatch.get(clientId);
250
+ if (timer !== undefined) {
251
+ clearTimeout(timer);
252
+ consoleReporterWatch.delete(clientId);
253
+ }
254
+ }
255
+
256
+ function controlClientId(payload: Record<string, unknown>): string | null {
257
+ const candidate = payload['_clientId'] ?? payload['clientId'];
258
+ return typeof candidate === 'string' ? candidate : null;
259
+ }
260
+
261
+ /** A missing envelope is a rolling-upgrade client and remains observable as
262
+ * unconfirmed. A PRESENT envelope is never guessed through: it either
263
+ * names the current connection generation exactly or is rejected. */
264
+ function rejectStalePostedControl(payload: Record<string, unknown>): ControlOutcome | null {
265
+ const raw = payload['_controlLifecycle'];
266
+ if (raw === undefined) return null;
267
+ const reported = parseEditorControlLifecycle(raw);
268
+ if (reported === null) {
269
+ return { status: 400, error: 'The control lifecycle envelope is malformed.' };
270
+ }
271
+ const clientId = controlClientId(payload);
272
+ const expected = clientId === null ? undefined : controlLifecycleByClientId.get(clientId);
273
+ if (expected === undefined) {
274
+ return {
275
+ status: 409,
276
+ error:
277
+ 'That editor page generation is no longer connected; its control report was ignored.',
278
+ };
279
+ }
280
+ const mismatch = editorControlLifecycleMismatch(expected, reported);
281
+ if (mismatch !== null) {
282
+ return {
283
+ status: 409,
284
+ error: `That control report belongs to an older ${mismatch}; it was ignored.`,
285
+ };
286
+ }
287
+ const connection = currentConnectionByClientId.get(expected.clientId);
288
+ if (connection !== undefined && !confirmedLifecycleConnections.has(connection)) {
289
+ confirmedLifecycleConnections.add(connection);
290
+ journalEvent({
291
+ kind: 'control-lifecycle-confirmed',
292
+ clientId8: short(expected.clientId),
293
+ connectionGeneration8: short(expected.connectionGeneration),
294
+ });
295
+ }
296
+ return null;
297
+ }
298
+
299
+ function socketLifecycleMatches(
300
+ client: EditorEventClient,
301
+ reported: EditorControlLifecycle | null,
302
+ malformed: boolean,
303
+ ): boolean {
304
+ // Rolling-upgrade compatibility: the server still accepts a page that
305
+ // predates the envelope, but status names it `unconfirmed`. Once an
306
+ // envelope is present it is strict; malformed and stale are not aliases
307
+ // for legacy.
308
+ if (reported === null && !malformed) return true;
309
+ const expected = controlLifecycleByConnection.get(client);
310
+ const mismatch =
311
+ expected && reported ? editorControlLifecycleMismatch(expected, reported) : null;
312
+ if (malformed || !expected || mismatch !== null) {
313
+ journalEvent({
314
+ kind: 'control-lifecycle-rejected',
315
+ clientId8: short(expected?.clientId),
316
+ mismatch: mismatch ?? 'connectionGeneration',
317
+ });
318
+ return false;
319
+ }
320
+ if (!confirmedLifecycleConnections.has(client)) {
321
+ confirmedLifecycleConnections.add(client);
322
+ journalEvent({
323
+ kind: 'control-lifecycle-confirmed',
324
+ clientId8: short(expected.clientId),
325
+ connectionGeneration8: short(expected.connectionGeneration),
326
+ });
327
+ }
328
+ return true;
329
+ }
330
+ // Pending command results — keyed by requestId, resolved by browser POST
331
+ const pendingCommands = new Map<
332
+ string,
333
+ {
334
+ resolve: (result: SettledCommandResult) => void;
335
+ timer: ReturnType<typeof setTimeout>;
336
+ /** Armed only for long-budget types (`relayCommandAckDeadlineMs`), and
337
+ * cleared by the tab's receipt. See that function for why delivery and
338
+ * work need separate budgets. */
339
+ ackTimer?: ReturnType<typeof setTimeout>;
340
+ /** Re-delivery timer while the target TAB is present but has no channel. */
341
+ holdTimer?: ReturnType<typeof setTimeout>;
342
+ /** The tab this command is addressed to — it outlives the channel. */
343
+ tabId?: string;
344
+ /** The frame to (re)deliver, kept so a held command can be sent again
345
+ * on the tab's NEXT channel without the caller re-issuing it. */
346
+ command?: Record<string, unknown>;
347
+ /** The tab's `epochCount` when the command was accepted, so a refusal can
348
+ * say how many times it reloaded while waiting. */
349
+ epochAtRelay?: number;
350
+ /** One `command-held` line per command, not one per retry. */
351
+ heldJournaled?: boolean;
352
+ /** Set by the tab's receipt. A POSITIVE marker, not the absence of a
353
+ * timer: the final refusal awaits a main-thread echo, and a receipt
354
+ * arriving inside that window must win. */
355
+ acknowledged?: boolean;
356
+ /** Long-budget commands have separate delivery and work budgets. The
357
+ * relay starts a defensive timer immediately, then the first receipt
358
+ * restarts it so time spent queued behind a blocked main thread cannot
359
+ * consume the work budget before the page begins the work. */
360
+ restartWorkTimerAfterReceipt?: () => void;
361
+ controller?: EditorEventClient;
362
+ controllerClientId?: string;
363
+ }
364
+ >();
365
+ // Per-command relay budgets live in ONE pure, unit-tested table
366
+ // (`server-utils.ts`'s `relayCommandTimeoutMs`) — including the reason each
367
+ // entry exists. Testing it here would mean booting a server and standing
368
+ // through a real expiry per case.
369
+
370
+ /** Fail every in-flight command with one error — used when the last SSE
371
+ * client goes away: a reloaded page has no memory of the requestId it was
372
+ * supposed to answer, so waiting out the timer only delays the inevitable
373
+ * (and mislabels a reload as "editor did not respond"). */
374
+ /** The ONE path that ends a pending command: drop both timers, forget it,
375
+ * answer its caller. Every settle below goes through here so a new timer
376
+ * can never be left armed on a command that already answered. */
377
+ /**
378
+ * A TAB RELOADED WHILE HOLDING A COMMAND. The page that took the command is
379
+ * gone, and with it whatever it was doing and every session it held; the
380
+ * new page-load has no memory of the requestId, so nothing will ever answer
381
+ * it, and waiting out the work budget (30 min for `blender-execute`) only
382
+ * mislabels a reload as "did not respond" half an hour late. Measured
383
+ * 2026-09-17: a `blender-execute` receipted at 01:44:47, the page reloaded
384
+ * at 01:44:49 (`client-disconnected ... commandsSettled: 0`, then
385
+ * `tab-reloaded epochCount: 2`), and the caller heard nothing until its own
386
+ * timeout. Only commands the OLD page-load had TAKEN are settled here (the
387
+ * receipt says so); one still held for delivery goes to the new page as
388
+ * the hold contract promises.
389
+ */
390
+ function settleCommandsOfReloadedTab(tabId: string, epochCount: number): void {
391
+ for (const [id, pending] of [...pendingCommands]) {
392
+ if (pending.tabId !== tabId) continue;
393
+ if (pending.acknowledged !== true) continue;
394
+ if ((pending.epochAtRelay ?? epochCount) >= epochCount) continue;
395
+ const type =
396
+ typeof pending.command?.['type'] === 'string' ? pending.command['type'] : 'command';
397
+ settlePendingCommand(id, {
398
+ ok: false,
399
+ error:
400
+ `The editor tab reloaded while \`${type}\` was running in it (page-load ` +
401
+ `${pending.epochAtRelay ?? 0} → ${epochCount}). The page that held the command is gone, ` +
402
+ 'and with it any session it was working in; nothing will answer it. Start again.',
403
+ });
404
+ }
405
+ }
406
+ onTabReloaded(settleCommandsOfReloadedTab);
407
+
408
+ /**
409
+ * The lifecycle whose table holds this TAB — the participant is not always
410
+ * in hand where the answer is needed (a command settles from a timer, a
411
+ * socket close, or a POST), and the tabId always is.
412
+ */
413
+ function tabTableOwning(tabId: string): TabLifecycleController | null {
414
+ if (hostTabLifecycle.tab(tabId) !== undefined) return hostTabLifecycle;
415
+ for (const lifecycle of participantTabLifecycles.values()) {
416
+ if (lifecycle?.tab(tabId) !== undefined) return lifecycle ?? null;
417
+ }
418
+ return null;
419
+ }
420
+
421
+ function settlePendingCommand(
422
+ id: string,
423
+ result: RelayedCommandResult,
424
+ callerReceipt?: () => void,
425
+ ): void {
426
+ const pending = pendingCommands.get(id);
427
+ if (!pending) return;
428
+ clearTimeout(pending.timer);
429
+ if (pending.ackTimer) clearTimeout(pending.ackTimer);
430
+ if (pending.holdTimer) clearTimeout(pending.holdTimer);
431
+ pendingCommands.delete(id);
432
+ journalEvent({
433
+ kind: 'command-result',
434
+ requestId8: short(id),
435
+ ok: result.ok === true,
436
+ ...(result.ok === true ? {} : { error: String(result.error ?? '') }),
437
+ });
438
+ // HOW THE PAGE IS DOING, filed against the tab. A command that expired
439
+ // while the heartbeat stayed fresh is the one piece of evidence for `hung`
440
+ // that no page-side measurement can supply (a wedged main thread samples
441
+ // no census and sends no report), and an answered one is the proof that
442
+ // clears it. `tab-presence.ts`'s `tabState` is the only reader.
443
+ if (pending.tabId) {
444
+ const table = tabTableOwning(pending.tabId);
445
+ if (result.timedOut === true) table?.onCommandOutcome(pending.tabId, 'timed-out');
446
+ else if (result.ok === true) table?.onCommandOutcome(pending.tabId, 'answered');
447
+ }
448
+ pending.resolve({ result, ...(callerReceipt ? { callerReceipt } : {}) });
449
+ }
450
+
451
+ /**
452
+ * A PACKAGE'S verbs, as the page reported them after its contribution pass
453
+ * (`/__editor/contributed-commands`, `tool-loader.ts`): the relay's wait
454
+ * for a contributed verb comes from its own row, never from the generic
455
+ * budget. Replaced whole on every report, so a reload's registry is the
456
+ * one that stands.
457
+ */
458
+ const contributedCommandTimeouts = new Map<string, number>();
459
+ function commandTimeoutMs(type: unknown): number {
460
+ if (typeof type === 'string') {
461
+ const contributed = contributedCommandTimeouts.get(type);
462
+ if (contributed !== undefined) return contributed;
463
+ }
464
+ return relayCommandTimeoutMs(type);
465
+ }
466
+ function commandAckDeadlineMs(type: unknown): number | null {
467
+ if (typeof type === 'string' && contributedCommandTimeouts.has(type))
468
+ return commandTimeoutMs(type) > RELAY_DELIVERY_ACK_MS ? RELAY_DELIVERY_ACK_MS : null;
469
+ return relayCommandAckDeadlineMs(type);
470
+ }
471
+ function handleContributedCommands(payload: Record<string, unknown>): ControlOutcome {
472
+ const rows = payload['commands'];
473
+ if (!Array.isArray(rows))
474
+ return { status: 400, error: 'contributed-commands requires { commands: [] }.' };
475
+ const next = new Map<string, number>();
476
+ for (const row of rows) {
477
+ const record = (row ?? {}) as Record<string, unknown>;
478
+ const type = record['type'];
479
+ const timeoutMs = record['timeoutMs'];
480
+ if (typeof type !== 'string' || typeof timeoutMs !== 'number' || !(timeoutMs > 0))
481
+ return {
482
+ status: 400,
483
+ error: 'each contributed command row needs a type and a positive timeoutMs.',
484
+ };
485
+ next.set(type, timeoutMs);
486
+ }
487
+ contributedCommandTimeouts.clear();
488
+ for (const [type, timeoutMs] of next) contributedCommandTimeouts.set(type, timeoutMs);
489
+ return CONTROL_OK;
490
+ }
491
+
492
+ /**
493
+ * A long budget is for the WORK, not for DELIVERY — so for the types that
494
+ * have one, start a receipt window beside the command's own timer.
495
+ *
496
+ * The tab clears it the instant its command listener picks the event up
497
+ * (`POST /__editor/command-received`). When the window expires instead,
498
+ * the command event is still QUEUED in a tab that may merely be blocked —
499
+ * a cold editor boot under machine load froze a real tab's main thread for
500
+ * 60-100s on 2026-08-09 while five 8s windows false-failed plays that all
501
+ * executed afterward. So the window RE-ARMS while the controller's socket
502
+ * lives, up to the ceiling (clamped to the command's own budget), and only
503
+ * then refuses, with wording that warns against blind resends. A tab that
504
+ * actually goes away is settled sooner and more precisely by the
505
+ * connection `close` handler's `failCommandsOwnedBy`. A no-op for ordinary
506
+ * commands, which already fail faster than the first window.
507
+ *
508
+ * On the duplex control socket the final refusal stops GUESSING. Before it
509
+ * settles, it reads the socket's last protocol pong (answered by the
510
+ * browser's network stack, so it is fresh even through a blocked main
511
+ * thread) and asks the page's INLINE echo responder to reply. Those two
512
+ * facts distinguish "blocked, still queued" from "responsive but running
513
+ * no command listener" — see `unacknowledgedCommandMessage`.
514
+ */
515
+ function armDeliveryReceiptWindow(requestId: string, type: unknown): void {
516
+ if (commandAckDeadlineMs(type) === null) return;
517
+ const windowMs = options.relayDeliveryAckMs ?? RELAY_DELIVERY_ACK_MS;
518
+ // Never outlive the command's own timer. `stop` (30s),
519
+ // `capture-asset-preview` (30s) and `bridge-screenshot` (15s) all have
520
+ // budgets SHORTER than the default ceiling, so an unclamped wait would
521
+ // hand those callers the generic "editor connected but did not respond"
522
+ // — the exact uninformative message the receipt exists to replace — and
523
+ // they would never see this path's guidance at all.
524
+ const maxWaitMs = Math.min(
525
+ options.relayDeliveryMaxWaitMs ?? RELAY_DELIVERY_MAX_WAIT_MS,
526
+ commandTimeoutMs(type),
527
+ );
528
+ const armedAt = Date.now();
529
+ const expire = (): void => {
530
+ const pending = pendingCommands.get(requestId);
531
+ if (!pending) return;
532
+ // Stop re-arming once the controller's connection is gone. Normally the
533
+ // `close` handler's `failCommandsOwnedBy` has already settled this
534
+ // command by then; this is the belt for the case where it has not.
535
+ const controller = pending.controller;
536
+ const socketAlive = controller !== undefined && isClientAlive(controller);
537
+ const waitedMs = Date.now() - armedAt;
538
+ if (socketAlive && waitedMs + 1 < maxWaitMs) {
539
+ pending.ackTimer = setTimeout(expire, Math.min(windowMs, maxWaitMs - waitedMs));
540
+ return;
541
+ }
542
+ if (!socketAlive || controller === undefined) {
543
+ // The narrow race the `close` handler leaves: the socket died between
544
+ // this timer firing and `failCommandsOwnedBy` running. Answer with
545
+ // that handler's OWN wording rather than the unacknowledged message,
546
+ // whose "Its connection is still open" would be false here — and
547
+ // which, unlike this one, no caller treats as retryable.
548
+ settlePendingCommand(requestId, {
549
+ ok: false,
550
+ timedOut: true,
551
+ error: CONTROLLER_DISCONNECTED_MESSAGE,
552
+ });
553
+ return;
554
+ }
555
+ void refuseUnacknowledged(requestId, type, controller, waitedMs);
556
+ };
557
+ const pending = pendingCommands.get(requestId);
558
+ if (pending) pending.ackTimer = setTimeout(expire, windowMs);
559
+ }
560
+
561
+ /**
562
+ * Settle an expired receipt window with MEASURED facts rather than a
563
+ * guess. The echo probe is awaited, so the command stays pending for at
564
+ * most one extra `CONTROL_ECHO_TIMEOUT_MS` — a window the old synchronous
565
+ * expiry did not have, which is why the receipt sets an `acknowledged`
566
+ * flag this re-reads afterwards instead of relying on its timer's absence.
567
+ */
568
+ async function refuseUnacknowledged(
569
+ requestId: string,
570
+ type: unknown,
571
+ controller: EditorEventClient,
572
+ waitedMs: number,
573
+ ): Promise<void> {
574
+ const socket = isEditorSocketClient(controller) ? controller : null;
575
+ const echoStartedAt = Date.now();
576
+ const mainThreadEcho = socket
577
+ ? (await socket.echo(CONTROL_ECHO_TIMEOUT_MS))
578
+ ? ('answered' as const)
579
+ : ('unanswered' as const)
580
+ : ('unavailable' as const);
581
+ if (socket) {
582
+ journalEvent({
583
+ kind: 'echo-probe',
584
+ clientId8: short(clientIdForResponse(controller)),
585
+ answered: mainThreadEcho === 'answered',
586
+ waitedMs: Date.now() - echoStartedAt,
587
+ });
588
+ }
589
+ // The receipt may have landed while the echo was in flight. It wins:
590
+ // the tab IS running the command, and the whole budget is now the work's.
591
+ if (pendingCommands.get(requestId)?.acknowledged) return;
592
+ if (!pendingCommands.has(requestId)) return;
593
+ // Re-read after the probe: a socket that died meanwhile is the
594
+ // disconnect case, and `failCommandsOwnedBy` may already have said so.
595
+ if (!isClientAlive(controller)) {
596
+ settlePendingCommand(requestId, {
597
+ ok: false,
598
+ timedOut: true,
599
+ error: CONTROLLER_DISCONNECTED_MESSAGE,
600
+ });
601
+ return;
602
+ }
603
+ const pending = pendingCommands.get(requestId);
604
+ const targetClientId = pending?.controllerClientId ?? null;
605
+ const health = clientControlHealthFor(targetClientId);
606
+ settlePendingCommand(requestId, {
607
+ ok: false,
608
+ timedOut: true,
609
+ error: unacknowledgedCommandMessage({
610
+ type,
611
+ visibility: health ? (health.visible ? 'visible' : 'hidden') : null,
612
+ focused: health ? health.focused : null,
613
+ silentForMs: health ? Date.now() - health.updatedAt : null,
614
+ waitedMs,
615
+ lastPongAgeMs: socket ? socket.lastPongAgeMs() : null,
616
+ mainThreadEcho,
617
+ }),
618
+ });
619
+ }
620
+
621
+ /**
622
+ * The same sweep, minus the commands a tab has RECEIPTED — the rule
623
+ * `failCommandsOwnedBy` already follows, for the same reason.
624
+ *
625
+ * `clientCount() === 0` is a fact about the TRANSPORT at one instant, not
626
+ * proof the page is gone, and the two are not the same event. MEASURED
627
+ * 2026-09-15: the hinged vise broke at call 20 of 33 when its socket closed
628
+ * with code 1006 and the SAME client id reconnected 577 ms later — no
629
+ * reload, no new document, 167 MB of heap against a 4192 MB limit. The page
630
+ * was still running the command it had receipted three seconds earlier.
631
+ *
632
+ * An UNRECEIPTED command is a different animal and still fails instantly
633
+ * here: it is queued in a page that has not picked it up, so a reload really
634
+ * does leave nobody who remembers the requestId (the cold-cache Vite
635
+ * re-optimization case this sweep was written for in the first place).
636
+ *
637
+ * A receipted one has a delivery path that does not need this socket:
638
+ * `sendCommandResultControl` POSTs the result over HTTP the moment
639
+ * `WebSocket.send` reports the socket gone. Waiting costs the command's own
640
+ * work budget in the genuinely-dead case and adds no new timeout.
641
+ */
642
+ function failUnreceiptedPendingCommands(error: string): number {
643
+ let settled = 0;
644
+ for (const [id, pending] of [...pendingCommands]) {
645
+ if (pending.acknowledged === true) continue;
646
+ settled++;
647
+ settlePendingCommand(id, { ok: false, timedOut: true, error });
648
+ }
649
+ return settled;
650
+ }
651
+
652
+ /**
653
+ * Settle the commands this connection's death stranded, and ONLY those.
654
+ *
655
+ * A RECEIPTED command is the WORK's, not the delivery window's — the same
656
+ * rule the echo probe above already follows ("the tab IS running the
657
+ * command, and the whole budget is now the work's"). This function used to
658
+ * break it and kill every pending command on the owning socket's `close`,
659
+ * including ones the tab had acknowledged and was busy running.
660
+ *
661
+ * That threw away results the transport was already designed to deliver:
662
+ * `sendCommandResultControl` falls back to POSTing the result over HTTP the
663
+ * moment `WebSocket.send` reports the socket gone. MEASURED 2026-09-14 — a
664
+ * replayed model broke at call 20 of 33 on a socket that closed with code
665
+ * 1006 and RECONNECTED 508 ms later under the same client id, with the tab
666
+ * reporting 160 MB of heap against a 4192 MB limit. Nothing had died; the
667
+ * server destroyed the pending command before its answer could arrive.
668
+ *
669
+ * The cost, stated rather than hidden: if a tab really is gone, a receipted
670
+ * command now waits out its own work budget instead of failing at once. That
671
+ * is the right trade — a slow correct failure beats a fast wrong one — and
672
+ * it adds no new timeout, because the budget already exists.
673
+ *
674
+ * Returns how many this death actually settled, which is the number the
675
+ * `client-disconnected` journal line reports.
676
+ */
677
+ function failCommandsOwnedBy(controller: EditorEventClient): number {
678
+ let settled = 0;
679
+ for (const [id, pending] of [...pendingCommands]) {
680
+ if (pending.controller !== controller) continue;
681
+ if (pending.acknowledged === true) continue;
682
+ settled++;
683
+ settlePendingCommand(id, {
684
+ ok: false,
685
+ timedOut: true,
686
+ error: CONTROLLER_DISCONNECTED_MESSAGE,
687
+ });
688
+ }
689
+ return settled;
690
+ }
691
+
692
+ // ---- Tab -> server control messages, transport-independent ----
693
+ //
694
+ // Four facts a tab reports upstream: it picked a command up, the command
695
+ // finished, its current state snapshot, and which surface it is showing.
696
+ // A local tab sends them as frames on its control socket; the share tunnel
697
+ // and any older page POST them. Both land HERE, so the two paths cannot
698
+ // answer differently. Each returns the HTTP shape its POST route needs.
699
+
700
+ const CONTROL_OK: ControlOutcome = { status: 200 };
701
+
702
+ /** The receipt: cancel the delivery window, leave the work budget alone. */
703
+ function handleCommandReceived(payload: Record<string, unknown>): ControlOutcome {
704
+ const stale = rejectStalePostedControl(payload);
705
+ if (stale) return stale;
706
+ const requestId = payload['_requestId'];
707
+ if (typeof requestId === 'string') {
708
+ const pending = pendingCommands.get(requestId);
709
+ if (pending) {
710
+ journalEvent({ kind: 'command-receipt', requestId8: short(requestId) });
711
+ const firstReceipt = pending.acknowledged !== true;
712
+ pending.acknowledged = true;
713
+ noteCommandReceipt(pending.controllerClientId);
714
+ if (pending.ackTimer) {
715
+ clearTimeout(pending.ackTimer);
716
+ delete pending.ackTimer;
717
+ }
718
+ if (firstReceipt) pending.restartWorkTimerAfterReceipt?.();
719
+ }
720
+ }
721
+ return CONTROL_OK;
722
+ }
723
+
724
+ /** The page reporting whether it is RUNNING a command listener at all —
725
+ * see `src/editor-api.ts`'s `reportCommandListener` for why presence
726
+ * cannot answer this. */
727
+ function handleCommandListener(payload: Record<string, unknown>): ControlOutcome {
728
+ const stale = rejectStalePostedControl(payload);
729
+ if (stale) return stale;
730
+ const clientId = typeof payload['_clientId'] === 'string' ? payload['_clientId'] : null;
731
+ if (clientId === null) return { status: 400, error: 'command-listener requires a client id.' };
732
+ if (typeof payload['attached'] !== 'boolean') {
733
+ return { status: 400, error: 'command-listener requires { attached: boolean }.' };
734
+ }
735
+ noteCommandListenerAttached(clientId, payload['attached'] ? Date.now() : null);
736
+ // The TABLE needs this too, and for a different question than the health
737
+ // verdict above: a channel is opened by the inline bootstrap before any
738
+ // module loads, so it is this report — and only this report — that proves
739
+ // the DOCUMENT is running. Blessing reads it (tab-presence.ts's
740
+ // `tabUnresponsive`, stage 2).
741
+ if (payload['attached'] === true) {
742
+ // …and for exactly that reason, THIS is where the boot clock stops. The
743
+ // control channel is opened by `index.html`'s inline bootstrap before a
744
+ // single module loads, so stopping at it would report a boot that had
745
+ // barely started: measured on an imported Unity port, the channel
746
+ // arrived at 26s while the server was still saturated at 135s. Written
747
+ // at most once per process (`takeBoundTimings`).
748
+ const boot = options.bootTimings?.() ?? null;
749
+ if (boot) journalEvent({ kind: 'boot', ...boot });
750
+ const tabId = tabIdForClient(clientId);
751
+ const pageEpoch = controlLifecycleByClientId.get(clientId)?.pageGeneration;
752
+ if (tabId !== null && pageEpoch !== undefined) {
753
+ clientTabLifecycles.get(clientId)?.onTabListener(tabId, pageEpoch);
754
+ }
755
+ // This — and only this — is the moment a NEW page-load is proven to be
756
+ // running the document, which is what makes it a valid re-test of every
757
+ // condition an older load reported. It arms the ledger's settle window;
758
+ // see `console-ledger.ts`'s clearing rule (a).
759
+ consoleLedger.noteLoad(clientId);
760
+ // A CHAT VIEW WITH NO AGENT OUTLIVES A PAGE LOAD, and rule (a) would sweep it: the
761
+ // condition was raised before any page existed, so the first real load retires it and
762
+ // `vgai console` goes quiet while the panel is still dead. Measured. So the session
763
+ // re-observes it HERE, under this load's own id — which is exactly what the ledger's
764
+ // own contract says a recurrence is ("the same fingerprint is observed again under the
765
+ // new load id and its count keeps climbing").
766
+ const agentRefusal = ctx.frontendRefusal();
767
+ if (agentRefusal) {
768
+ consoleLedger.observe({
769
+ severity: 'warn',
770
+ message: `The Chat view has no agent runtime: ${agentRefusal}`,
771
+ source: 'session/frontend-handoff',
772
+ loadId: clientId,
773
+ });
774
+ }
775
+ // …and start the clock on this load's own console reporter. See
776
+ // `armConsoleReporterWatch`.
777
+ armConsoleReporterWatch(clientId);
778
+ }
779
+ return CONTROL_OK;
780
+ }
781
+
782
+ /**
783
+ * WHICH STEP OF A PLAY BOOT THE PAGE IS INSIDE.
784
+ *
785
+ * Published by the page BEFORE the step runs (`src/play-boot-phase.ts`), so
786
+ * a step that never returns is still named. Held here, not in the state
787
+ * snapshot: the snapshot is the expensive derivation, and a page wedged in a
788
+ * boot step is exactly the page that cannot produce one — which is how three
789
+ * consecutive commands timed out at N=20000 against a session every reader
790
+ * called healthy (`play-stall.ts`).
791
+ *
792
+ * ONE latch for the session, not one per tab, and that is the honest scope:
793
+ * the relay routes every command to the single BLESSED tab, so the phase a
794
+ * refusal needs is that tab's. A guest tab booting play of its own would
795
+ * overwrite it — the report would then name a real step of a real boot that
796
+ * is not the one this command is waiting on. `run` is carried so a reader can
797
+ * see that, and the alternative (a per-tab table keyed on an id the POST
798
+ * fallback self-reports) buys nothing until a second local tab can be a
799
+ * command target.
800
+ */
801
+ function handlePlayPhase(payload: Record<string, unknown>): ControlOutcome {
802
+ const stale = rejectStalePostedControl(payload);
803
+ if (stale) return stale;
804
+ const phase = payload['phase'];
805
+ if (phase !== null && typeof phase !== 'string') {
806
+ return { status: 400, error: 'play-phase requires { phase: string | null }.' };
807
+ }
808
+ const changed = ctx.livePlayPhase?.phase !== phase;
809
+ ctx.livePlayPhase = {
810
+ phase,
811
+ at: typeof payload['at'] === 'number' ? payload['at'] : 0,
812
+ run: typeof payload['run'] === 'number' ? payload['run'] : 0,
813
+ // The SERVER's clock, because it is the only one both parties share.
814
+ receivedAt: Date.now(),
815
+ };
816
+ // The durable half: a reader of the journal sees what the page said it
817
+ // was entering, whether or not anything timed out inside it — once per
818
+ // change, since the heartbeat road may have carried the same word.
819
+ if (changed) journalEvent({ kind: 'page-phase', phase });
820
+ return CONTROL_OK;
821
+ }
822
+
823
+ /**
824
+ * Console errors and warnings, reported as OCCURRENCE DELTAS by the page's
825
+ * `console-sync.ts`. The server owns the set from here on: the page's own
826
+ * ring buffer dies with the tab, and this ledger does not.
827
+ */
828
+ function handleConsoleEntries(payload: Record<string, unknown>): ControlOutcome {
829
+ const stale = rejectStalePostedControl(payload);
830
+ if (stale) return stale;
831
+ const clientId = typeof payload['_clientId'] === 'string' ? payload['_clientId'] : null;
832
+ if (clientId === null) return { status: 400, error: 'console-entries requires a client id.' };
833
+ const reported = payload['entries'];
834
+ if (!Array.isArray(reported)) {
835
+ return { status: 400, error: 'console-entries requires { entries: [...] }.' };
836
+ }
837
+ // Any report at all — including the EMPTY announcement a page sends when it
838
+ // installs its reporter — is the proof this load's console reaches here.
839
+ noteConsoleReporter(clientId);
840
+ for (const raw of reported) {
841
+ if (raw === null || typeof raw !== 'object') continue;
842
+ const entry = raw as Record<string, unknown>;
843
+ const severity = entry['severity'];
844
+ const message = entry['message'];
845
+ if (severity !== 'error' && severity !== 'warn') continue;
846
+ if (typeof message !== 'string' || message.trim() === '') continue;
847
+ consoleLedger.observe({
848
+ severity,
849
+ message,
850
+ source: typeof entry['source'] === 'string' ? entry['source'] : null,
851
+ occurrences: typeof entry['occurrences'] === 'number' ? entry['occurrences'] : 1,
852
+ loadId: clientId,
853
+ });
854
+ }
855
+ return CONTROL_OK;
856
+ }
857
+
858
+ /**
859
+ * Clearing rule (c) of the unresolved-console ledger: the code that RAISED a
860
+ * condition reporting that it is gone (`console-ledger.ts` documents why the
861
+ * page-load rule cannot cover this — a play remount is not a page load, so
862
+ * `[play-mode] Restart required` outlived the very verb named to fix it).
863
+ *
864
+ * Deliberately no client-id fence: unlike `console-entries`, this claims
865
+ * nothing about WHO saw what — it names conditions by their own text, and the
866
+ * ledger refuses to retire an acked one.
867
+ */
868
+ function handleConsoleResolved(payload: Record<string, unknown>): ControlOutcome {
869
+ const stale = rejectStalePostedControl(payload);
870
+ if (stale) return stale;
871
+ const reported = payload['conditions'];
872
+ if (!Array.isArray(reported)) {
873
+ return { status: 400, error: 'console-resolved requires { conditions: [...] }.' };
874
+ }
875
+ const by = typeof payload['by'] === 'string' && payload['by'] ? payload['by'] : 'editor';
876
+ const conditions: { severity: 'error' | 'warn'; message: string }[] = [];
877
+ for (const raw of reported) {
878
+ if (raw === null || typeof raw !== 'object') continue;
879
+ const condition = raw as Record<string, unknown>;
880
+ const severity = condition['severity'];
881
+ const message = condition['message'];
882
+ if (severity !== 'error' && severity !== 'warn') continue;
883
+ if (typeof message !== 'string' || message.trim() === '') continue;
884
+ conditions.push({ severity, message });
885
+ }
886
+ consoleLedger.resolve(conditions, by);
887
+ return CONTROL_OK;
888
+ }
889
+
890
+ /**
891
+ * A PAGE ERROR, captured by `index.html`'s inline bootstrap.
892
+ *
893
+ * The doctrine gap this closes: every other door onto a tab's errors runs
894
+ * INSIDE the module graph (`editor-console.ts`'s capture, read back by
895
+ * `command-listener.ts`'s `collectPageErrors`), so the one failure that most
896
+ * needs explaining — a boot that dies before the listener attaches — was the
897
+ * one the product could not describe. It could say `commandListener: not
898
+ * attached` and never why. These errors arrive on the control connection the
899
+ * bootstrap already holds, are journaled, and are read back through
900
+ * `/__editor/state` (a plain GET), so `vgai status` answers with no listener
901
+ * and no tab cooperation beyond the socket.
902
+ */
903
+ function handlePageError(payload: Record<string, unknown>): ControlOutcome {
904
+ const stale = rejectStalePostedControl(payload);
905
+ if (stale) return stale;
906
+ const clientId = typeof payload['_clientId'] === 'string' ? payload['_clientId'] : null;
907
+ const message = typeof payload['message'] === 'string' ? payload['message'].trim() : '';
908
+ if (clientId === null) return { status: 400, error: 'page-error requires a client id.' };
909
+ if (message === '') return { status: 400, error: 'page-error requires { message: string }.' };
910
+ const capped = message.slice(0, PAGE_ERROR_MAX_CHARS);
911
+ if (!pageErrorsByClient.has(clientId) && pageErrorsByClient.size >= PAGE_ERROR_CLIENTS_MAX) {
912
+ return { status: 400, error: 'page-error: too many reporting clients.' };
913
+ }
914
+ const existing = pageErrorsByClient.get(clientId) ?? [];
915
+ // Bounded, and the FIRST errors are the ones kept: a boot failure cascades
916
+ // (one bad module, then every consumer of it), and the first line is the
917
+ // cause while the tail is the echo.
918
+ if (existing.length < PAGE_ERRORS_PER_CLIENT && !existing.includes(capped)) {
919
+ pageErrorsByClient.set(clientId, [...existing, capped]);
920
+ journalEvent({
921
+ kind: 'page-error',
922
+ tabId8: short(tabIdForClient(clientId)),
923
+ message: capped,
924
+ });
925
+ }
926
+ return CONTROL_OK;
927
+ }
928
+
929
+ function handleCommandResult(payload: Record<string, unknown>): ControlOutcome {
930
+ const stale = rejectStalePostedControl(payload);
931
+ if (stale) return stale;
932
+ const body = payload as {
933
+ _requestId: string;
934
+ ok?: unknown;
935
+ error?: string;
936
+ data?: Record<string, unknown>;
937
+ _clientId?: string;
938
+ _awaitCallerReceipt?: boolean;
939
+ };
940
+ const pending = pendingCommands.get(body._requestId);
941
+ if (pending) {
942
+ // Only when the reporter NAMES itself. Over the socket the server fills
943
+ // `_clientId` in from the connection, so a tab there cannot claim to be
944
+ // another one; over the POST route a client that declares nothing (the
945
+ // share tunnel's bridge, a curl, a test harness) has always been
946
+ // trusted, and now that every connection carries a server-minted id it
947
+ // would otherwise be rejected for not knowing a name it never chose.
948
+ if (
949
+ pending.controllerClientId &&
950
+ typeof body._clientId === 'string' &&
951
+ pending.controllerClientId !== body._clientId
952
+ ) {
953
+ return { status: 403, error: 'Command result came from a different editor tab.' };
954
+ }
955
+ const receiptClientId = pending.controllerClientId;
956
+ const callerReceipt =
957
+ body._awaitCallerReceipt === true && receiptClientId
958
+ ? () => {
959
+ sendToClientHandle(receiptClientId, COMMAND_RESULT_RECEIPT_EVENT, body._requestId);
960
+ }
961
+ : undefined;
962
+ // THE PARSE BOUNDARY for the relay's answer shape. `CommandResult.ok` is
963
+ // required because the tab always writes it — but this payload arrives
964
+ // over HTTP/socket from off the wire, so the guarantee is CHECKED here
965
+ // rather than assumed by the type (and rather than making `ok` optional
966
+ // for every reader downstream, which is how the two declarations of this
967
+ // shape drifted apart in the first place). The relay's own envelope keys
968
+ // (`_requestId`, `_clientId`, `_awaitCallerReceipt`) are addressing, not
969
+ // answer, and stop here.
970
+ settlePendingCommand(
971
+ body._requestId,
972
+ {
973
+ ok: body.ok === true,
974
+ ...(body.error !== undefined ? { error: body.error } : {}),
975
+ ...(body.data !== undefined ? { data: body.data } : {}),
976
+ },
977
+ callerReceipt,
978
+ );
979
+ }
980
+ return CONTROL_OK;
981
+ }
982
+
983
+ function handleEditorState(reported: Record<string, unknown>): ControlOutcome {
984
+ const stale = rejectStalePostedControl(reported);
985
+ if (stale) return stale;
986
+ const clientId = typeof reported['_clientId'] === 'string' ? reported['_clientId'] : null;
987
+ const {
988
+ _clientId: _ignoredClientId,
989
+ _controlLifecycle: _ignoredControlLifecycle,
990
+ _statePatch: statePatch,
991
+ ...state
992
+ } = reported;
993
+ const previous = clientId ? editorStatesByClient.get(clientId)?.state : ctx.editorState;
994
+ if (statePatch === true && !previous) {
995
+ return {
996
+ status: 409,
997
+ error: 'Editor state patch arrived before this tab reported a full state snapshot.',
998
+ };
999
+ }
1000
+ const nextState = statePatch === true ? { ...previous, ...state } : state;
1001
+ const updatedAt = Date.now();
1002
+ ctx.editorState = nextState;
1003
+ ctx.editorStateUpdatedAt = updatedAt;
1004
+ if (clientId) {
1005
+ editorStatesByClient.set(clientId, { state: nextState, updatedAt });
1006
+ const pageErrors = nextState['pageErrors'];
1007
+ const presence = nextState['presence'];
1008
+ const presenceRecord =
1009
+ presence && typeof presence === 'object' ? (presence as Record<string, unknown>) : null;
1010
+ updateClientControlHealth(clientId, {
1011
+ errorState: Array.isArray(pageErrors)
1012
+ ? pageErrors.length === 0
1013
+ ? 'healthy'
1014
+ : 'errored'
1015
+ : 'unknown',
1016
+ visible: presenceRecord?.['visibility'] === 'visible',
1017
+ focused: presenceRecord?.['focused'] === true,
1018
+ updatedAt,
1019
+ });
1020
+ }
1021
+ return CONTROL_OK;
1022
+ }
1023
+
1024
+ function handleTabRoute(
1025
+ body: Record<string, unknown>,
1026
+ trusted: ReturnType<typeof trustedShareIdentity>,
1027
+ ): ControlOutcome {
1028
+ const stale = rejectStalePostedControl(body);
1029
+ if (stale) return stale;
1030
+ const route = body['route'];
1031
+ const claimedParticipantId = body['participantId'];
1032
+ if (
1033
+ trusted &&
1034
+ typeof claimedParticipantId === 'string' &&
1035
+ trusted.participantId !== claimedParticipantId
1036
+ ) {
1037
+ return { status: 403, error: 'A shared tab can only route its own participant.' };
1038
+ }
1039
+ const clientId = body['clientId'];
1040
+ if (
1041
+ typeof clientId !== 'string' ||
1042
+ (route !== 'project' && route !== 'no-project' && route !== 'unknown')
1043
+ ) {
1044
+ return { status: 400, error: 'tab/route requires { clientId: string, route }' };
1045
+ }
1046
+ const participantId = trusted?.participantId ?? claimedParticipantId;
1047
+ const lifecycle =
1048
+ clientTabLifecycles.get(clientId) ??
1049
+ (typeof participantId === 'string'
1050
+ ? participantTabLifecycles.get(participantId)
1051
+ : undefined) ??
1052
+ (trusted ? undefined : hostTabLifecycle);
1053
+ const tabId = tabIdForClient(clientId);
1054
+ if (tabId !== null) lifecycle?.onTabRoute(tabId, route);
1055
+ return CONTROL_OK;
1056
+ }
1057
+
1058
+ // ---- The event stream, shared by BOTH transports ----
1059
+ //
1060
+ // `/__editor/events` answers a plain GET as SSE and a WebSocket upgrade as
1061
+ // a duplex control socket. Everything below the transport — share identity,
1062
+ // collaboration join/resume, the client pool, the tab bijection, the
1063
+ // disconnect settlement — is ONE implementation, so the two transports can
1064
+ // never drift into two behaviors.
1065
+
1066
+ interface EventStreamIdentity {
1067
+ readonly participantId: string | undefined;
1068
+ readonly clientId: string | undefined;
1069
+ /** The page's persistent TAB identity (sessionStorage; survives reloads). */
1070
+ readonly tabId: string | undefined;
1071
+ /** The page-load epoch shared with the heartbeat worker. */
1072
+ readonly pageGeneration: string | undefined;
1073
+ readonly displayName: string | undefined;
1074
+ /**
1075
+ * WHAT KIND OF PAGE this connection belongs to ({@link TabSurface}).
1076
+ *
1077
+ * Two readings, and the connection is where both are available: the page
1078
+ * DECLARES it (the tab bootstrap passes through what the frame wrote on
1079
+ * the script's url), and the server OBSERVES it (a control connection
1080
+ * whose `Origin` is the desktop frame's can only be a VS Code window).
1081
+ * The observation is what covers a frame that never ran the bootstrap at
1082
+ * all — the case that produced "SOMETHING IS OFF" with nothing to name.
1083
+ */
1084
+ readonly surface: TabSurface;
1085
+ readonly trusted: ReturnType<typeof trustedShareIdentity>;
1086
+ readonly lastEventId: string | undefined;
1087
+ }
1088
+
1089
+ /** Identity check that must answer BEFORE either transport writes a byte. */
1090
+ function resolveEventStreamIdentity(source: {
1091
+ query: (name: string) => string | undefined;
1092
+ header: (name: string) => string | undefined;
1093
+ trusted: ReturnType<typeof trustedShareIdentity>;
1094
+ }): EventStreamIdentity | { error: string } {
1095
+ const claimedParticipantId = source.query('participantId');
1096
+ const trusted = source.trusted;
1097
+ if (trusted && claimedParticipantId && trusted.participantId !== claimedParticipantId) {
1098
+ return { error: 'The authenticated share participant does not match this event stream.' };
1099
+ }
1100
+ return {
1101
+ participantId: trusted?.participantId ?? claimedParticipantId,
1102
+ clientId: source.query('clientId'),
1103
+ tabId: source.query('tabId'),
1104
+ pageGeneration: source.query('pageGeneration'),
1105
+ displayName: source.query('displayName'),
1106
+ surface:
1107
+ source.query('surface') === 'vscode' || source.header('origin') === DESKTOP_FRAME_ORIGIN
1108
+ ? 'vscode'
1109
+ : 'editor',
1110
+ trusted,
1111
+ lastEventId: source.header('last-event-id'),
1112
+ };
1113
+ }
1114
+
1115
+ /** Pool the connection, replay collaboration, arm the bijection. Returns the
1116
+ * ONE teardown both transports run when their connection ends. */
1117
+ function openEventStream(
1118
+ identity: EventStreamIdentity,
1119
+ client: EditorEventClient,
1120
+ send: (event: string, data: string, id?: string) => void,
1121
+ ): (close?: { code: number; reason: string }) => void {
1122
+ const { participantId, trusted } = identity;
1123
+ const transport = isEditorSocketClient(client) ? ('ws' as const) : ('sse' as const);
1124
+ // EVERY connection belongs to a tab, and every connection is identified.
1125
+ //
1126
+ // A page that declares no `tabId` — the share tunnel's bridged stream, a
1127
+ // curl, a test client, anything older than the heartbeat — gets one
1128
+ // derived from its connection, and a connection that declares no
1129
+ // `clientId` gets a server-minted one. There is deliberately no
1130
+ // second class of citizen here: an anonymous connection used to be
1131
+ // invisible to the tab bijection and selectable by the relay only
1132
+ // through a special "legacy" fallback, which is two rules for one
1133
+ // question. Now `tabPresent`'s union rule covers it — present while it
1134
+ // holds a channel, because it can never beat.
1135
+ const clientId = identity.clientId ?? `conn-${randomUUID()}`;
1136
+ const tabId = identity.tabId ?? `tab-of-${clientId}`;
1137
+ const pageGeneration = identity.pageGeneration ?? `page-of-${clientId}`;
1138
+ const previousConnection = currentConnectionByClientId.get(clientId);
1139
+ if (previousConnection !== undefined && previousConnection !== client) {
1140
+ // A page reconnect is a successor generation, never a second controller.
1141
+ // End the predecessor before any command can select it from the pool.
1142
+ closeClient(previousConnection, 4000, 'superseded by a newer control connection');
1143
+ }
1144
+ const controlLifecycle: EditorControlLifecycle = {
1145
+ version: EDITOR_CONTROL_LIFECYCLE_VERSION,
1146
+ serverGeneration: processSessionId(),
1147
+ connectionGeneration: randomUUID(),
1148
+ clientId,
1149
+ tabId,
1150
+ pageGeneration,
1151
+ };
1152
+ controlLifecycleByConnection.set(client, controlLifecycle);
1153
+ controlLifecycleByClientId.set(clientId, controlLifecycle);
1154
+ currentConnectionByClientId.set(clientId, client);
1155
+ clientTabIds.set(clientId, tabId);
1156
+ if (participantId) {
1157
+ const collaboration = currentCollaboration();
1158
+ if (collaboration) {
1159
+ const leaveTimer = participantLeaveTimers.get(participantId);
1160
+ if (leaveTimer) {
1161
+ clearTimeout(leaveTimer);
1162
+ participantLeaveTimers.delete(participantId);
1163
+ }
1164
+ const localHost =
1165
+ trusted === null && participantId === ctx.localShareHost?.participantId
1166
+ ? ctx.localShareHost
1167
+ : null;
1168
+ if (
1169
+ !collaboration
1170
+ .snapshot()
1171
+ .participants.some((item) => item.participantId === participantId)
1172
+ ) {
1173
+ collaboration.join({
1174
+ participantId,
1175
+ account: trusted?.account ?? localHost?.account ?? null,
1176
+ displayName:
1177
+ trusted?.account.name ??
1178
+ trusted?.account.email ??
1179
+ localHost?.account.name ??
1180
+ localHost?.account.email ??
1181
+ identity.displayName?.slice(0, 80) ??
1182
+ 'Editor participant',
1183
+ kind: 'human',
1184
+ role: localHost ? 'maintainer' : defaultCollaborationRole(participantId, trusted?.role),
1185
+ ephemeralRole: trusted !== null,
1186
+ location: currentCollaborationLocation(),
1187
+ });
1188
+ }
1189
+ if (!trusted) localParticipantIds.add(participantId);
1190
+ participantConnections.set(
1191
+ participantId,
1192
+ (participantConnections.get(participantId) ?? 0) + 1,
1193
+ );
1194
+ const lastEventId = Number(identity.lastEventId);
1195
+ const resumed =
1196
+ Number.isSafeInteger(lastEventId) && lastEventId >= 0
1197
+ ? collaboration.resume(lastEventId)
1198
+ : null;
1199
+ if (!resumed || resumed.reset) {
1200
+ send('collaboration-snapshot', JSON.stringify(collaboration.snapshot()));
1201
+ } else {
1202
+ for (const event of resumed.events) {
1203
+ send(`collaboration-${event.channel}`, JSON.stringify(event), String(event.sequence));
1204
+ }
1205
+ // The replay above is a gap BY CONSTRUCTION: presence and
1206
+ // participant patches bump the sequence without joining the durable
1207
+ // event log, so a resumed client that only replayed events would sit
1208
+ // on stale cursors and stale participant rows for the rest of the
1209
+ // connection. The snapshot is a whole-state replacement on the client
1210
+ // (`collaboration-client.ts` publish()), so sending it AFTER the
1211
+ // replay repairs the gap without disturbing event ordering.
1212
+ send('collaboration-snapshot', JSON.stringify(collaboration.snapshot()));
1213
+ }
1214
+ }
1215
+ }
1216
+ addClient(client, clientId, participantId);
1217
+ // Every transport gets the lifecycle. `control-duplex` remains the
1218
+ // separate native-socket capability grant; a tunnelled/SSE page uses this
1219
+ // same generation on its POST fallback.
1220
+ send('control-lifecycle', JSON.stringify(controlLifecycle));
1221
+ journalEvent({
1222
+ kind: 'client-connected',
1223
+ clientId8: short(clientId),
1224
+ transport,
1225
+ participant: participantId ? short(participantId) : null,
1226
+ });
1227
+ // Tab bijection: a connection is a HINT that its tab has a channel. It
1228
+ // never blesses anything by itself — the reconciler does, from the table.
1229
+ const clientTabLifecycle = participantId
1230
+ ? tabLifecycleForParticipant(participantId, trusted === null)
1231
+ : hostTabLifecycle;
1232
+ clientTabLifecycles.set(clientId, clientTabLifecycle);
1233
+ clientTabLifecycle.onTabChannelOpen(tabId, identity.surface);
1234
+ return (close?: { code: number; reason: string }) => {
1235
+ const ownsCurrentGeneration = currentConnectionByClientId.get(clientId) === client;
1236
+ removeClient(client);
1237
+ controlLifecycleByConnection.delete(client);
1238
+ confirmedLifecycleConnections.delete(client);
1239
+ if (participantId) {
1240
+ const remaining = Math.max(0, (participantConnections.get(participantId) ?? 1) - 1);
1241
+ if (remaining === 0) {
1242
+ participantConnections.delete(participantId);
1243
+ const timer = setTimeout(() => {
1244
+ participantLeaveTimers.delete(participantId);
1245
+ if ((participantConnections.get(participantId) ?? 0) > 0) return;
1246
+ if (!trusted) localParticipantIds.delete(participantId);
1247
+ currentCollaboration()?.leave(participantId);
1248
+ }, 5_000);
1249
+ timer.unref?.();
1250
+ participantLeaveTimers.set(participantId, timer);
1251
+ } else {
1252
+ participantConnections.set(participantId, remaining);
1253
+ }
1254
+ }
1255
+ if (ownsCurrentGeneration) {
1256
+ currentConnectionByClientId.delete(clientId);
1257
+ controlLifecycleByClientId.delete(clientId);
1258
+ clientTabIds.delete(clientId);
1259
+ pageErrorsByClient.delete(clientId);
1260
+ }
1261
+ // A socket that ended with NO CLOSE FRAME (1006) is what a killed
1262
+ // renderer process leaves behind — an ordinary tab close sends one. Read
1263
+ // the tab's resource census BEFORE the reconcile below can sweep the
1264
+ // record, so the death line has something to say (2026-08-10: a game
1265
+ // tab's renderer died repeatedly and the record was `1006` and nothing).
1266
+ const deathProfile =
1267
+ close?.code === ABNORMAL_SOCKET_CLOSE
1268
+ ? (clientTabLifecycles.get(clientId)?.censusOf(tabId) ?? null)
1269
+ : null;
1270
+ // Only when the tab has NO channel left: a reload's new socket often
1271
+ // opens before the old one's close lands, and calling this on the
1272
+ // dying one would report a channel-down the tab never had.
1273
+ if (clientIdsForTab(tabId).length === 0) {
1274
+ clientTabLifecycle.onTabChannelClose(tabId);
1275
+ }
1276
+ if (ownsCurrentGeneration) clientTabLifecycles.delete(clientId);
1277
+ const commandsSettled = failCommandsOwnedBy(client);
1278
+ journalEvent({
1279
+ kind: 'client-disconnected',
1280
+ clientId8: short(clientId),
1281
+ transport,
1282
+ code: close?.code ?? null,
1283
+ reason: close?.reason ? close.reason.slice(0, 120) : null,
1284
+ commandsSettled,
1285
+ });
1286
+ if (close?.code === ABNORMAL_SOCKET_CLOSE) {
1287
+ journalEvent({
1288
+ kind: 'tab-death-profile',
1289
+ tabId8: short(tabId),
1290
+ code: close.code,
1291
+ censusAgeMs: deathProfile?.ageMs ?? null,
1292
+ census: deathProfile?.census ?? null,
1293
+ });
1294
+ }
1295
+ // Last tab gone with commands still in flight: a command nobody has
1296
+ // picked up cannot be answered (a reload mints a fresh page with no
1297
+ // memory of the requestId), so fail THOSE now with the real reason
1298
+ // instead of letting the timer expire into a misleading "editor
1299
+ // connected but did not respond". The classic trigger is a mid-command
1300
+ // full page reload — e.g. Vite discovering/re-optimizing deps on a first
1301
+ // play (dogfooded 2026-07-12: every first `vgai play` on a cold cache
1302
+ // burned the full 120s window).
1303
+ //
1304
+ // A RECEIPTED command survives this: see
1305
+ // `failUnreceiptedPendingCommands` for the socket blip that is not a
1306
+ // death, and the measurement behind it.
1307
+ if (clientCount() === 0 && pendingCommands.size > 0) {
1308
+ const settled = failUnreceiptedPendingCommands(
1309
+ 'Editor page disconnected while the command was pending — usually a mid-command page ' +
1310
+ 'reload (e.g. Vite dep re-optimization on a cold cache) or a closed tab. ' +
1311
+ 'Reload the editor tab and retry the command.',
1312
+ );
1313
+ if (settled > 0) {
1314
+ journalEvent({ kind: 'command-swept-on-last-tab-gone', settled });
1315
+ }
1316
+ }
1317
+ };
1318
+ }
1319
+
1320
+ router.get('/__editor/events', (req: Request, res: Response) => {
1321
+ const identity = resolveEventStreamIdentity({
1322
+ query: (name) => (typeof req.query[name] === 'string' ? req.query[name] : undefined),
1323
+ header: (name) => req.header(name),
1324
+ trusted: trustedShareIdentity(req),
1325
+ });
1326
+ if ('error' in identity) {
1327
+ res.status(403).end(identity.error);
1328
+ return;
1329
+ }
1330
+ res.writeHead(200, {
1331
+ 'Content-Type': 'text/event-stream',
1332
+ 'Cache-Control': 'no-cache',
1333
+ Connection: 'keep-alive',
1334
+ });
1335
+ res.write(':ok\n\n');
1336
+ const dispose = openEventStream(identity, res, (event, data, id) => {
1337
+ res.write(`${id === undefined ? '' : `id: ${id}\n`}event: ${event}\ndata: ${data}\n\n`);
1338
+ });
1339
+ req.on('close', dispose);
1340
+ });
1341
+
1342
+ // ---- Duplex control socket (same path, same vocabulary) ----
1343
+ //
1344
+ // Upstream control frames land on the SAME handlers the POST routes call,
1345
+ // so the two transports cannot answer differently. The POST routes stay:
1346
+ // the share tunnel bridges through the SSE form and keeps POSTing.
1347
+ const controlSocket: EditorControlSocketServer = createEditorControlSocket({
1348
+ // The heartbeat rides this same upgrade listener — see `RawSocketRoute`.
1349
+ extraRoutes: [heartbeat],
1350
+ authorize: (request, url) => {
1351
+ // S3, and it MATTERS more here than on the GET: a WebSocket handshake
1352
+ // is not subject to CORS, so without this check any page on the web
1353
+ // could open this socket against a developer's loopback editor and
1354
+ // both read the event stream and push control frames. `EventSource`
1355
+ // on the same path is refused by the browser's own CORS, which is why
1356
+ // the GET form does not need it. Same predicate as the mutating-route
1357
+ // gate above, including its VGAI_EDITOR_HOST escape.
1358
+ const host = process.env['VGAI_EDITOR_HOST'];
1359
+ if (!isAllowedEditorOrigin(headerValue(request, 'origin'), host ? [host] : [])) {
1360
+ return 'Cross-origin request rejected.';
1361
+ }
1362
+ const shareRequest = {
1363
+ method: 'GET',
1364
+ originalUrl: request.url ?? '/',
1365
+ header: (name: string) => headerValue(request, name),
1366
+ };
1367
+ let trusted: ReturnType<typeof trustedShareIdentity>;
1368
+ try {
1369
+ trusted = trustedShareIdentity(shareRequest);
1370
+ } catch (error) {
1371
+ return error instanceof Error ? error.message : String(error);
1372
+ }
1373
+ const claimed = url.searchParams.get('participantId');
1374
+ if (trusted && claimed && trusted.participantId !== claimed) {
1375
+ return 'The authenticated share participant does not match this event stream.';
1376
+ }
1377
+ return null;
1378
+ },
1379
+ open: (connection) => {
1380
+ const identity = resolveEventStreamIdentity({
1381
+ query: (name) => connection.url.searchParams.get(name) ?? undefined,
1382
+ header: (name) => headerValue(connection.request, name),
1383
+ trusted: trustedShareIdentity({
1384
+ method: 'GET',
1385
+ originalUrl: connection.request.url ?? '/',
1386
+ header: (name: string) => headerValue(connection.request, name),
1387
+ }),
1388
+ });
1389
+ if ('error' in identity) {
1390
+ connection.close(1008, identity.error);
1391
+ return () => {};
1392
+ }
1393
+ return openEventStream(identity, connection.client, connection.send);
1394
+ },
1395
+ duplexGranted: (connection) => {
1396
+ journalEvent({
1397
+ kind: 'duplex-granted',
1398
+ clientId8: short(clientIdForResponse(connection.client)),
1399
+ });
1400
+ },
1401
+ message: (type, payload, connection, lifecycle, lifecycleMalformed) => {
1402
+ if (!socketLifecycleMatches(connection.client, lifecycle, lifecycleMalformed)) {
1403
+ connection.close(1008, 'stale control lifecycle');
1404
+ return;
1405
+ }
1406
+ const clientId = clientIdForResponse(connection.client) ?? undefined;
1407
+ switch (type) {
1408
+ case 'command-received':
1409
+ handleCommandReceived({ ...payload, _clientId: clientId });
1410
+ return;
1411
+ case 'command-listener':
1412
+ // Same reason as `command-result` below: the socket KNOWS which page
1413
+ // spoke, so a tab cannot report a listener on another one's behalf.
1414
+ handleCommandListener({ ...payload, _clientId: clientId });
1415
+ return;
1416
+ case 'page-error':
1417
+ handlePageError({ ...payload, _clientId: clientId });
1418
+ return;
1419
+ case 'console-entries':
1420
+ // Same reason as `command-listener`: the socket KNOWS which page-load
1421
+ // spoke, and the ledger's clearing rule is fenced on that id.
1422
+ handleConsoleEntries({ ...payload, _clientId: clientId });
1423
+ return;
1424
+ case 'console-resolved':
1425
+ handleConsoleResolved(payload);
1426
+ return;
1427
+ case 'play-phase':
1428
+ handlePlayPhase({ ...payload, _clientId: clientId });
1429
+ return;
1430
+ case 'command-result':
1431
+ // The socket KNOWS which tab spoke, so the tab cannot claim to be
1432
+ // another one — stronger than the POST route's self-reported id.
1433
+ handleCommandResult({ ...payload, _clientId: clientId });
1434
+ return;
1435
+ case 'state':
1436
+ handleEditorState({ ...payload, _clientId: clientId });
1437
+ return;
1438
+ case 'tab-route':
1439
+ handleTabRoute(
1440
+ { ...payload, clientId: clientId ?? payload['clientId'] },
1441
+ trustedShareIdentity({
1442
+ method: 'POST',
1443
+ originalUrl: '/__editor/tab/route',
1444
+ header: (name: string) => headerValue(connection.request, name),
1445
+ }),
1446
+ );
1447
+ return;
1448
+ default:
1449
+ // An unknown control type is a client/server version skew, never a
1450
+ // silent partial read.
1451
+ console.warn(`[vgai-editor] ignoring unknown control frame "${type}"`);
1452
+ }
1453
+ },
1454
+ });
1455
+ router.attachControlSocket = (server: HttpServer) => controlSocket.attach(server);
1456
+ // ---- Editor command relay ----
1457
+ //
1458
+ // COMMANDS ARE ADDRESSED TO A TAB, NOT TO A SOCKET. A socket is a
1459
+ // per-page-load thing that comes and goes under an ordinary reload; the tab
1460
+ // is what the caller means. So the relay asks the TABLE which tab is
1461
+ // blessed, then looks up whatever channel that tab currently holds — and if
1462
+ // it holds none this instant, it WAITS inside the command's own budget
1463
+ // instead of answering "disconnected" about a tab that is provably here.
1464
+ //
1465
+ // That inversion is the fix for the 2026-08-09 defect: the old path asked
1466
+ // `sendToController`, which refused to select any tab that had not yet
1467
+ // POSTed an application-level state report, and then reported the refusal
1468
+ // as a disconnect. Reproduced headlessly at HEAD: a live, duplex-granted,
1469
+ // blessed tab, `editorsConnected: 1`, and `command-relayed clientId8:null`
1470
+ // followed 1ms later by "Editor disconnected before the command could be
1471
+ // delivered."
1472
+
1473
+ /** How often a held command re-attempts delivery to its tab. */
1474
+ const COMMAND_HOLD_RETRY_MS = 200;
1475
+
1476
+ const openGameplayRecordings = new Map<string, OpenGameplayRecording>();
1477
+ const MAX_RECORDING_CHUNK_BYTES = 8 * 1024 * 1024;
1478
+
1479
+ function openGameplayRecording(id: unknown): OpenGameplayRecording | null {
1480
+ return typeof id === 'string' && /^[0-9a-f-]{36}$/.test(id)
1481
+ ? (openGameplayRecordings.get(id) ?? null)
1482
+ : null;
1483
+ }
1484
+
1485
+ /**
1486
+ * Add the standing `commandListener` verdict to each tab row.
1487
+ *
1488
+ * A tab can hold more than one page-load at once (the overlap across a
1489
+ * reload), so the row takes the BEST of them: `'ready'` beats `'silent
1490
+ * since'` beats `'not attached'`. That is not optimism — the field's claim is
1491
+ * "can this tab take a command", and one page-load that can take one settles
1492
+ * it. Without this rule, every ordinary reload would flash `not attached`
1493
+ * from the incoming page while the outgoing one was still listening.
1494
+ *
1495
+ * A row whose tab is not in `present()` is left UNDECORATED rather than given
1496
+ * a default: an absent tab is something this cannot measure.
1497
+ */
1498
+ function decorateWithCommandListener(
1499
+ rows: readonly TabPresenceReport[],
1500
+ table: TabLifecycleController | null,
1501
+ now: number,
1502
+ ): Array<
1503
+ TabPresenceReport & {
1504
+ commandListener?: CommandListenerHealth;
1505
+ pageErrors?: string[];
1506
+ controlLifecycles?: PresentedControlLifecycle[];
1507
+ }
1508
+ > {
1509
+ const rank: Record<string, number> = { 'not attached': 0, ready: 2 };
1510
+ const presentTabs = table?.present() ?? [];
1511
+ const fullTabIds = new Map(presentTabs.map((tab) => [short(tab.tabId), tab.tabId]));
1512
+ const pageOwners = new Map<string, Set<string>>();
1513
+ for (const tab of presentTabs) {
1514
+ for (const epoch of tab.epochs) {
1515
+ const owners = pageOwners.get(epoch.epoch) ?? new Set<string>();
1516
+ owners.add(tab.tabId);
1517
+ pageOwners.set(epoch.epoch, owners);
1518
+ }
1519
+ }
1520
+ return rows.map((row) => {
1521
+ const tabId = fullTabIds.get(row.tabId8);
1522
+ if (tabId === undefined) return row;
1523
+ let best: CommandListenerHealth = 'not attached';
1524
+ const pageErrors: string[] = [];
1525
+ const controlLifecycles: PresentedControlLifecycle[] = [];
1526
+ for (const clientId of clientIdsForTab(tabId)) {
1527
+ const verdict = commandListenerHealth(commandListenerFactsFor(clientId), now);
1528
+ if ((rank[verdict] ?? 1) > (rank[best] ?? 1)) best = verdict;
1529
+ // Every live page-load of this tab: across a reload's overlap both are
1530
+ // real, and the outgoing page's error is often the reason the incoming
1531
+ // one exists.
1532
+ for (const message of pageErrorsByClient.get(clientId) ?? []) {
1533
+ if (!pageErrors.includes(message)) pageErrors.push(message);
1534
+ }
1535
+ const lifecycle = controlLifecycleByClientId.get(clientId);
1536
+ if (lifecycle) {
1537
+ const connection = currentConnectionByClientId.get(clientId);
1538
+ const owners = pageOwners.get(lifecycle.pageGeneration) ?? new Set<string>();
1539
+ const confirmed =
1540
+ connection !== undefined && confirmedLifecycleConnections.has(connection);
1541
+ const status: PresentedControlLifecycle['status'] = !confirmed
1542
+ ? 'unconfirmed'
1543
+ : owners.size > 1 || (owners.size === 1 && !owners.has(tabId))
1544
+ ? 'mismatch'
1545
+ : owners.has(tabId)
1546
+ ? 'aligned'
1547
+ : 'awaiting-heartbeat';
1548
+ controlLifecycles.push({
1549
+ status,
1550
+ serverGeneration8: short(lifecycle.serverGeneration),
1551
+ connectionGeneration8: short(lifecycle.connectionGeneration),
1552
+ pageGeneration8: short(lifecycle.pageGeneration),
1553
+ clientId8: short(lifecycle.clientId),
1554
+ });
1555
+ }
1556
+ }
1557
+ // `[]` is an honest answer here — the capture is installed by the inline
1558
+ // bootstrap, so a tab this function can see has one, and "none reported"
1559
+ // is a measurement. A tab the table cannot see is left UNDECORATED by
1560
+ // the early return above, never given a default.
1561
+ return { ...row, commandListener: best, pageErrors, controlLifecycles };
1562
+ });
1563
+ }
1564
+
1565
+ /** The lifecycle whose TABLE answers for this command's participant. */
1566
+ function tabTableFor(participantId: string | null): TabLifecycleController | null {
1567
+ if (participantId === '__vgai_missing_local_host__') return null;
1568
+ if (participantId !== null && participantId !== ctx.hostParticipantId) {
1569
+ return participantTabLifecycles.get(participantId) ?? null;
1570
+ }
1571
+ return hostTabLifecycle;
1572
+ }
1573
+
1574
+ /**
1575
+ * The tab's page-loads in the order a command should try them: the page
1576
+ * whose control generation the tab's own heartbeat confirms first
1577
+ * (`aligned`), then any page still beating, then the rest. A same-tab
1578
+ * navigation leaves the OUTGOING page's connection registered for a while
1579
+ * beside the incoming one, and delivering to whichever the map listed
1580
+ * first sent every command to a page that was gone — measured 2026-09-05:
1581
+ * a tab with two lifecycles, the stale one first, timed out every relay
1582
+ * for minutes while its status said "commandListener ready".
1583
+ */
1584
+ function clientsInDeliveryOrder(tabId: string, table: TabLifecycleController | null): string[] {
1585
+ const tab = table?.tab(tabId);
1586
+ const liveEpochs = new Set((tab?.epochs ?? []).map((epoch) => epoch.epoch));
1587
+ const rank = (clientId: string): number => {
1588
+ const lifecycle = controlLifecycleByClientId.get(clientId);
1589
+ if (lifecycle === undefined) return 2;
1590
+ return liveEpochs.has(lifecycle.pageGeneration) ? 0 : 1;
1591
+ };
1592
+ return [...clientIdsForTab(tabId)].sort((a, b) => rank(a) - rank(b));
1593
+ }
1594
+
1595
+ /**
1596
+ * Deliver to the tab's current channel, or HOLD.
1597
+ *
1598
+ * Holding is bounded by the command's own timer — nothing new is invented
1599
+ * here, the wait simply happens on the server instead of being converted
1600
+ * into a false refusal the caller then retries five times.
1601
+ */
1602
+ function deliverToTab(requestId: string, type: unknown, participantId: string | null): void {
1603
+ const pending = pendingCommands.get(requestId);
1604
+ if (pending === undefined) return;
1605
+ const tabId = pending.tabId;
1606
+ if (tabId === undefined) return;
1607
+ const table = tabTableFor(participantId);
1608
+
1609
+ for (const clientId of clientsInDeliveryOrder(tabId, table)) {
1610
+ const client = sendToClientHandle(clientId, 'editor-command', pending.command);
1611
+ if (client === null) continue;
1612
+ pending.controller = client;
1613
+ pending.controllerClientId = clientId;
1614
+ noteCommandRelay(clientId);
1615
+ journalEvent({
1616
+ kind: 'command-relayed',
1617
+ command: String(type ?? 'unknown'),
1618
+ requestId8: short(requestId),
1619
+ tabId8: short(tabId),
1620
+ clientId8: short(clientId),
1621
+ });
1622
+ armDeliveryReceiptWindow(requestId, type);
1623
+ return;
1624
+ }
1625
+
1626
+ // No channel right now. Is the TAB still here? The reconciler removes a
1627
+ // tab from the table only once it is absent past its grace — so a missing
1628
+ // record is the one honest occasion for `CONTROLLER_DISCONNECTED_MESSAGE`,
1629
+ // and the CLI's retry contract keeps meaning what it says.
1630
+ if (table !== null && table.tab(tabId) === undefined) {
1631
+ settlePendingCommand(requestId, {
1632
+ ok: false,
1633
+ timedOut: true,
1634
+ error: CONTROLLER_DISCONNECTED_MESSAGE,
1635
+ });
1636
+ return;
1637
+ }
1638
+ if (!pending.heldJournaled) {
1639
+ pending.heldJournaled = true;
1640
+ journalEvent({
1641
+ kind: 'command-held',
1642
+ requestId8: short(requestId),
1643
+ tabId8: short(tabId),
1644
+ reason: 'no-channel',
1645
+ });
1646
+ }
1647
+ pending.holdTimer = setTimeout(
1648
+ () => deliverToTab(requestId, type, participantId),
1649
+ COMMAND_HOLD_RETRY_MS,
1650
+ );
1651
+ pending.holdTimer.unref?.();
1652
+ }
1653
+
1654
+ function relayCommand(
1655
+ body: Record<string, unknown>,
1656
+ requestedParticipantId?: string,
1657
+ ): Promise<SettledCommandResult> {
1658
+ const participantId =
1659
+ requestedParticipantId ??
1660
+ ctx.hostParticipantId ??
1661
+ (participantConnections.size > 0 ? '__vgai_missing_local_host__' : null);
1662
+ const table = tabTableFor(participantId);
1663
+ const targetTabId = table?.blessedTabId() ?? null;
1664
+ // Fail fast when the TABLE says no tab is present — and say so in table
1665
+ // facts. A caller can act on "no tab has been present for 9s" plus the URL
1666
+ // to open; it could never act on "disconnected" about a tab that was there.
1667
+ if (targetTabId === null) {
1668
+ // A tab can be PRESENT and still unblessable: its worker beats, its
1669
+ // page is dead. Refusing that with "no tab is present" would send the
1670
+ // reader looking for a window that is sitting right in front of them.
1671
+ const zombie = table?.unresponsive()[0];
1672
+ if (zombie !== undefined) {
1673
+ return Promise.resolve({
1674
+ result: {
1675
+ ok: false,
1676
+ timedOut: true,
1677
+ error: tabUnresponsiveMessage(zombie, Date.now()),
1678
+ },
1679
+ });
1680
+ }
1681
+ const absence = table === null ? null : tabAbsenceMessage(table.state(), Date.now());
1682
+ return Promise.resolve({
1683
+ result: {
1684
+ ok: false,
1685
+ timedOut: true,
1686
+ error:
1687
+ participantId === '__vgai_missing_local_host__'
1688
+ ? 'No local editor tab is connected; refusing to route this command to a remote participant.'
1689
+ : // A participant id only helps a caller who NAMED one. The local
1690
+ // session's own host participant is an internal identity — once
1691
+ // its tab goes away, naming it says nothing the caller can act
1692
+ // on, while `noEditorConnectedMessage` names the URL to open and,
1693
+ // for a `--no-open`/`VGAI_NO_OPEN` session, why no tab appeared
1694
+ // by itself.
1695
+ participantId !== null && participantId !== ctx.hostParticipantId
1696
+ ? `No editor tab is connected for participant ${participantId}.`
1697
+ : `${absence === null ? 'No editor tab is present' : absence}. ${noEditorConnectedMessage(options.tabBijection)}`,
1698
+ },
1699
+ });
1700
+ }
1701
+
1702
+ // Assign a request ID so the browser can report the result back
1703
+ const requestId = randomUUID();
1704
+ const commandWithId = { ...body, _requestId: requestId };
1705
+
1706
+ const timeoutMs = commandTimeoutMs(body['type']);
1707
+ const hasSeparateDeliveryBudget = commandAckDeadlineMs(body['type']) !== null;
1708
+ const resultPromise = new Promise<SettledCommandResult>((resolve) => {
1709
+ const expire = (): void => {
1710
+ const tab = table?.tab(targetTabId);
1711
+ const pending = pendingCommands.get(requestId);
1712
+ const expiredAt = Date.now();
1713
+ const base =
1714
+ tab === undefined
1715
+ ? 'Command timed out — editor connected but did not respond.'
1716
+ : `Command timed out — ${tabWaitingMessage(
1717
+ tab,
1718
+ expiredAt,
1719
+ Math.max(0, tab.epochCount - (pending?.epochAtRelay ?? tab.epochCount)),
1720
+ )} and did not respond.`;
1721
+ // NAME THE PHASE. The sentence above is true and says nothing a reader
1722
+ // can act on: measured at N=20000, `play`, `screenshot` and `stop` all
1723
+ // expired with it while the page sat inside one 199.5s block and its
1724
+ // WORKER heartbeat kept the tab looking healthy. See `play-stall.ts`.
1725
+ const stall = playStallDiagnosis({
1726
+ command: body['type'],
1727
+ base,
1728
+ phase: ctx.livePlayPhase,
1729
+ now: expiredAt,
1730
+ });
1731
+ journalEvent({
1732
+ kind: 'play-stall',
1733
+ command: typeof body['type'] === 'string' ? body['type'] : 'unknown',
1734
+ requestId8: short(requestId),
1735
+ phase: stall.phase,
1736
+ phaseAgeMs: stall.phaseAgeMs,
1737
+ waitedMs: timeoutMs,
1738
+ });
1739
+ // …and into the console ledger, so `vgai status` explains the stuck
1740
+ // play instead of printing a healthy tab. Keyed on the PHASE, never on
1741
+ // the duration, so a repeating stall stays one condition with a count.
1742
+ consoleLedger.observe({
1743
+ severity: 'error',
1744
+ source: 'play-stall',
1745
+ message: playStallConsoleMessage(body['type'], stall.phase),
1746
+ loadId: pending?.controllerClientId ?? targetTabId,
1747
+ });
1748
+ settlePendingCommand(requestId, {
1749
+ ok: false,
1750
+ timedOut: true,
1751
+ error: stall.message,
1752
+ });
1753
+ };
1754
+ const timer = setTimeout(expire, timeoutMs);
1755
+ pendingCommands.set(requestId, {
1756
+ resolve,
1757
+ timer,
1758
+ tabId: targetTabId,
1759
+ command: commandWithId,
1760
+ epochAtRelay: table?.tab(targetTabId)?.epochCount ?? 0,
1761
+ ...(hasSeparateDeliveryBudget
1762
+ ? {
1763
+ restartWorkTimerAfterReceipt: () => {
1764
+ const pending = pendingCommands.get(requestId);
1765
+ if (pending === undefined) return;
1766
+ clearTimeout(pending.timer);
1767
+ pending.timer = setTimeout(expire, timeoutMs);
1768
+ },
1769
+ }
1770
+ : {}),
1771
+ });
1772
+ });
1773
+
1774
+ deliverToTab(requestId, body['type'], participantId);
1775
+
1776
+ return resultPromise;
1777
+ }
1778
+
1779
+ /** Relay helpers whose commands never defer editor presentation. */
1780
+ async function relayCommandResult(body: Record<string, unknown>): Promise<RelayedCommandResult> {
1781
+ const settled = await relayCommand(body);
1782
+ settled.callerReceipt?.();
1783
+ return settled.result;
1784
+ }
1785
+
1786
+ // MediaRecorder emits bounded binary chunks while the page is recording.
1787
+ // They stream straight into the project's ignored capture area instead of
1788
+ // riding the JSON command result, whose 50 MiB limit is intentionally too
1789
+
1790
+ return {
1791
+ handleCommandReceived,
1792
+ handleCommandListener,
1793
+ handleContributedCommands,
1794
+ handlePlayPhase,
1795
+ handleConsoleEntries,
1796
+ handleConsoleResolved,
1797
+ handlePageError,
1798
+ handleCommandResult,
1799
+ handleEditorState,
1800
+ handleTabRoute,
1801
+ decorateWithCommandListener,
1802
+ tabTableFor,
1803
+ clientsInDeliveryOrder,
1804
+ relayCommand,
1805
+ relayCommandResult,
1806
+ openGameplayRecording,
1807
+ openGameplayRecordings,
1808
+ close: () => controlSocket.close(),
1809
+ MAX_RECORDING_CHUNK_BYTES,
1810
+ };
1811
+ }