@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,1508 @@
1
+ /**
2
+ * THE TAB TABLE — what the server knows about the browser tabs that exist for
3
+ * this session, and the only thing any presence question is allowed to read.
4
+ *
5
+ * WHY THIS EXISTS (owner, 2026-08-09): "there exist tabs that may or may not
6
+ * be open; if one or more tabs is open, choose one of them to be the editor;
7
+ * if no tabs are open, auto-open one. Make sure the tabs do NOT get
8
+ * disconnected, and if they do, make it clear that it happened. You're
9
+ * relying on sockets to prove connection — that's highly unreliable. It
10
+ * should be heartbeats."
11
+ *
12
+ * The invariant: IF a tab for this session exists in a browser, the backend
13
+ * sees it — 100%.
14
+ *
15
+ * WHAT A SOCKET ACTUALLY PROVES. Almost nothing, in either direction. A
16
+ * socket can be OPEN to a tab that cannot run a line of JavaScript (cold
17
+ * module evaluation blocks the main thread for a minute), and a socket can
18
+ * CLOSE for a tab that never went anywhere (a reload, a laptop lid). The old
19
+ * model read both as facts about the tab: an open socket meant "here", a
20
+ * close meant "lost — reopening". Worse, it demanded a THIRD thing before it
21
+ * would route a command — an application-level state report — so a live,
22
+ * duplex-granted, blessed tab that had not yet finished loading its module
23
+ * graph was invisible to the relay and its commands came back "Editor
24
+ * disconnected before the command could be delivered." Measured 2026-08-09
25
+ * and reproduced headlessly: `editorsConnected: 1, connected: true`, no state
26
+ * report, five refused plays.
27
+ *
28
+ * SO PRESENCE IS A BEAT. A dedicated Worker in the page beats every second
29
+ * over its OWN socket. It keeps beating while the page's main thread is
30
+ * blocked, its socket is not the page's socket, and it dies exactly when the
31
+ * tab dies — which is the whole list of properties presence needs and a page
32
+ * socket has none of.
33
+ *
34
+ * IDENTITY. `tabId` lives in `sessionStorage`, which is scoped to one tab and
35
+ * survives its reloads, so a reload is the SAME tab with a new `epoch`. The
36
+ * one thing sessionStorage does not survive correctly is "Duplicate Tab",
37
+ * which copies it — two epochs beating at once under one tabId, detected here
38
+ * and answered with a re-mint.
39
+ *
40
+ * PURE ON PURPOSE. Every decision below is a function of (state, input, now)
41
+ * with the clock injected, so the tests never wait: `tab-lifecycle.test.ts`
42
+ * drives real gaps, reloads and departures in microseconds — against these
43
+ * functions directly as well as through the shell. The stateful shell that
44
+ * wires this to sockets, the opener and a tick lives in `tab-lifecycle.ts`.
45
+ */
46
+
47
+ import type { SessionJournalEvent } from './support/project/session-journal';
48
+ import type { TabCensus } from '@volter/editor-sdk/project/tab-census';
49
+
50
+ /** What the page last told its worker about itself. */
51
+ export type TabVisibility = 'visible' | 'hidden';
52
+
53
+ /**
54
+ * Which surface a tab is showing.
55
+ * - `'project'` — the editor, on the project this session serves.
56
+ * - `'no-project'` — the launcher/hub or the startup-failure surface: a real
57
+ * tab that is not on the project and can be adopted into it.
58
+ * - `'unknown'` — the page has not said yet (still booting). Read as "on
59
+ * its way to the project", which is what it is for every tab this session
60
+ * opened itself.
61
+ */
62
+ export type TabRoute = 'project' | 'no-project' | 'unknown';
63
+
64
+ /**
65
+ * WHAT KIND OF PAGE this tab is.
66
+ *
67
+ * - `'editor'` — the vgai editor's own page, which is every tab this session
68
+ * opens itself.
69
+ * - `'vscode'` — a Code-OSS workbench window running the editor through the
70
+ * frame (docs/CODE-OSS.md §Boot, DESKTOP). It is a tab like any other under
71
+ * the bijection; the only difference is that vgai does not author its HTML,
72
+ * so the things a page does for itself — minting an identity, beating,
73
+ * saying goodbye — arrive from a script the session serves it
74
+ * (`server/tab-bootstrap.ts`).
75
+ *
76
+ * Why the table carries it at all: without it, a VS Code window that had not
77
+ * yet been given that script was reported as an anomaly ("SOMETHING IS OFF")
78
+ * with no way to say WHICH bootstrap was missing, and a window that had been
79
+ * given it was indistinguishable from a browser tab. Both readings are now
80
+ * the truth, in one word.
81
+ *
82
+ * TWO SOURCES, one field, because each covers what the other cannot: the
83
+ * bootstrap DECLARES it (`?surface=` on the script's own url, which survives
84
+ * the web shape where the workbench and the session share an origin), and the
85
+ * server OBSERVES it (a control connection whose `Origin` is the desktop
86
+ * frame's, which needs no cooperation from a page that never got the script).
87
+ */
88
+ export type TabSurface = 'editor' | 'vscode';
89
+
90
+ /**
91
+ * THE RESOURCE CENSUS — what the tab was holding, sampled by the page and
92
+ * carried on the beat. Declared in `@vgai/sdk/tab-census` (the one package
93
+ * every unit that speaks this shape already depends on) and re-exported here,
94
+ * because this file is where the server's readers look for it.
95
+ *
96
+ * WHY IT EXISTS (measured 2026-08-10): a game tab's Chrome RENDERER PROCESS was
97
+ * killed repeatedly — WS close code 1006, no goodbye — while a 3840x2080 WebGL
98
+ * world with an HDR bloom chain was resident and painting. JS heap stayed flat
99
+ * at ~190 MB throughout, so the kill was GPU/compositor-side, at a per-process
100
+ * ceiling Chrome does not document and never announces. The tab table recovered
101
+ * perfectly (departed → auto-open) and recorded NOTHING about why.
102
+ *
103
+ * The class is agent-native: an agent authors lush effects with zero cost
104
+ * feedback, and the quality loop grades looks and never cost. So the tab now
105
+ * says what it is holding, on a channel that already exists, and a death gets
106
+ * a cause line instead of a shrug.
107
+ *
108
+ * MEASUREMENT ONLY. Nothing here enforces a budget — budgets are a later
109
+ * decision, once real profiles exist to set them from.
110
+ */
111
+ export type { TabCensus };
112
+
113
+ /**
114
+ * One census as the SERVER filed it: the profile plus the server's own clock.
115
+ *
116
+ * THE INVARIANT `at` DEPENDS ON: a census reaches the server only on the one
117
+ * beat that carries a FRESH page sample. The worker drops it after sending
118
+ * (`tab-heartbeat.ts`'s `body()`), so `at` means "the page sampled this, and
119
+ * it arrived", within the one beat (≤1s) between the two.
120
+ *
121
+ * That is load-bearing rather than tidy. The worker used to echo its cached
122
+ * profile on every beat and this stamp moved with the echo, so a hidden tab
123
+ * that had not sampled in half an hour still reported a half-second-old
124
+ * profile — and the age, whose whole job is to make a stale profile READ as
125
+ * stale, lied on the path that matters most: Chrome kills background tabs
126
+ * under memory pressure. The stamp is also the server's, never the page's;
127
+ * an age computed across two clocks is not an age.
128
+ */
129
+ export type TabCensusSample = TabCensus & { readonly at: number };
130
+
131
+ /** How many samples per tab the table keeps. Two: the last one, and the one
132
+ * before it — enough to see a resource CLIMB in the line that reports a death,
133
+ * and short enough that the table stays a table. */
134
+ export const TAB_CENSUS_HISTORY = 2;
135
+
136
+ /**
137
+ * THE PAGE SAYING GOODBYE — the one signal a page sends about its own end.
138
+ *
139
+ * WHY IT EXISTS (measured 2026-09-17): four different causes produced one
140
+ * symptom, "the battery stopped", and the product reported all four the same
141
+ * way. A renderer killed by a dev-server reload and a page whose main thread
142
+ * was stuck in a 46 MB encode are indistinguishable from the beats alone —
143
+ * both are silence. The difference is whether the page got a chance to say it
144
+ * was going: `pagehide` fires for a close, a navigation and a reload, and does
145
+ * NOT fire for a renderer kill or a wedged main thread. So a silence WITH a
146
+ * goodbye is `closed`/`reloading` and a silence WITHOUT one is `crashed`, and
147
+ * that is the whole discriminator.
148
+ *
149
+ * Sent with `navigator.sendBeacon` (index.html's inline bootstrap), which is
150
+ * the one transport the browser promises to deliver after the document is
151
+ * gone. `persisted` is `PageTransitionEvent.persisted`: the page went into the
152
+ * back/forward cache and may yet come back alive, which is a different sentence
153
+ * from "this page is over" and is carried rather than collapsed.
154
+ */
155
+ export interface TabCloseBeacon {
156
+ readonly tabId: string;
157
+ /** The page-load that is leaving — never assumed to be the current one. */
158
+ readonly epoch: string;
159
+ /** True when the document went into the back/forward cache. */
160
+ readonly persisted: boolean;
161
+ /**
162
+ * WHY the page is saying goodbye, and the only thing that makes this beacon
163
+ * an ACKNOWLEDGEMENT rather than a departure notice.
164
+ *
165
+ * `'pagehide'` — the document is unloading (index.html's bootstrap). The
166
+ * original and the default: a beacon that does not say is one of these.
167
+ *
168
+ * `'session-ended'` — the page received the session's `tab-close` and has
169
+ * already run every terminator behind `markSessionEnded`
170
+ * (`src/tab-lifecycle-client.ts`). Measured 2026-09-19/20: the session used
171
+ * to broadcast `tab-close`, sleep 150 ms blind and tear the server down,
172
+ * with nothing waiting for or recording whether any page heard it — and a
173
+ * page that did not hear it kept its Blender engine worker (one engine
174
+ * thread plus a 16-thread pool) running at 100%+ CPU for minutes after
175
+ * `vgai close`. This is the page's half of that fact; `tab-lifecycle.ts`'s
176
+ * `notifySessionEnded` is the half that waits for it.
177
+ */
178
+ readonly reason: 'pagehide' | 'session-ended';
179
+ }
180
+
181
+ /** One heartbeat, as it arrives from a tab's worker. */
182
+ export interface TabBeat {
183
+ readonly tabId: string;
184
+ readonly epoch: string;
185
+ readonly seq: number;
186
+ readonly visibility: TabVisibility;
187
+ /**
188
+ * A FRESH page sample, on the one beat that carries it — see
189
+ * {@link TabCensusSample}. Beats run at 1 Hz and the page samples at 0.2 Hz
190
+ * while visible and not at all while hidden, so most beats have no census
191
+ * and a hidden tab's beats have none for as long as it stays hidden. That
192
+ * silence is the honest signal, not a gap to paper over.
193
+ */
194
+ readonly census?: TabCensus;
195
+ /** What the page's main thread announced it was entering (`building
196
+ * src/models/x.ts`), or `null` when it left; present on the one beat
197
+ * that carries the change. */
198
+ readonly phase?: string | null;
199
+ }
200
+
201
+ /** One page-load of a tab, tracked only long enough to tell reload from duplicate. */
202
+ interface EpochRecord {
203
+ readonly epoch: string;
204
+ readonly firstSeenAt: number;
205
+ readonly lastBeatAt: number;
206
+ }
207
+
208
+ /** Everything the server knows about one tab. */
209
+ export interface TabRecord {
210
+ readonly tabId: string;
211
+ /** First time this tabId was seen at all (beat or connection). */
212
+ readonly firstSeenAt: number;
213
+ /** Newest beat from any of this tab's live epochs; 0 if it has never beaten. */
214
+ readonly lastBeatAt: number;
215
+ /** True once at least one beat has arrived — see `tabPresent`. */
216
+ readonly beatEver: boolean;
217
+ /** The primary (oldest live) epoch — this tab's current page load. */
218
+ readonly epoch: string | null;
219
+ readonly epochStartedAt: number;
220
+ /** How many distinct page loads this tab has had. A reload increments it. */
221
+ readonly epochCount: number;
222
+ readonly epochs: readonly EpochRecord[];
223
+ readonly visibility: TabVisibility;
224
+ readonly route: TabRoute;
225
+ /** What kind of page this tab is — see {@link TabSurface}. */
226
+ readonly surface: TabSurface;
227
+ /** Does this tab hold a live control (command) channel right now? */
228
+ readonly connected: boolean;
229
+ /**
230
+ * Has THIS page-load ever opened a command channel? Reset on every epoch.
231
+ *
232
+ * The zombie-page discriminator. A heartbeat proves the TAB exists; it says
233
+ * nothing about the document. A page whose main thread died after the
234
+ * inline bootstrap keeps beating (the worker is a separate thread) and can
235
+ * never open a channel or run a command — so a tab that has been beating
236
+ * without ever establishing one this epoch is not a candidate for blessing,
237
+ * while a tab that HAD one and is mid-reload obviously still is.
238
+ */
239
+ readonly channelThisEpoch: boolean;
240
+ /**
241
+ * Has THIS page-load reported an ATTACHED COMMAND LISTENER? Reset on every
242
+ * epoch, and the second half of the zombie discriminator above.
243
+ *
244
+ * A channel is not a document. `index.html`'s inline bootstrap opens the
245
+ * control connection BEFORE the module graph exists, precisely so a tab
246
+ * that is merely slow stays visible — which means a page whose module graph
247
+ * never evaluates at all opens a channel and then does nothing forever.
248
+ * That tab used to read as fully healthy here: blessed in the same
249
+ * millisecond its socket connected, `vgai edit` reporting a live session,
250
+ * and every command buffered in the bootstrap's replay queue with no
251
+ * consumer that would ever arrive. Measured 2026-08-13 in a session journal
252
+ * left behind by a real boot failure — `client-connected` and `tab-blessed`
253
+ * on the same millisecond, no `tab-appeared` (the heartbeat worker never
254
+ * started either), no listener, 77 seconds of nothing, then the tab closed.
255
+ *
256
+ * `connectCommandListener` reporting itself is the only evidence that the
257
+ * document is RUNNING, so it is what blessing is allowed to require —
258
+ * after {@link TabPresenceConfig.listenerBudgetMs}, never before.
259
+ */
260
+ readonly listenerThisEpoch: boolean;
261
+ /** Recent page-owned listener reports, including one whose first beat is still in flight. */
262
+ readonly listenerEpochs: readonly string[];
263
+ /** Has the unresponsive verdict already been journaled for this epoch? */
264
+ readonly unresponsiveNoticed: boolean;
265
+ /** When beats stopped arriving; null while they are current. */
266
+ readonly gapSince: number | null;
267
+ /** Has the open gap already been journaled? (One line per gap, not per tick.) */
268
+ readonly gapNoticed: boolean;
269
+ /**
270
+ * The last {@link TAB_CENSUS_HISTORY} DISTINCT resource profiles this tab
271
+ * reported, oldest first. Empty for a tab that has never sent one (an older
272
+ * page, a tab bridged through the share tunnel, a browser with no census
273
+ * yet). The newest is what a death line quotes; the one before it is what
274
+ * makes a climb visible.
275
+ */
276
+ readonly census: readonly TabCensusSample[];
277
+ /**
278
+ * The page-load that sent a {@link TabCloseBeacon}, or null when none has.
279
+ * See {@link tabState}: this is what separates `closed`/`reloading` from
280
+ * `crashed`, and it is a fact about an EPOCH, never about the tab — a reload
281
+ * closes one page-load and opens another under the same tabId.
282
+ */
283
+ readonly closedEpoch: string | null;
284
+ /** SERVER clock: when that beacon landed. Null with no beacon. */
285
+ readonly closedAt: number | null;
286
+ /** That beacon's `persisted` — the document went into the back/forward cache. */
287
+ readonly closedPersisted: boolean;
288
+ /** That beacon's {@link TabCloseBeacon.reason}; null with no beacon. It is
289
+ * what separates a page that ACKNOWLEDGED the session's end (`ended`) from
290
+ * one that merely went away (`closed`). */
291
+ readonly closedReason: 'pagehide' | 'session-ended' | null;
292
+ /**
293
+ * The length of the most recent CLOSED gap in this tab's beats, and when it
294
+ * closed. Two fields rather than one because the verdict needs both: a gap
295
+ * longer than the grace that a beat later CLOSED is a suspend (a lid, a
296
+ * sleep), not a death — and only while the return is recent enough to be
297
+ * what a reader is looking at. `gapSince`/`gapNoticed` above cannot answer
298
+ * this: `recordBeat` clears them the moment the gap ends, which is exactly
299
+ * when this question gets asked.
300
+ */
301
+ readonly lastGapMs: number | null;
302
+ readonly lastGapEndedAt: number | null;
303
+ /**
304
+ * When a relayed command last EXPIRED against this tab, or null when the
305
+ * last one answered.
306
+ *
307
+ * The second half of `hung`, and the half no page-side measurement can
308
+ * supply: a page whose main thread is inside a 46 MB encode samples no
309
+ * census and answers no command, and the census-age test below sees only a
310
+ * profile that stopped arriving. A command that went out and never came back
311
+ * while the beats stayed fresh is the server's OWN evidence for the same
312
+ * fact, and it is the one an operator already has in hand.
313
+ */
314
+ readonly commandTimeoutAt: number | null;
315
+ }
316
+
317
+ export interface TabPresenceConfig {
318
+ /** The worker's beat cadence. Everything else is expressed in multiples. */
319
+ readonly beatIntervalMs: number;
320
+ /** A visible tab is PRESENT while its newest beat is younger than this. */
321
+ readonly graceMs: number;
322
+ /**
323
+ * The same for a hidden tab. Chrome throttles background timers and may
324
+ * throttle workers, so cadence is not assumed: the grace is long, and the
325
+ * server actively probes over the heartbeat socket (message delivery is not
326
+ * throttled) — a probe answer is recorded as a beat, so a hidden tab that
327
+ * still answers stays present indefinitely.
328
+ */
329
+ readonly hiddenGraceMs: number;
330
+ /** A gap this long is worth a journal line (owner: make it clear). */
331
+ readonly gapNoticeMs: number;
332
+ /** Beats older than this stop probing and let the tab depart on grace. */
333
+ readonly probeAfterMs: number;
334
+ /**
335
+ * How long a page-load gets to open a command channel before the tab is
336
+ * called unresponsive. Generous: a cold editor boot blocks the main thread
337
+ * for 60-100s, but the CHANNEL is opened by index.html's inline bootstrap
338
+ * before any module loads, so a page that has not opened one in this long
339
+ * never will.
340
+ */
341
+ readonly channelBudgetMs: number;
342
+ /**
343
+ * How long a page-load gets to attach its COMMAND LISTENER before the tab
344
+ * is called unresponsive — the whole module graph, not just the inline
345
+ * bootstrap, so it is far longer than `channelBudgetMs`.
346
+ *
347
+ * Sized from the two measurements that bound it. A cold editor boot blocks
348
+ * the main thread for 60-100s (the same measurement `channelBudgetMs`'s
349
+ * comment cites), and `arrivalGraceMs` already concedes that a cold Vite
350
+ * dep-optimize can take minutes — so a smaller budget would demote healthy
351
+ * tabs mid-boot, which is the 2026-08-09 defect this whole file exists to
352
+ * undo. Bigger than either is not a cost worth paying either: this budget
353
+ * only ever decides how long the session may keep CLAIMING a tab can run
354
+ * commands before it says otherwise, and nothing waits on it — a command
355
+ * relayed meanwhile is held on its own budget exactly as before.
356
+ */
357
+ readonly listenerBudgetMs: number;
358
+ /** How long the table must be EMPTY before auto-open fires. */
359
+ /** Base interval between auto-opens; each attempt doubles it. */
360
+ /**
361
+ * No blessing may be REASSIGNED within this long of the last change.
362
+ * Hysteresis against a beat landing either side of a grace boundary and
363
+ * churning which tab owns the session. A DEPARTED blessed tab is exempt —
364
+ * there is nothing to keep sticky — so the dwell only ever delays an
365
+ * upgrade, never a repair.
366
+ */
367
+ readonly blessDwellMs: number;
368
+ /**
369
+ * A tab that requested the index page this recently is ARRIVING — booting,
370
+ * no worker yet. Neither auto-open nor `ensure` may open a duplicate under
371
+ * it. Cold Vite dep-optimize can take minutes, hence the size.
372
+ */
373
+ readonly arrivalGraceMs: number;
374
+ /**
375
+ * Beats fresh, census this old — the page's MAIN THREAD is not sampling, and
376
+ * {@link tabState} says `hung`.
377
+ *
378
+ * SIZED FROM THE CENSUS CADENCE, with the same margin the beat grace uses.
379
+ * The page samples every `TAB_CENSUS_INTERVAL_MS` = 5s
380
+ * (`packages/editor/src/tab-census.ts`), and `graceMs` is 3 × the 1s beat
381
+ * interval — so this is 3 × 5s. Two skipped samples are weather (a beat that
382
+ * neither transport could carry drops the sample it was carrying, and the
383
+ * next is 5s behind it); three is a thread that has stopped running timers.
384
+ *
385
+ * ONLY FOR A VISIBLE TAB, and {@link tabState} enforces that rather than
386
+ * softening the number: a hidden tab is deliberately not sampled at all, so
387
+ * its census age is a fact about the tab being backgrounded and says nothing
388
+ * about its main thread.
389
+ */
390
+ readonly hungAfterMs: number;
391
+ /**
392
+ * How long a DEPARTED tab's record is kept so the session can still say what
393
+ * happened to it. `closed` and `crashed` are verdicts about a tab that is no
394
+ * longer present, so without a memory the table answers them with silence —
395
+ * which is the 2026-09-17 defect exactly. Bounded because it is a memory,
396
+ * not a log; the session journal is the archive.
397
+ */
398
+ readonly departedMemoryMs: number;
399
+ }
400
+
401
+ export const DEFAULT_TAB_PRESENCE_CONFIG: TabPresenceConfig = {
402
+ beatIntervalMs: 1_000,
403
+ graceMs: 3_000,
404
+ hiddenGraceMs: 30_000,
405
+ gapNoticeMs: 2_000,
406
+ probeAfterMs: 2_000,
407
+ channelBudgetMs: 30_000,
408
+ listenerBudgetMs: 120_000,
409
+ blessDwellMs: 2_000,
410
+ arrivalGraceMs: 120_000,
411
+ hungAfterMs: 15_000,
412
+ departedMemoryMs: 120_000,
413
+ };
414
+
415
+ /** How many departed records the table keeps, whatever
416
+ * {@link TabPresenceConfig.departedMemoryMs} says. A session has a handful of
417
+ * page-loads; this is an order of magnitude past that. */
418
+ export const DEPARTED_TAB_MEMORY = 8;
419
+
420
+ export interface TabPresenceState {
421
+ readonly tabs: ReadonlyMap<string, TabRecord>;
422
+ /**
423
+ * TABS THAT ARE GONE, newest departure last — the table's short memory.
424
+ *
425
+ * A departed tab used to be deleted outright, so the one question an
426
+ * operator actually asks ("what happened to it?") had no answer anywhere:
427
+ * `closed` and `crashed` are both verdicts about a tab that is no longer
428
+ * present. Kept SEPARATE from `tabs` deliberately — every decision above
429
+ * reads `tabs`, and a dead record among the live ones would have to be
430
+ * filtered out at each of them. Bounded by
431
+ * {@link TabPresenceConfig.departedMemoryMs} and {@link DEPARTED_TAB_MEMORY}.
432
+ */
433
+ readonly departed: ReadonlyMap<string, TabRecord>;
434
+ /** The one tab commands and lifecycle events address. */
435
+ readonly blessedTabId: string | null;
436
+ /** When the blessing last changed — the dwell clock for `blessDwellMs`. */
437
+ readonly blessedAt: number | null;
438
+ /** Since when the table has had ZERO present tabs; null while any is present. */
439
+ readonly absentSince: number | null;
440
+ /** True once any tab has ever been present. Auto-open only REPLACES. */
441
+ readonly everPresent: boolean;
442
+ /**
443
+ * Auto-open has given up and said so. Set once the budget is spent with
444
+ * still nothing in the table, cleared the moment any tab appears (or a
445
+ * fresh `vgai edit` mandate arrives).
446
+ */
447
+ }
448
+
449
+ export function initialTabPresenceState(): TabPresenceState {
450
+ return {
451
+ tabs: new Map(),
452
+ departed: new Map(),
453
+ blessedTabId: null,
454
+ blessedAt: null,
455
+ absentSince: null,
456
+ everPresent: false,
457
+ };
458
+ }
459
+
460
+ /** The grace this tab gets before it counts as departed. */
461
+ function graceFor(tab: TabRecord, config: TabPresenceConfig): number {
462
+ return tab.visibility === 'hidden' ? config.hiddenGraceMs : config.graceMs;
463
+ }
464
+
465
+ /**
466
+ * Is this tab PRESENT?
467
+ *
468
+ * A UNION, deliberately: a fresh beat OR a live control channel. Both are
469
+ * POSITIVE evidence that a tab exists; neither absence is proof on its own,
470
+ * and requiring both would manufacture exactly the false absences this model
471
+ * replaces (a tab mid-reload has no channel for a moment; a tab whose worker
472
+ * was never allowed to start has no beats at all — the share tunnel bridges
473
+ * the event stream one way, so a bridged tab can never beat).
474
+ *
475
+ * What the heartbeat buys, then, is that it ADDS presence and never removes
476
+ * it: a tab whose page socket is down — mid-reload, main thread blocked by a
477
+ * cold module graph, control POSTs starved in the browser's connection pool —
478
+ * is still visibly here, which is the whole failure this replaces. A gap in
479
+ * the beats of a tab that is otherwise connected does not evict it; it gets
480
+ * journaled loudly and shows up in `vgai status` as `lastBeatAgo`, which is
481
+ * the owner's "if they DO get disconnected, make it clear".
482
+ */
483
+ export function tabPresent(tab: TabRecord, now: number, config: TabPresenceConfig): boolean {
484
+ if (tab.connected) return true;
485
+ if (!tab.beatEver) return false;
486
+ return now - tab.lastBeatAt < graceFor(tab, config);
487
+ }
488
+
489
+ /** Every present tab, oldest first (the tiebreak bless order). */
490
+ export function presentTabs(
491
+ state: TabPresenceState,
492
+ now: number,
493
+ config: TabPresenceConfig,
494
+ ): TabRecord[] {
495
+ return [...state.tabs.values()]
496
+ .filter((tab) => tabPresent(tab, now, config))
497
+ .sort((a, b) => a.firstSeenAt - b.firstSeenAt || (a.tabId < b.tabId ? -1 : 1));
498
+ }
499
+
500
+ /** Should the server probe this tab's worker rather than wait out the grace? */
501
+ export function tabNeedsProbe(tab: TabRecord, now: number, config: TabPresenceConfig): boolean {
502
+ if (!tab.beatEver) return false;
503
+ const age = now - tab.lastBeatAt;
504
+ return age >= config.probeAfterMs && age < graceFor(tab, config);
505
+ }
506
+
507
+ function blankTab(tabId: string, now: number): TabRecord {
508
+ return {
509
+ tabId,
510
+ firstSeenAt: now,
511
+ lastBeatAt: 0,
512
+ beatEver: false,
513
+ epoch: null,
514
+ epochStartedAt: 0,
515
+ epochCount: 0,
516
+ epochs: [],
517
+ visibility: 'visible',
518
+ route: 'unknown',
519
+ surface: 'editor',
520
+ connected: false,
521
+ channelThisEpoch: false,
522
+ listenerThisEpoch: false,
523
+ listenerEpochs: [],
524
+ unresponsiveNoticed: false,
525
+ gapSince: null,
526
+ gapNoticed: false,
527
+ census: [],
528
+ closedEpoch: null,
529
+ closedAt: null,
530
+ closedPersisted: false,
531
+ closedReason: null,
532
+ lastGapMs: null,
533
+ lastGapEndedAt: null,
534
+ commandTimeoutAt: null,
535
+ };
536
+ }
537
+
538
+ /**
539
+ * File a fresh page sample against a tab's history, newest last.
540
+ *
541
+ * A plain append, because every arrival IS a fresh sample — the worker sends a
542
+ * profile once and drops it (see {@link TabCensusSample}). A beat with no
543
+ * census leaves the history untouched: silence is not a profile, and a hidden
544
+ * tab is silent for exactly as long as it is hidden.
545
+ *
546
+ * Two identical-looking samples five seconds apart are kept as two, and that
547
+ * is the point: they differ in the field the death line reads, `at`.
548
+ */
549
+ function fileCensus(
550
+ history: readonly TabCensusSample[],
551
+ census: TabCensus | undefined,
552
+ now: number,
553
+ ): readonly TabCensusSample[] {
554
+ if (census === undefined) return history;
555
+ return [...history, { ...census, at: now }].slice(-TAB_CENSUS_HISTORY);
556
+ }
557
+
558
+ /**
559
+ * A tab whose PAGE never came up this page-load, past the budget for the
560
+ * stage it is stuck at.
561
+ *
562
+ * This is the zombie: everything the server can see about the tab is fine and
563
+ * the document is not. It is present (the tab really is open, and saying
564
+ * otherwise would be a lie a user can see through), it is passed over for
565
+ * blessing so a healthy sibling wins, and when it is the only tab the refusal
566
+ * names this exact fact instead of holding a command for a page that will
567
+ * never take it.
568
+ *
569
+ * TWO STAGES, because there are two things a live tab can fail to do and each
570
+ * has its own evidence and its own budget:
571
+ *
572
+ * 1. NO CHANNEL. The worker beats and the page never opened a control
573
+ * connection. `channelBudgetMs` — short, because the connection is opened
574
+ * by the inline bootstrap before any module loads, so a page that has not
575
+ * opened one in half a minute never will.
576
+ * 2. NO LISTENER. The channel is open and the module graph behind it never
577
+ * produced a command listener. `listenerBudgetMs` — long, because this
578
+ * stage is waiting on the whole editor app.
579
+ *
580
+ * Stage 2 exempts a tab that says it is on the LAUNCHER (`route:
581
+ * 'no-project'` — the hub, or the startup-failure surface): those surfaces
582
+ * legitimately run no project command listener, and they are healthy targets
583
+ * for `ensure`'s adopt path, not zombies. A tab whose document never ran says
584
+ * nothing at all and stays `'unknown'`, which is exactly the case stage 2 is
585
+ * for.
586
+ */
587
+ export function tabUnresponsive(tab: TabRecord, now: number, config: TabPresenceConfig): boolean {
588
+ const epochStartedAt = tab.epochStartedAt || tab.firstSeenAt;
589
+ if (!tab.connected && !tab.channelThisEpoch) {
590
+ if (!tab.beatEver) return false;
591
+ return now - epochStartedAt >= config.channelBudgetMs;
592
+ }
593
+ if (tab.listenerThisEpoch) return false;
594
+ if (tab.route === 'no-project') return false;
595
+ return now - epochStartedAt >= config.listenerBudgetMs;
596
+ }
597
+
598
+ /** Which stage {@link tabUnresponsive} is reporting — the word a journal line
599
+ * and a refusal both need, derived from the same record rather than restated. */
600
+ export function tabUnresponsiveReason(tab: TabRecord): 'no-channel' | 'no-command-listener' {
601
+ return !tab.connected && !tab.channelThisEpoch ? 'no-channel' : 'no-command-listener';
602
+ }
603
+
604
+ function replaceTab(state: TabPresenceState, tab: TabRecord): TabPresenceState {
605
+ const tabs = new Map(state.tabs);
606
+ tabs.set(tab.tabId, tab);
607
+ // A tab that is live again is not a memory. Dropping it here rather than at
608
+ // each caller is why this is the ONE function that writes the table.
609
+ if (!state.departed.has(tab.tabId)) return { ...state, tabs };
610
+ const departed = new Map(state.departed);
611
+ departed.delete(tab.tabId);
612
+ return { ...state, tabs, departed };
613
+ }
614
+
615
+ /** An epoch is "live" while it has beaten within the duplicate-detection window. */
616
+ function liveEpochs(
617
+ epochs: readonly EpochRecord[],
618
+ now: number,
619
+ config: TabPresenceConfig,
620
+ ): EpochRecord[] {
621
+ return epochs.filter((e) => now - e.lastBeatAt < config.beatIntervalMs * 3);
622
+ }
623
+
624
+ export interface BeatOutcome {
625
+ readonly state: TabPresenceState;
626
+ readonly events: SessionJournalEvent[];
627
+ /**
628
+ * `'re-mint'` when this beat came from a tab whose sessionStorage was
629
+ * COPIED (Chrome's "Duplicate Tab"): two epochs are beating under one
630
+ * tabId, and the younger one is told to mint a fresh tabId.
631
+ */
632
+ readonly reply: 're-mint' | null;
633
+ }
634
+
635
+ /**
636
+ * Record one heartbeat.
637
+ *
638
+ * Reload vs duplicate is decided by one measurement, not a timer: a dedicated
639
+ * Worker dies with its page, so after a RELOAD the old epoch never beats
640
+ * again — its last beat necessarily predates the new epoch's first. Two
641
+ * epochs whose beats INTERLEAVE can only be two live pages, which is the
642
+ * duplicate. The re-mint goes to the younger epoch (the beat that observed
643
+ * the interleave), so the original tab keeps its identity.
644
+ */
645
+ export function recordBeat(
646
+ state: TabPresenceState,
647
+ beat: TabBeat,
648
+ now: number,
649
+ config: TabPresenceConfig = DEFAULT_TAB_PRESENCE_CONFIG,
650
+ ): BeatOutcome {
651
+ const events: SessionJournalEvent[] = [];
652
+ // A beat from a tab the table has already DEPARTED is a RETURN, not a new
653
+ // tab: a machine that went to sleep froze the server's tick and the page's
654
+ // worker together, and on wake the beat can easily land after the sweep. Its
655
+ // old record is what makes that readable — resurrect it, and the gap it
656
+ // carries is what `tabState` reads as `suspended` rather than as a death.
657
+ // (A CLOSED tab cannot come back this way: `sessionStorage` died with it, so
658
+ // a genuinely new tab arrives under a new tabId.)
659
+ const existing = state.tabs.get(beat.tabId) ?? state.departed.get(beat.tabId);
660
+ const base = existing ?? blankTab(beat.tabId, now);
661
+ const tabId8 = beat.tabId.slice(0, 8);
662
+
663
+ const others = liveEpochs(base.epochs, now, config).filter((e) => e.epoch !== beat.epoch);
664
+ const mine = base.epochs.find((e) => e.epoch === beat.epoch);
665
+ const myFirstSeenAt = mine?.firstSeenAt ?? now;
666
+
667
+ // Two live pages under one tabId: another epoch has beaten AT OR AFTER this
668
+ // one first appeared, so they overlap in time. Only the YOUNGEST re-mints.
669
+ const rival = others.find((e) => e.lastBeatAt >= myFirstSeenAt);
670
+ const duplicated = rival !== undefined && others.every((e) => e.firstSeenAt <= myFirstSeenAt);
671
+ if (duplicated) {
672
+ events.push({ kind: 'tab-duplicated', tabId8 });
673
+ // The record is NOT advanced by an impostor beat: the original tab owns
674
+ // this tabId, and the duplicate is about to re-mint and come back as a
675
+ // tab of its own.
676
+ return { state, events, reply: 're-mint' };
677
+ }
678
+
679
+ const epochs: EpochRecord[] = [
680
+ ...others,
681
+ { epoch: beat.epoch, firstSeenAt: myFirstSeenAt, lastBeatAt: now },
682
+ ];
683
+ // The beat's own epoch IS the tab's current page-load. (Reading the OLDEST
684
+ // live epoch instead looks defensible and is not: a reload's dead epoch
685
+ // stays "live" for the pruning window, so the reload went unnoticed for
686
+ // three seconds and the new page-load inherited the old one's channel
687
+ // credit — long enough for the zombie check to be answering about a page
688
+ // that no longer existed.)
689
+ const epochChanged = base.epoch !== null && base.epoch !== beat.epoch;
690
+
691
+ if (!existing) {
692
+ events.push({ kind: 'tab-appeared', tabId8, visibility: beat.visibility });
693
+ } else if (existing.beatEver && epochChanged) {
694
+ // A duplicate's FIRST beat is indistinguishable from a reload and is
695
+ // reported as one; its second beat interleaves with the original's and
696
+ // lands on the `tab-duplicated` branch above. One arguable line, followed
697
+ // immediately by the correction — better than withholding the reload
698
+ // report that is right in every other case.
699
+ events.push({ kind: 'tab-reloaded', tabId8, epochCount: existing.epochCount + 1 });
700
+ }
701
+ if (existing?.gapSince !== null && existing?.gapNoticed === true) {
702
+ events.push({ kind: 'tab-gap-closed', tabId8, gapMs: now - existing.gapSince });
703
+ }
704
+
705
+ // THE GAP THIS BEAT JUST CLOSED. Recorded here and not in `sweepPresence`
706
+ // because a gap longer than the grace normally ends in a DEPARTURE — the
707
+ // only way one gets closed instead is a tab that came back, which is the
708
+ // suspend `tabState` has to tell apart from a death.
709
+ const gapMs = base.beatEver ? now - base.lastBeatAt : 0;
710
+ const closedGap = gapMs >= graceFor(base, config);
711
+ // A beat from the epoch that said goodbye means the page came BACK — the
712
+ // back/forward cache restored it. The goodbye is no longer true of it.
713
+ const sameEpochReturned = base.closedEpoch !== null && base.closedEpoch === beat.epoch;
714
+
715
+ const tab: TabRecord = {
716
+ ...base,
717
+ lastBeatAt: now,
718
+ beatEver: true,
719
+ epoch: beat.epoch,
720
+ epochStartedAt: myFirstSeenAt,
721
+ epochCount: base.epochCount + (epochChanged || base.epochCount === 0 ? 1 : 0),
722
+ epochs,
723
+ visibility: beat.visibility,
724
+ // A new page-load starts owing a channel again — and gets a fresh chance
725
+ // to be called unresponsive, or not.
726
+ channelThisEpoch: epochChanged ? base.connected : base.channelThisEpoch,
727
+ // A listener belongs to the page-load that reported it and to no other:
728
+ // a reload has to load the module graph again, so the new epoch owes its
729
+ // own listener and its own budget starts here.
730
+ listenerThisEpoch: base.listenerEpochs.includes(beat.epoch),
731
+ unresponsiveNoticed: epochChanged ? false : base.unresponsiveNoticed,
732
+ gapSince: null,
733
+ gapNoticed: false,
734
+ // A reload does NOT clear the history: the profile of the page that just
735
+ // went away is the whole point of keeping one, and a renderer death is
736
+ // followed by exactly such a reload.
737
+ census: fileCensus(base.census, beat.census, now),
738
+ ...(sameEpochReturned
739
+ ? { closedEpoch: null, closedAt: null, closedPersisted: false, closedReason: null }
740
+ : {}),
741
+ ...(closedGap ? { lastGapMs: gapMs, lastGapEndedAt: now } : {}),
742
+ // A beat is not an answer to a command, so it does not clear a timeout:
743
+ // that is `noteTabCommandOutcome`'s job, and conflating them is how the
744
+ // worker's liveness would end up vouching for the page's again. A NEW
745
+ // page-load owes nothing to the old one's commands, though.
746
+ ...(epochChanged ? { commandTimeoutAt: null } : {}),
747
+ };
748
+ const next = replaceTab(state, tab);
749
+ return {
750
+ state: next,
751
+ events,
752
+ reply: null,
753
+ };
754
+ }
755
+
756
+ /**
757
+ * Record a page's goodbye ({@link TabCloseBeacon}).
758
+ *
759
+ * Filed against whichever half of the table holds the tab — a beacon can
760
+ * easily land after the sweep has already moved the record into the departed
761
+ * memory (a close is exactly the event that ends the beats), and a verdict
762
+ * that can only be filed on a live record would miss the case it exists for.
763
+ *
764
+ * A beacon for a tab the server has never seen is DROPPED. There is no record
765
+ * to describe and nothing to say about one; inventing a blank record here
766
+ * would put a tab in the memory that was never in the table.
767
+ */
768
+ export function recordTabClose(
769
+ state: TabPresenceState,
770
+ beacon: TabCloseBeacon,
771
+ now: number,
772
+ ): TabPresenceState {
773
+ const closed = {
774
+ closedEpoch: beacon.epoch,
775
+ closedAt: now,
776
+ closedPersisted: beacon.persisted,
777
+ closedReason: beacon.reason,
778
+ };
779
+ const live = state.tabs.get(beacon.tabId);
780
+ if (live !== undefined) return replaceTab(state, { ...live, ...closed });
781
+ const gone = state.departed.get(beacon.tabId);
782
+ if (gone === undefined) return state;
783
+ const departed = new Map(state.departed);
784
+ departed.set(beacon.tabId, { ...gone, ...closed });
785
+ return { ...state, departed };
786
+ }
787
+
788
+ /**
789
+ * Has this tab acknowledged the session's end?
790
+ *
791
+ * Reads BOTH halves of the table, for {@link recordTabClose}'s reason: the ack
792
+ * is the last thing a page does, so its record can already have been swept
793
+ * into the departed memory by the time the question is asked.
794
+ */
795
+ export function tabEndAcknowledged(state: TabPresenceState, tabId: string): boolean {
796
+ const tab = state.tabs.get(tabId) ?? state.departed.get(tabId);
797
+ return tab?.closedReason === 'session-ended';
798
+ }
799
+
800
+ /**
801
+ * How a relayed command against this tab ENDED — the server's own half of the
802
+ * `hung` verdict (see {@link TabRecord.commandTimeoutAt}).
803
+ *
804
+ * Latches on a timeout and clears on any answer, which is the honest pair:
805
+ * one expired command is evidence the page was not running, and the next
806
+ * answered one is proof it is. Nothing else clears it — a fresh beat is the
807
+ * WORKER speaking, and the whole point of this field is that the worker cannot
808
+ * vouch for the page.
809
+ */
810
+ export function noteTabCommandOutcome(
811
+ state: TabPresenceState,
812
+ tabId: string,
813
+ now: number,
814
+ outcome: 'timed-out' | 'answered',
815
+ ): TabPresenceState {
816
+ const tab = state.tabs.get(tabId);
817
+ if (tab === undefined) return state;
818
+ const commandTimeoutAt = outcome === 'timed-out' ? now : null;
819
+ if (tab.commandTimeoutAt === commandTimeoutAt) return state;
820
+ if (outcome === 'timed-out' && tab.commandTimeoutAt !== null) return state;
821
+ return replaceTab(state, { ...tab, commandTimeoutAt });
822
+ }
823
+
824
+ /**
825
+ * A tab's CONTROL channel opened or closed. This is a hint, never a verdict:
826
+ * it updates one boolean on the record and schedules a reconcile. Nothing
827
+ * here opens, closes, or blesses a tab.
828
+ */
829
+ export function setTabConnected(
830
+ state: TabPresenceState,
831
+ tabId: string,
832
+ connected: boolean,
833
+ now: number,
834
+ ): TabPresenceState {
835
+ const existing = state.tabs.get(tabId);
836
+ if (existing === undefined) {
837
+ if (!connected) return state;
838
+ const fresh = replaceTab(state, {
839
+ ...blankTab(tabId, now),
840
+ connected: true,
841
+ channelThisEpoch: true,
842
+ });
843
+ return fresh;
844
+ }
845
+ if (existing.connected === connected) return state;
846
+ return replaceTab(state, {
847
+ ...existing,
848
+ connected,
849
+ // Opening a channel is the proof this page-load is alive; losing one is
850
+ // not proof of the opposite, so the flag only ever latches ON.
851
+ channelThisEpoch: existing.channelThisEpoch || connected,
852
+ });
853
+ }
854
+
855
+ /**
856
+ * This page-load reported its COMMAND LISTENER attached.
857
+ *
858
+ * Latches ON for the epoch, for the same reason `channelThisEpoch` does:
859
+ * attaching is proof the document is running, while the detach report that
860
+ * `connectCommandListener`'s cleanup sends (an unmount, an HMR swap) is not
861
+ * proof of the opposite. The listener and heartbeat travel independently:
862
+ * retain the reporting page's identity even if its first beat has not arrived.
863
+ * Never credit whichever page happens to own the tab table at report time.
864
+ */
865
+ export function setTabListener(
866
+ state: TabPresenceState,
867
+ tabId: string,
868
+ now: number,
869
+ epoch: string,
870
+ ): TabPresenceState {
871
+ const existing = state.tabs.get(tabId) ?? blankTab(tabId, now);
872
+ if (existing.listenerEpochs.includes(epoch)) return state;
873
+ // Reloads must not grow the tab record forever. Keep the current page's
874
+ // credit plus recent reports that may be racing a heartbeat or a duplicate.
875
+ const recent = existing.listenerEpochs.filter((value) => value !== existing.epoch);
876
+ const listenerEpochs = [
877
+ ...(existing.epoch !== null && existing.listenerThisEpoch ? [existing.epoch] : []),
878
+ ...recent.slice(-7),
879
+ epoch,
880
+ ];
881
+ return replaceTab(state, {
882
+ ...existing,
883
+ listenerEpochs,
884
+ listenerThisEpoch:
885
+ existing.listenerThisEpoch || existing.epoch === null || existing.epoch === epoch,
886
+ });
887
+ }
888
+
889
+ /**
890
+ * What kind of page this tab is — declared by the bootstrap, or observed from
891
+ * the frame's own origin. Never DOWNGRADED to `'editor'`: the two sources
892
+ * arrive on different connections of one tab, and the narrower reading is the
893
+ * one that knows something.
894
+ */
895
+ export function setTabSurface(
896
+ state: TabPresenceState,
897
+ tabId: string,
898
+ surface: TabSurface,
899
+ ): TabPresenceState {
900
+ const existing = state.tabs.get(tabId);
901
+ if (existing === undefined || existing.surface === surface) return state;
902
+ if (surface === 'editor') return state;
903
+ return replaceTab(state, { ...existing, surface });
904
+ }
905
+
906
+ /** The tab said which surface it is showing. */
907
+ export function setTabRoute(
908
+ state: TabPresenceState,
909
+ tabId: string,
910
+ route: TabRoute,
911
+ ): TabPresenceState {
912
+ const existing = state.tabs.get(tabId);
913
+ if (existing === undefined || existing.route === route) return state;
914
+ return replaceTab(state, { ...existing, route });
915
+ }
916
+
917
+ export interface ReconcileInput {
918
+ readonly now: number;
919
+ /** Last time this server served its index page — an ARRIVING tab. */
920
+ readonly lastIndexRequestAt: number | null;
921
+ /** False for a session that must never open a browser (--no-open, remote). */
922
+ readonly mayOpen: boolean;
923
+ }
924
+
925
+ export interface ReconcileResult {
926
+ readonly state: TabPresenceState;
927
+ /** The blessed tab AFTER reconciliation (null when no tab is present). */
928
+ readonly blessedTabId: string | null;
929
+ /** Present tabs that are not blessed — each is told to yield. */
930
+ readonly yieldTabIds: string[];
931
+ /** Open exactly one tab: the table says there are none. */
932
+ readonly events: SessionJournalEvent[];
933
+ }
934
+
935
+ /**
936
+ * THE ONE FUNCTION THAT DECIDES ANYTHING.
937
+ *
938
+ * Socket events never open, close or bless a tab — they only schedule a call
939
+ * to this. That inversion is the reason the old model could not be repaired
940
+ * in place: its decisions were spread across a connect handler, a disconnect
941
+ * handler, a route handler and a self-heal tick, each holding a fragment of
942
+ * the truth and racing the others. Here the whole truth is the table, read
943
+ * once, and everything downstream is derived from it.
944
+ *
945
+ * Blessing is STICKY: the current blessed tab keeps the blessing while it
946
+ * remains present, so an ordinary reload (same tabId, new epoch) does not
947
+ * hand the session to a different tab. Otherwise the oldest present tab wins,
948
+ * which is stable under any arrival order.
949
+ */
950
+ /**
951
+ * Step 1 — gaps and departures, in TAB vocabulary.
952
+ *
953
+ * `lastBeatAt` only ever moves forward, so "age >= grace" IS "continuously
954
+ * absent for the whole grace window": there is no partial-credit state a beat
955
+ * near the boundary could oscillate across. Reappearance, by contrast, is
956
+ * instant — a beat is proof, and making a returning tab serve a probation
957
+ * would be inventing an absence.
958
+ */
959
+ function sweepPresence(
960
+ state: TabPresenceState,
961
+ now: number,
962
+ config: TabPresenceConfig,
963
+ events: SessionJournalEvent[],
964
+ ): TabPresenceState {
965
+ const tabs = new Map(state.tabs);
966
+ const departed = new Map(state.departed);
967
+ for (const tab of [...tabs.values()]) {
968
+ const tabId8 = tab.tabId.slice(0, 8);
969
+ if (!tabPresent(tab, now, config)) {
970
+ events.push({
971
+ kind: 'tab-departed',
972
+ tabId8,
973
+ absentMs: tab.beatEver ? now - tab.lastBeatAt : 0,
974
+ });
975
+ tabs.delete(tab.tabId);
976
+ departed.set(tab.tabId, tab);
977
+ continue;
978
+ }
979
+ if (!tab.beatEver) continue;
980
+ const age = now - tab.lastBeatAt;
981
+ if (age < config.gapNoticeMs) continue;
982
+ if (!tab.gapNoticed) events.push({ kind: 'tab-heartbeat-gap', tabId8, sinceMs: age });
983
+ tabs.set(tab.tabId, { ...tab, gapSince: tab.gapSince ?? tab.lastBeatAt, gapNoticed: true });
984
+ }
985
+ return { ...state, tabs, departed: pruneDeparted(departed, now, config) };
986
+ }
987
+
988
+ /** The departed memory, bounded by age and by count — a memory, not a log. */
989
+ function pruneDeparted(
990
+ departed: Map<string, TabRecord>,
991
+ now: number,
992
+ config: TabPresenceConfig,
993
+ ): Map<string, TabRecord> {
994
+ for (const [tabId, tab] of departed) {
995
+ // A tab that never beat has no departure age of its own; the tab's own
996
+ // first-seen stamp is the only clock it has.
997
+ const goneAt = tab.beatEver ? tab.lastBeatAt : tab.firstSeenAt;
998
+ if (now - goneAt >= config.departedMemoryMs) departed.delete(tabId);
999
+ }
1000
+ while (departed.size > DEPARTED_TAB_MEMORY) {
1001
+ const oldest = departed.keys().next();
1002
+ if (oldest.done === true) break;
1003
+ departed.delete(oldest.value);
1004
+ }
1005
+ return departed;
1006
+ }
1007
+
1008
+ /** Step 2 — the zombies: present and beating, but their page never woke up. */
1009
+ function partitionEligible(
1010
+ state: TabPresenceState,
1011
+ present: readonly TabRecord[],
1012
+ now: number,
1013
+ config: TabPresenceConfig,
1014
+ events: SessionJournalEvent[],
1015
+ ): { state: TabPresenceState; eligible: TabRecord[] } {
1016
+ const tabs = new Map(state.tabs);
1017
+ const eligible: TabRecord[] = [];
1018
+ for (const tab of present) {
1019
+ if (!tabUnresponsive(tab, now, config)) {
1020
+ eligible.push(tab);
1021
+ continue;
1022
+ }
1023
+ if (tab.unresponsiveNoticed) continue;
1024
+ events.push({
1025
+ kind: 'tab-unresponsive',
1026
+ tabId8: tab.tabId.slice(0, 8),
1027
+ reason: tabUnresponsiveReason(tab),
1028
+ unresponsiveForMs: now - (tab.epochStartedAt || tab.firstSeenAt),
1029
+ });
1030
+ tabs.set(tab.tabId, { ...tab, unresponsiveNoticed: true });
1031
+ }
1032
+ return { state: { ...state, tabs }, eligible };
1033
+ }
1034
+
1035
+ /**
1036
+ * Step 3 — exactly one blessed tab.
1037
+ *
1038
+ * Sticky while the holder stays ELIGIBLE, and no reassignment inside
1039
+ * `blessDwellMs` unless the holder is genuinely gone. Together those are the
1040
+ * anti-flap: an eligibility flip cannot ping-pong the session between two
1041
+ * tabs, while a real departure is repaired immediately.
1042
+ */
1043
+ function chooseBlessed(
1044
+ state: TabPresenceState,
1045
+ present: readonly TabRecord[],
1046
+ eligible: readonly TabRecord[],
1047
+ now: number,
1048
+ config: TabPresenceConfig,
1049
+ events: SessionJournalEvent[],
1050
+ ): TabPresenceState {
1051
+ let blessedTabId = state.blessedTabId;
1052
+ let blessedAt = state.blessedAt;
1053
+ const holderEligible = eligible.some((tab) => tab.tabId === blessedTabId);
1054
+ const holderPresent = present.some((tab) => tab.tabId === blessedTabId);
1055
+ const dwelling = blessedAt !== null && now - blessedAt < config.blessDwellMs;
1056
+ if (blessedTabId !== null && !holderEligible && !(holderPresent && dwelling)) {
1057
+ blessedTabId = null;
1058
+ }
1059
+ if (blessedTabId === null && eligible.length > 0) {
1060
+ // Oldest first, but a tab with a live channel beats one without: a healthy
1061
+ // sibling must win over a page that is merely mid-something.
1062
+ const chosen = eligible.find((tab) => tab.connected) ?? eligible[0]!;
1063
+ events.push({
1064
+ kind: 'tab-blessed',
1065
+ tabId8: chosen.tabId.slice(0, 8),
1066
+ previousTabId8: state.blessedTabId === null ? null : state.blessedTabId.slice(0, 8),
1067
+ reason: state.blessedTabId === null ? 'oldest' : 'sticky',
1068
+ });
1069
+ blessedTabId = chosen.tabId;
1070
+ blessedAt = now;
1071
+ }
1072
+ return { ...state, blessedTabId, blessedAt };
1073
+ }
1074
+
1075
+ export function reconcile(
1076
+ state: TabPresenceState,
1077
+ input: ReconcileInput,
1078
+ config: TabPresenceConfig = DEFAULT_TAB_PRESENCE_CONFIG,
1079
+ ): ReconcileResult {
1080
+ const { now } = input;
1081
+ const events: SessionJournalEvent[] = [];
1082
+
1083
+ let next = sweepPresence(state, now, config, events);
1084
+
1085
+ const present = presentTabs(next, now, config);
1086
+ next =
1087
+ present.length > 0
1088
+ ? { ...next, absentSince: null, everPresent: true }
1089
+ : next.absentSince === null
1090
+ ? { ...next, absentSince: now }
1091
+ : next;
1092
+
1093
+ const partition = partitionEligible(next, present, now, config, events);
1094
+ next = chooseBlessed(partition.state, present, partition.eligible, now, config, events);
1095
+ const blessedTabId = next.blessedTabId;
1096
+ // YIELD MEANS "ANOTHER TAB HOLDS THIS SESSION" — so there has to BE another
1097
+ // tab. With nothing blessed, no present tab is an extra, and every one of
1098
+ // them would be told to go away.
1099
+ //
1100
+ // MEASURED 2026-09-19, reproducing the wedge in WORK.md §"The editor tab can
1101
+ // wedge in a state `vgai edit` cannot self-heal". A page that stalls before
1102
+ // React mounts beats forever with no command listener; at
1103
+ // `listenerBudgetMs` `partitionEligible` drops it from `eligible`,
1104
+ // `chooseBlessed` releases the blessing, and the filter below then named the
1105
+ // session's ONLY tab as an extra:
1106
+ //
1107
+ // 08:59:57.381 tab-unresponsive tabId8 025db401 reason no-command-listener
1108
+ // 08:59:57.382 tab-yielded tabId8 025db401
1109
+ // 08:59:57.390 client-disconnected code 1001 ← the page closed itself
1110
+ // 09:00:27.418 tab-departed tabId8 025db401
1111
+ //
1112
+ // `tab-lifecycle-client.ts`'s `handleYield` is `window.close()`, falling back
1113
+ // to navigating away to the yield page — so the bijection destroyed the one
1114
+ // tab it exists to keep, and `ensure`'s heal (`tab-reload`, the one message a
1115
+ // page with no module graph can still act on) never got the chance: the yield
1116
+ // fires on the reconcile TICK, before any `vgai edit` can reach the heal.
1117
+ const yieldTabIds =
1118
+ blessedTabId === null
1119
+ ? []
1120
+ : present.filter((tab) => tab.tabId !== blessedTabId).map((tab) => tab.tabId);
1121
+
1122
+ return { state: next, blessedTabId, yieldTabIds, events };
1123
+ }
1124
+
1125
+ /**
1126
+ * Is a tab on its way in — index page served, app not yet beating?
1127
+ *
1128
+ * A booting tab has no worker, so the table cannot see it and would happily
1129
+ * open a second one on top of it. The index request is the only evidence
1130
+ * available in that window, and it is bounded so a load that died never
1131
+ * suppresses the reconciler forever.
1132
+ */
1133
+ export function tabArriving(
1134
+ state: TabPresenceState,
1135
+ now: number,
1136
+ lastIndexRequestAt: number | null,
1137
+ config: TabPresenceConfig = DEFAULT_TAB_PRESENCE_CONFIG,
1138
+ ): boolean {
1139
+ if (lastIndexRequestAt === null) return false;
1140
+ if (now - lastIndexRequestAt >= config.arrivalGraceMs) return false;
1141
+ if (state.absentSince === null) return !state.everPresent;
1142
+ return lastIndexRequestAt >= state.absentSince;
1143
+ }
1144
+
1145
+ /**
1146
+ * WHAT IS TRUE OF THIS TAB, in one word — the first thing every `vgai` verb
1147
+ * says about a tab, and the reason this file exists in its current form.
1148
+ *
1149
+ * MEASURED 2026-09-17. One symptom — "the battery stopped" — had four causes
1150
+ * in one night, and the product reported all four the same way or not at all:
1151
+ * a renderer killed by a dev-server reload; a worker that never finished
1152
+ * booting; a page whose main thread was stuck in a 46 MB encode (`tab present,
1153
+ * last heartbeat 20.5s ago, did not respond`); and a twin call genuinely
1154
+ * running for 16 minutes at 21% CPU. Every one of those is a DIFFERENT
1155
+ * instruction to whoever is reading, and "present / did not respond" is none
1156
+ * of them.
1157
+ *
1158
+ * The seven words, and the evidence each is allowed to be derived from:
1159
+ *
1160
+ * - `ended` — the page ACKNOWLEDGED the session's end: it received
1161
+ * `tab-close`, ran every terminator behind `markSessionEnded`
1162
+ * (its Blender engine worker among them) and said so. The one
1163
+ * verdict that means nothing of this page is still running.
1164
+ * - `closed` — the page said goodbye and no successor is beating.
1165
+ * - `reloading` — the page said goodbye and a NEW page-load is already beating.
1166
+ * - `crashed` — the beats stopped past the grace with NO goodbye. The worker
1167
+ * died with its renderer; nothing got a chance to speak.
1168
+ * - `suspended` — the beats stopped past the grace and RESUMED under the same
1169
+ * epoch. A lid, a sleep, a throttle — not a death.
1170
+ * - `hung` — the beats are fresh and the PAGE is not running: its census
1171
+ * has stopped being sampled, or a command went out and never
1172
+ * came back. The worker is a separate thread, which is exactly
1173
+ * why it keeps saying the tab is fine.
1174
+ * - `busy` — the page is answering and a Blender call is outstanding.
1175
+ * - `present` — none of the above.
1176
+ *
1177
+ * THE NUMBER IS THE VERDICT. `busy` deliberately does not try to tell a hung
1178
+ * twin call from a long one: from outside the worker those are the same
1179
+ * observation, and `ms` (the in-flight age) is the whole truth available. A
1180
+ * reader decides; nothing here cancels, kills, restarts or budgets anything.
1181
+ */
1182
+ export type TabState =
1183
+ | 'ended'
1184
+ | 'closed'
1185
+ | 'reloading'
1186
+ | 'crashed'
1187
+ | 'suspended'
1188
+ | 'hung'
1189
+ | 'busy'
1190
+ | 'present';
1191
+
1192
+ export interface TabStateVerdict {
1193
+ readonly state: TabState;
1194
+ /**
1195
+ * THE number that goes with the word, in ms — and a different measurement
1196
+ * per state, because each word is about a different clock: time since the
1197
+ * goodbye (`closed`/`reloading`), beat age (`crashed`/`present`), the gap
1198
+ * that was resumed (`suspended`), census age or time since the command
1199
+ * expired (`hung`), the outstanding call's age (`busy`). Null only for a tab
1200
+ * that has never beaten, which has no clock of its own at all.
1201
+ */
1202
+ readonly ms: number | null;
1203
+ /** The evidence, in a sentence. Printed beside the word so a reader never
1204
+ * has to know which field the verdict came from. */
1205
+ readonly because: string;
1206
+ }
1207
+
1208
+ const seconds = (ms: number): string => `${Math.round(ms / 100) / 10}s`;
1209
+
1210
+ /**
1211
+ * Derive {@link TabState} — ONE function, ONE place, from the table's own
1212
+ * fields. Every surface that says what a tab is doing calls this; a second
1213
+ * derivation somewhere else is a second opinion, which is the bug this whole
1214
+ * file replaces.
1215
+ */
1216
+ export function tabState(
1217
+ tab: TabRecord,
1218
+ now: number,
1219
+ config: TabPresenceConfig = DEFAULT_TAB_PRESENCE_CONFIG,
1220
+ ): TabStateVerdict {
1221
+ const grace = graceFor(tab, config);
1222
+ const beatAge = tab.beatEver ? now - tab.lastBeatAt : null;
1223
+ const beating = beatAge !== null && beatAge < grace;
1224
+
1225
+ if (tab.closedEpoch !== null && tab.closedAt !== null) {
1226
+ const sinceClose = Math.max(0, now - tab.closedAt);
1227
+ const successor = tab.epoch !== null && tab.epoch !== tab.closedEpoch && beating;
1228
+ if (!successor && tab.closedReason === 'session-ended') {
1229
+ return {
1230
+ state: 'ended',
1231
+ ms: sinceClose,
1232
+ because:
1233
+ `its page acknowledged the session's end ${seconds(sinceClose)} ago — every ` +
1234
+ 'terminator it holds (the Blender engine worker among them) had already run when ' +
1235
+ 'it said so',
1236
+ };
1237
+ }
1238
+ if (!successor) {
1239
+ return {
1240
+ state: 'closed',
1241
+ ms: sinceClose,
1242
+ because:
1243
+ `its page sent a close beacon ${seconds(sinceClose)} ago` +
1244
+ (tab.closedPersisted ? ' (into the back/forward cache — it may yet come back)' : '') +
1245
+ ' and no new page-load is beating',
1246
+ };
1247
+ }
1248
+ // A successor that has outlived the grace is no longer RELOADING; it is
1249
+ // just the tab, and the goodbye it replaced is history.
1250
+ if (sinceClose < grace) {
1251
+ return {
1252
+ state: 'reloading',
1253
+ ms: sinceClose,
1254
+ because: `its page said goodbye ${seconds(sinceClose)} ago and a new page-load is already beating`,
1255
+ };
1256
+ }
1257
+ }
1258
+
1259
+ if (!tab.beatEver) {
1260
+ return {
1261
+ state: 'present',
1262
+ ms: null,
1263
+ because:
1264
+ tab.surface === 'vscode'
1265
+ ? "this VS Code window has never beaten: its page never ran the session's tab " +
1266
+ 'bootstrap (/__editor/tab-bootstrap.js), which is what starts the heartbeat ' +
1267
+ 'worker — the frame loads it before the bridge (docs/CODE-OSS.md §Boot, ' +
1268
+ 'DESKTOP). Its control channel is all that proves it'
1269
+ : 'this tab has never beaten (no heartbeat worker — a tunnelled tab is refused the ' +
1270
+ 'script); its control channel is what proves it',
1271
+ };
1272
+ }
1273
+
1274
+ if (!beating) {
1275
+ const age = beatAge ?? 0;
1276
+ return {
1277
+ state: 'crashed',
1278
+ ms: age,
1279
+ because:
1280
+ `its beats stopped ${seconds(age)} ago with no close beacon — the heartbeat worker ` +
1281
+ 'dies with its renderer, and nothing said goodbye',
1282
+ };
1283
+ }
1284
+
1285
+ if (
1286
+ tab.lastGapMs !== null &&
1287
+ tab.lastGapEndedAt !== null &&
1288
+ tab.lastGapMs >= grace &&
1289
+ now - tab.lastGapEndedAt < grace
1290
+ ) {
1291
+ return {
1292
+ state: 'suspended',
1293
+ ms: tab.lastGapMs,
1294
+ because: `its beats stopped for ${seconds(tab.lastGapMs)} and resumed under the SAME page-load — a sleep, not a death`,
1295
+ };
1296
+ }
1297
+
1298
+ if (tab.commandTimeoutAt !== null) {
1299
+ const since = Math.max(0, now - tab.commandTimeoutAt);
1300
+ return {
1301
+ state: 'hung',
1302
+ ms: since,
1303
+ because:
1304
+ `a command timed out against it ${seconds(since)} ago while its beats stayed fresh — ` +
1305
+ 'the heartbeat is a worker, so it says nothing about the page',
1306
+ };
1307
+ }
1308
+
1309
+ const newest = latestCensus(tab);
1310
+ if (newest !== null) {
1311
+ const censusAge = Math.max(0, now - newest.at);
1312
+ // ONLY a visible tab. A hidden one is deliberately not sampled at all
1313
+ // (`src/tab-census.ts`), so its census age is a fact about being
1314
+ // backgrounded and no evidence at all about its main thread.
1315
+ if (tab.visibility === 'visible' && censusAge >= config.hungAfterMs) {
1316
+ return {
1317
+ state: 'hung',
1318
+ ms: censusAge,
1319
+ because:
1320
+ `its beats are fresh but the page has not sampled its census in ${seconds(censusAge)} ` +
1321
+ `(it samples every 5s while visible) — the MAIN THREAD is not running`,
1322
+ };
1323
+ }
1324
+ const inFlight = newest.blender?.inFlightMs ?? null;
1325
+ if (censusAge < config.hungAfterMs && inFlight !== null && inFlight > 0) {
1326
+ return {
1327
+ state: 'busy',
1328
+ ms: inFlight,
1329
+ because:
1330
+ `a Blender call has been outstanding for ${seconds(inFlight)}; the page is still ` +
1331
+ 'sampling, so it is running — whether that call is stuck is what the number is for',
1332
+ };
1333
+ }
1334
+ }
1335
+
1336
+ return {
1337
+ state: 'present',
1338
+ ms: beatAge,
1339
+ because: `beating ${seconds(beatAge ?? 0)} ago, page sampling`,
1340
+ };
1341
+ }
1342
+
1343
+ /** What `vgai status` prints per tab, and what a refusal quotes. */
1344
+ export interface TabPresenceReport {
1345
+ readonly tabId8: string;
1346
+ readonly presentFor: number;
1347
+ readonly lastBeatAgo: number | null;
1348
+ readonly epochCount: number;
1349
+ /**
1350
+ * How long ago THIS page-load started (`epochStartedAt`), i.e. the age of
1351
+ * the document currently running in this tab. `presentFor` above is the
1352
+ * age of the TAB and survives its reloads, which is a different question
1353
+ * and the wrong one for anything that cares about what the running
1354
+ * document has in memory.
1355
+ *
1356
+ * P20 reads it as the reference point for "did bytes under `public/`
1357
+ * change after this document loaded" — a page-lifetime asset cache is
1358
+ * exactly as old as its epoch. It is the server's own observation (the
1359
+ * heartbeat's epoch id changing), not a page claim.
1360
+ */
1361
+ readonly epochAgeMs: number;
1362
+ readonly visibility: TabVisibility;
1363
+ readonly route: TabRoute;
1364
+ /** What kind of page this tab is — see {@link TabSurface}. */
1365
+ readonly surface: TabSurface;
1366
+ readonly blessed: boolean;
1367
+ readonly channel: 'open' | 'down';
1368
+ /** Beating, but this page-load has never opened a command channel. */
1369
+ readonly unresponsive: boolean;
1370
+ /** This tab's newest resource profile, or null when it has never sent one. */
1371
+ readonly census: TabCensus | null;
1372
+ /** How long ago that profile was filed. Null when there is none. */
1373
+ readonly censusAgeMs: number | null;
1374
+ /**
1375
+ * {@link tabState}'s verdict, flattened — the FIRST thing every reader
1376
+ * prints about this tab, before it attempts anything that can fail. Every
1377
+ * other field on this row answers a narrower question; this one answers the
1378
+ * question that was actually asked.
1379
+ */
1380
+ readonly state: TabState;
1381
+ readonly stateMs: number | null;
1382
+ readonly stateBecause: string;
1383
+ }
1384
+
1385
+ /** The newest profile this tab filed, or null. */
1386
+ export function latestCensus(tab: TabRecord): TabCensusSample | null {
1387
+ return tab.census[tab.census.length - 1] ?? null;
1388
+ }
1389
+
1390
+ /**
1391
+ * The whole table, for a human or an agent. Owner: "if they DO get
1392
+ * disconnected make it clear that it happened" — a per-tab row with the beat
1393
+ * age in it is that clarity, and it is the same data every decision above
1394
+ * read, not a parallel summary that can disagree with them.
1395
+ */
1396
+ export function tabPresenceReport(
1397
+ state: TabPresenceState,
1398
+ now: number,
1399
+ config: TabPresenceConfig = DEFAULT_TAB_PRESENCE_CONFIG,
1400
+ ): TabPresenceReport[] {
1401
+ return presentTabs(state, now, config).map((tab) => reportRow(state, tab, now, config));
1402
+ }
1403
+
1404
+ /**
1405
+ * The same row for a tab that is GONE — the table's short memory
1406
+ * ({@link TabPresenceState.departed}).
1407
+ *
1408
+ * Separate from {@link tabPresenceReport} on purpose: `editorsConnected` and
1409
+ * every present-tab reader counts that array, and a dead row inside it would
1410
+ * make a crashed tab read as a connected one. The words a departed row carries
1411
+ * (`closed`, `crashed`) are exactly the ones nobody could print before.
1412
+ */
1413
+ export function departedTabReport(
1414
+ state: TabPresenceState,
1415
+ now: number,
1416
+ config: TabPresenceConfig = DEFAULT_TAB_PRESENCE_CONFIG,
1417
+ ): TabPresenceReport[] {
1418
+ return [...state.departed.values()].map((tab) => reportRow(state, tab, now, config));
1419
+ }
1420
+
1421
+ function reportRow(
1422
+ state: TabPresenceState,
1423
+ tab: TabRecord,
1424
+ now: number,
1425
+ config: TabPresenceConfig,
1426
+ ): TabPresenceReport {
1427
+ const newest = latestCensus(tab);
1428
+ const verdict = tabState(tab, now, config);
1429
+ return {
1430
+ tabId8: tab.tabId.slice(0, 8),
1431
+ presentFor: now - tab.firstSeenAt,
1432
+ lastBeatAgo: tab.beatEver ? now - tab.lastBeatAt : null,
1433
+ epochCount: tab.epochCount,
1434
+ // `|| firstSeenAt` matches `tabUnresponsive`'s own fallback for a tab
1435
+ // whose epoch has not been stamped yet — never `now`, which would read
1436
+ // as a document that just loaded.
1437
+ epochAgeMs: now - (tab.epochStartedAt || tab.firstSeenAt),
1438
+ visibility: tab.visibility,
1439
+ route: tab.route,
1440
+ surface: tab.surface,
1441
+ blessed: tab.tabId === state.blessedTabId,
1442
+ channel: tab.connected ? 'open' : 'down',
1443
+ unresponsive: tabUnresponsive(tab, now, config),
1444
+ census: newest === null ? null : stripStamp(newest),
1445
+ censusAgeMs: newest === null ? null : now - newest.at,
1446
+ state: verdict.state,
1447
+ stateMs: verdict.ms,
1448
+ stateBecause: verdict.because,
1449
+ };
1450
+ }
1451
+
1452
+ /** The profile without the server's filing stamp — the report carries the age
1453
+ * as its own field, and two ways to say "when" is one too many. */
1454
+ function stripStamp(sample: TabCensusSample): TabCensus {
1455
+ const { at: _at, ...census } = sample;
1456
+ return census;
1457
+ }
1458
+
1459
+ /**
1460
+ * The refusal/timeout wording, stated as TABLE FACTS and nothing else.
1461
+ *
1462
+ * Every previous version of this message described a socket ("its connection
1463
+ * is still open", "editor tab lost") and was routinely wrong about the tab.
1464
+ * These two sentences can only be wrong if the table is, and the table is
1465
+ * what the decision was made from.
1466
+ */
1467
+ export function tabAbsenceMessage(state: TabPresenceState, now: number): string {
1468
+ const absentMs = state.absentSince === null ? 0 : now - state.absentSince;
1469
+ return (
1470
+ `No tab has been present for ${(absentMs / 1000).toFixed(1)}s` +
1471
+ ` (nothing reopens it: run \`volter-editor edit\` to open one)`
1472
+ );
1473
+ }
1474
+
1475
+ /** The zombie's refusal: the tab is open, the page is not running. */
1476
+ export function tabUnresponsiveMessage(tab: TabRecord, now: number): string {
1477
+ const forMs = now - (tab.epochStartedAt || tab.firstSeenAt);
1478
+ const beat = tab.beatEver
1479
+ ? `beating (last heartbeat ${((now - tab.lastBeatAt) / 1000).toFixed(1)}s ago)`
1480
+ : 'connected (it has never sent a heartbeat)';
1481
+ const stuck =
1482
+ tabUnresponsiveReason(tab) === 'no-channel'
1483
+ ? `has opened no command channel in ${(forMs / 1000).toFixed(0)}s`
1484
+ : `opened a control channel but has attached no command listener in ` +
1485
+ `${(forMs / 1000).toFixed(0)}s — it never finished loading the editor app, so every ` +
1486
+ `command sent there queues with nothing to run it`;
1487
+ return (
1488
+ `The one tab present is ${beat} but its PAGE ${stuck}. The tab is open and its ` +
1489
+ `document is not running. Reload it, or re-run \`volter-editor edit\` (which reuses this session ` +
1490
+ `and opens a fresh tab). \`volter-editor status\` prints any page errors that tab reported.`
1491
+ );
1492
+ }
1493
+
1494
+ /** The other half: the tab IS here, and here is what it has been doing. */
1495
+ export function tabWaitingMessage(
1496
+ tab: TabRecord,
1497
+ now: number,
1498
+ reloadsWhileWaiting: number,
1499
+ ): string {
1500
+ const beat = tab.beatEver
1501
+ ? `last heartbeat ${((now - tab.lastBeatAt) / 1000).toFixed(1)}s ago`
1502
+ : 'no heartbeat yet';
1503
+ const reloads =
1504
+ reloadsWhileWaiting > 0
1505
+ ? ` but reloaded ${reloadsWhileWaiting === 1 ? 'once' : `${reloadsWhileWaiting} times`} while this command waited`
1506
+ : '';
1507
+ return `the tab is present (${beat})${reloads}`;
1508
+ }