@vgai/editor 0.2.0 → 0.4.0-canary.20260715.0

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 (430) hide show
  1. package/README.md +24 -0
  2. package/dist/assets/axe-C7YBtOuM.js +30 -0
  3. package/dist/assets/browserAll-DDtGGE48.js +1 -0
  4. package/dist/assets/create-runtime-DbTlBlKG.js +1 -0
  5. package/dist/assets/{game-BI111o5f.js → game-BE0r7DMI.js} +3 -3
  6. package/dist/assets/game-BdTHR2_X.js +1 -0
  7. package/dist/assets/{game-pt_1lQLv.js → game-C3iQGSqo.js} +1 -1
  8. package/dist/assets/game-CcHfYxjP.js +1 -0
  9. package/dist/assets/game-Cqzj_lpD.js +1 -0
  10. package/dist/assets/game-DSZdeCHu.js +1 -0
  11. package/dist/assets/game-Dch9sAPc.js +1 -0
  12. package/dist/assets/{game-xsOho_wg.js → game-SAFn90pA.js} +3 -3
  13. package/dist/assets/game-loop-DMIgSMV-.js +1 -0
  14. package/dist/assets/{game-source-CvidN7AB.js → game-source-Dk83w26t.js} +1 -1
  15. package/dist/assets/{index-Bp5cecD6.js → index-4idIsHHJ.js} +2 -2
  16. package/dist/assets/{index-DL7RcQ0n.js → index-C-LhOTBN.js} +1 -1
  17. package/dist/assets/{index-Cs8xwv3e.js → index-CDkxceqE.js} +1 -1
  18. package/dist/assets/index-CQEfi217.js +15412 -0
  19. package/dist/assets/index-Dvi-V0NE.css +1 -0
  20. package/dist/assets/ingest-siblings-s0P5I1Ub.js +1 -0
  21. package/dist/assets/mount-manifest-DXMOlF79.js +1 -0
  22. package/dist/assets/react-18-C9eHn8x1.js +1 -0
  23. package/dist/assets/webworkerAll-Fn0e-ouF.js +1 -0
  24. package/dist/assets/world-documents-GM0a9Kv7.js +1 -0
  25. package/dist/index.html +15 -13
  26. package/dist/ingest/racing-game/vgai.game.json +5 -4
  27. package/dist/ingest/simcity/vgai.game.json +5 -4
  28. package/dist/ingest/tanks/vgai.game.json +5 -4
  29. package/dist/project-scripts.bundle.js +3917 -0
  30. package/dist/scenes/untitled.vscn.json +2 -0
  31. package/dist-server/packaged.mjs +7697 -3435
  32. package/package.json +31 -4
  33. package/scripts/probe-reporter.ts +349 -0
  34. package/scripts/run-probe.ts +2260 -0
  35. package/server/asset-catalog-audit.ts +268 -0
  36. package/server/asset-catalog-v2.ts +146 -0
  37. package/server/asset-library-routes.ts +276 -58
  38. package/server/canonical-path.ts +1 -1
  39. package/server/dev.ts +148 -44
  40. package/server/editor-server.ts +888 -61
  41. package/server/editor-sse.ts +115 -4
  42. package/server/harness-chat-service.ts +796 -0
  43. package/server/local-asset-catalog.ts +157 -22
  44. package/server/packaged.ts +51 -21
  45. package/server/process-shutdown.ts +136 -0
  46. package/server/prod.ts +9 -6
  47. package/server/project-hmr-files.ts +41 -0
  48. package/server/project-operations.ts +343 -0
  49. package/server/project-optimize-deps-entries.ts +4 -4
  50. package/server/project-output-writer.ts +156 -0
  51. package/server/project-validation.ts +140 -0
  52. package/server/project-view.ts +8 -50
  53. package/server/server-utils.ts +75 -10
  54. package/server/session-registry.ts +12 -0
  55. package/src/EditorContext.tsx +107 -3
  56. package/src/action-registry.ts +115 -17
  57. package/src/adapter-resolver.ts +213 -478
  58. package/src/asset-events.ts +64 -1
  59. package/src/asset-preview.ts +906 -0
  60. package/src/asset-selection.ts +121 -0
  61. package/src/asset-workflow/asset-capabilities.ts +259 -0
  62. package/src/asset-workflow/asset-editor-errors.ts +46 -0
  63. package/src/asset-workflow/asset-import-pipeline.ts +176 -0
  64. package/src/asset-workflow/asset-library-query.ts +107 -0
  65. package/src/asset-workflow/asset-types.ts +192 -0
  66. package/src/asset-workflow/asset-workflow-quality.ts +65 -0
  67. package/src/asset-workflow/import-contract.ts +113 -0
  68. package/src/asset-workflow/model-conversion.ts +126 -0
  69. package/src/asset-workflow/model-inspection.ts +185 -0
  70. package/src/asset-workflow/project-asset-commands.ts +23 -0
  71. package/src/asset-workflow/project-asset-health.ts +153 -0
  72. package/src/asset-workflow/project-asset-operations.ts +119 -0
  73. package/src/asset-workflow/thumbnail-system.ts +172 -0
  74. package/src/authoring/active-systems.ts +15 -0
  75. package/src/authoring/boundary-authoring-adapter.ts +16 -5
  76. package/src/authoring/composite-authoring-adapter.ts +305 -124
  77. package/src/authoring/design-time-layers.ts +300 -117
  78. package/src/authoring/edit-mode-authoring.ts +278 -293
  79. package/src/authoring/embed-only-adapter.ts +4 -2
  80. package/src/authoring/first-party-hierarchy-menu.ts +7 -2
  81. package/src/authoring/first-party-inspector/AddSectionMenu.tsx +5 -12
  82. package/src/authoring/first-party-inspector/EnvironmentSection.tsx +53 -126
  83. package/src/authoring/first-party-inspector/HeaderSection.tsx +54 -65
  84. package/src/authoring/first-party-inspector/is-first-party-selection.ts +30 -11
  85. package/src/authoring/first-party-inspector/register.tsx +13 -2
  86. package/src/authoring/ingest-authoring-adapter.ts +142 -45
  87. package/src/authoring/mount-failure-report.ts +5 -5
  88. package/src/authoring/provenance.ts +41 -0
  89. package/src/authoring/react-dom-authoring-adapter.ts +92 -92
  90. package/src/authoring/react-inspector-section.tsx +489 -51
  91. package/src/authoring/react-world-authoring-adapter.ts +541 -306
  92. package/src/authoring/story-overlay.ts +1 -1
  93. package/src/authoring/story-types.ts +11 -0
  94. package/src/authoring/vgai-scene-authoring-adapter.ts +54 -25
  95. package/src/authoring/viewport-tool-context.ts +78 -0
  96. package/src/authoring/world-canvas-viewport-state.ts +35 -0
  97. package/src/authoring/world-hidden-viewport.ts +186 -0
  98. package/src/authoring/world-pan-state.ts +33 -17
  99. package/src/authoring/world-session-state.ts +1 -1
  100. package/src/authoring/world2d-authoring-adapter.ts +109 -52
  101. package/src/availability-tick.ts +56 -0
  102. package/src/browser-play-entry.ts +5 -5
  103. package/src/browser-project-scripts.ts +1 -0
  104. package/src/build-identity.ts +15 -0
  105. package/src/command-listener.ts +893 -19
  106. package/src/components/AlignToolbar.tsx +74 -63
  107. package/src/components/AppRoot.tsx +193 -26
  108. package/src/components/ApplicationMenus.tsx +272 -0
  109. package/src/components/AssetBrowser.tsx +1450 -293
  110. package/src/components/AssetEditorShell.tsx +141 -0
  111. package/src/components/AssetImportDetails.tsx +218 -0
  112. package/src/components/AssetLibraryPanel.tsx +14 -0
  113. package/src/components/CameraInfo.tsx +11 -20
  114. package/src/components/CenterDocuments.tsx +585 -0
  115. package/src/components/CommandPalette.tsx +75 -30
  116. package/src/components/ConsolePanel.css +55 -0
  117. package/src/components/ConsolePanel.tsx +57 -112
  118. package/src/components/DebugConsolePanel.tsx +461 -0
  119. package/src/components/DebuggerPanel.tsx +165 -0
  120. package/src/components/DefaultEditorLayout.tsx +167 -231
  121. package/src/components/GameHierarchy.tsx +873 -290
  122. package/src/components/GamePerformanceOverlay.tsx +46 -0
  123. package/src/components/HarnessChatPanel.tsx +2352 -0
  124. package/src/components/HistoryPanel.tsx +226 -0
  125. package/src/components/Inspector.tsx +153 -99
  126. package/src/components/InspectorToolSection.tsx +6 -4
  127. package/src/components/MountFailureBanner.tsx +31 -48
  128. package/src/components/NetworkSection.tsx +25 -14
  129. package/src/components/OnlineAssetBrowser.tsx +794 -343
  130. package/src/components/OverlayReportBanner.tsx +38 -64
  131. package/src/components/PerformancePanel.tsx +206 -0
  132. package/src/components/PlayBar.tsx +93 -263
  133. package/src/components/PrefabSaveDialog.tsx +55 -127
  134. package/src/components/ProjectHeader.css +60 -0
  135. package/src/components/ProjectHeader.tsx +170 -105
  136. package/src/components/ProjectScreen.tsx +246 -184
  137. package/src/components/ReactCanvasControls.tsx +185 -0
  138. package/src/components/ResolutionPicker.tsx +85 -0
  139. package/src/components/RootActivateDialog.tsx +39 -68
  140. package/src/components/SaveDialog.tsx +55 -127
  141. package/src/components/SaveStatus.tsx +11 -29
  142. package/src/components/StartupLoadingScreen.tsx +96 -0
  143. package/src/components/StateWatchPanel.tsx +302 -0
  144. package/src/components/StatsOverlay.tsx +12 -5
  145. package/src/components/StatusBar.tsx +64 -0
  146. package/src/components/ThemeManagerDialog.css +89 -0
  147. package/src/components/ThemeManagerDialog.tsx +371 -0
  148. package/src/components/ToolHost.tsx +26 -15
  149. package/src/components/Toolbar.tsx +117 -214
  150. package/src/components/VgaiVoxelLogo.tsx +89 -0
  151. package/src/components/ViewportOverlay.tsx +97 -172
  152. package/src/components/ViewportPanel.tsx +130 -31
  153. package/src/components/WorkspaceDock.tsx +891 -0
  154. package/src/components/WorldSelectionOverlay.tsx +51 -63
  155. package/src/components/WorldTextEditor.tsx +9 -5
  156. package/src/components/asset-documents.tsx +390 -0
  157. package/src/components/asset-editor-persistence.ts +44 -0
  158. package/src/components/asset-selection-section.tsx +338 -0
  159. package/src/components/asset-viewers/AudioViewer.tsx +73 -21
  160. package/src/components/asset-viewers/GenericJsonViewer.tsx +26 -11
  161. package/src/components/asset-viewers/ImageViewer.tsx +43 -11
  162. package/src/components/asset-viewers/InputMapViewer.tsx +512 -134
  163. package/src/components/asset-viewers/MaterialAssetEditor.tsx +540 -0
  164. package/src/components/asset-viewers/ModelViewer.tsx +805 -121
  165. package/src/components/asset-viewers/OnlineAssetDetail.tsx +177 -60
  166. package/src/components/asset-viewers/SourceAssetViewer.tsx +121 -0
  167. package/src/components/asset-workflow.css +506 -0
  168. package/src/components/build-documents.tsx +696 -0
  169. package/src/components/core-utilities.tsx +75 -0
  170. package/src/components/data-documents.tsx +364 -0
  171. package/src/components/{DataPanel.tsx → data-editor.tsx} +138 -288
  172. package/src/components/debugger-panel-state.ts +57 -0
  173. package/src/components/inspector-widgets/AlignmentGrid.tsx +8 -14
  174. package/src/components/inspector-widgets/BorderEditor.tsx +41 -33
  175. package/src/components/inspector-widgets/ColorPicker.tsx +39 -39
  176. package/src/components/inspector-widgets/FontPicker.tsx +13 -21
  177. package/src/components/inspector-widgets/GradientEditor.tsx +11 -19
  178. package/src/components/inspector-widgets/ScrubbableInput.tsx +6 -3
  179. package/src/components/inspector-widgets/ShadowEditor.tsx +20 -33
  180. package/src/components/inspector-widgets/shared.tsx +90 -49
  181. package/src/components/inspectors/AnimationSection.tsx +39 -40
  182. package/src/components/inspectors/AudioSection.tsx +28 -39
  183. package/src/components/inspectors/BoneAttachmentSection.tsx +19 -24
  184. package/src/components/inspectors/CameraSection.tsx +14 -23
  185. package/src/components/inspectors/ComponentsSection.tsx +39 -43
  186. package/src/components/inspectors/JointsSection.tsx +34 -43
  187. package/src/components/inspectors/LightSection.tsx +90 -35
  188. package/src/components/inspectors/MaterialSection.tsx +137 -174
  189. package/src/components/inspectors/MeshSection.tsx +9 -17
  190. package/src/components/inspectors/NavigationSection.tsx +8 -15
  191. package/src/components/inspectors/ParticleSection.tsx +91 -76
  192. package/src/components/inspectors/PhysicsSection.tsx +42 -46
  193. package/src/components/inspectors/PostProcessingSection.tsx +28 -36
  194. package/src/components/inspectors/RenderingSection.tsx +12 -20
  195. package/src/components/inspectors/ShadowSection.tsx +7 -8
  196. package/src/components/inspectors/SplineSection.tsx +31 -42
  197. package/src/components/inspectors/TransformSection.tsx +14 -22
  198. package/src/components/patterns/Dialog.tsx +95 -0
  199. package/src/components/patterns/Fields.tsx +44 -0
  200. package/src/components/patterns/List.tsx +25 -0
  201. package/src/components/patterns/StateSurface.tsx +40 -0
  202. package/src/components/patterns/Surfaces.tsx +115 -0
  203. package/src/components/patterns/Tabs.tsx +80 -0
  204. package/src/components/patterns/Toolbar.tsx +51 -0
  205. package/src/components/patterns/Tree.tsx +72 -0
  206. package/src/components/primitives/Button.tsx +46 -0
  207. package/src/components/primitives/ColorInput.tsx +11 -47
  208. package/src/components/primitives/EditorIcon.tsx +99 -0
  209. package/src/components/primitives/FormControls.tsx +71 -0
  210. package/src/components/primitives/JsonInput.tsx +15 -2
  211. package/src/components/primitives/Layout.tsx +100 -0
  212. package/src/components/primitives/Menu.tsx +105 -0
  213. package/src/components/primitives/NumberInput.tsx +8 -35
  214. package/src/components/primitives/Panel.tsx +49 -44
  215. package/src/components/primitives/SectionHeader.tsx +10 -28
  216. package/src/components/primitives/Text.tsx +46 -0
  217. package/src/components/primitives/Tooltip.tsx +20 -12
  218. package/src/components/primitives/Vec3Input.tsx +3 -0
  219. package/src/components/primitives/banner-tones.ts +33 -0
  220. package/src/components/primitives/editor-icons.ts +102 -0
  221. package/src/components/primitives/panel-header-styles.ts +30 -0
  222. package/src/components/project-operation-documents.tsx +226 -0
  223. package/src/components/status-contributions.tsx +175 -0
  224. package/src/components/story-documents-register.ts +15 -0
  225. package/src/components/story-documents.tsx +1185 -0
  226. package/src/components/tool-documents.tsx +110 -0
  227. package/src/components/workspace-dock.css +293 -0
  228. package/src/components/workspace-history.ts +33 -0
  229. package/src/components/world-documents.tsx +145 -0
  230. package/src/components/xstate-documents.tsx +209 -0
  231. package/src/composite-screenshot.ts +248 -0
  232. package/src/design-system/index.ts +70 -0
  233. package/src/design-system-stories/Foundation.stories.tsx +183 -0
  234. package/src/design-system-stories/Patterns.stories.tsx +251 -0
  235. package/src/design-system-stories/Workspace.stories.tsx +538 -0
  236. package/src/doctor/report.ts +57 -13
  237. package/src/editor-api.ts +295 -31
  238. package/src/editor-compatibility.ts +200 -0
  239. package/src/editor-console.ts +35 -17
  240. package/src/editor-hotkeys.ts +85 -33
  241. package/src/editor-store.ts +538 -233
  242. package/src/editor-viewport.ts +160 -34
  243. package/src/entity-factory.ts +14 -3
  244. package/src/gizmo-registry.ts +21 -5
  245. package/src/harness-chat-editor-context.ts +37 -0
  246. package/src/harness-chat-indicator.ts +68 -0
  247. package/src/harness-chat-layout.ts +53 -0
  248. package/src/harness-chat-lifecycle.ts +170 -0
  249. package/src/harness-chat-reconnect.ts +85 -0
  250. package/src/harness-chat-types.ts +409 -0
  251. package/src/hierarchy-drop.ts +50 -0
  252. package/src/hierarchy-expansion-state.ts +100 -0
  253. package/src/hierarchy-menu-registry.ts +1 -1
  254. package/src/hierarchy-projection.ts +76 -0
  255. package/src/history/editor-session.ts +21 -0
  256. package/src/history/history-commands.ts +147 -0
  257. package/src/history/history-service.ts +904 -0
  258. package/src/history/json-history-resource.ts +165 -0
  259. package/src/history/persistence-coordinator.ts +35 -0
  260. package/src/history/project-file-history.ts +266 -0
  261. package/src/history/project-root-history-backends.ts +53 -0
  262. package/src/history/resource-registry.ts +209 -0
  263. package/src/history/snapshot-store.ts +108 -0
  264. package/src/history/source-history-backend.ts +374 -0
  265. package/src/history/types.ts +100 -0
  266. package/src/hmr-registration-group.ts +67 -0
  267. package/src/hmr-stable-react-context.ts +23 -0
  268. package/src/hosted-example.ts +12 -2
  269. package/src/hotkeys.ts +89 -18
  270. package/src/ingest/discovery-react.ts +9 -10
  271. package/src/ingest/discovery.ts +4 -4
  272. package/src/ingest/discovery2d.ts +5 -5
  273. package/src/ingest/games/bloom-composer/vgai.game.json +3 -3
  274. package/src/ingest/games/embed-bundle/vgai.game.json +3 -3
  275. package/src/ingest/games/games-fps/vgai.game.json +3 -3
  276. package/src/ingest/games/iframe-reachable/vgai.game.json +3 -3
  277. package/src/ingest/games/interactive-cubes/vgai.game.json +3 -3
  278. package/src/ingest/games/mismatched-three/vgai.game.json +3 -3
  279. package/src/ingest/games/own-three-bundled/vgai.game.json +3 -3
  280. package/src/ingest/games/pointerlock-maze/vgai.game.json +7 -4
  281. package/src/ingest/games-2d/bubbo-bubbo/vgai.game.json +3 -3
  282. package/src/ingest/games-2d/bunnymark/vgai.game.json +3 -3
  283. package/src/ingest/games-2d/flappy/vgai.game.json +3 -3
  284. package/src/ingest/games-2d/flappy-pixi/vgai.game.json +3 -3
  285. package/src/ingest/games-2d/puzzling-potions/vgai.game.json +3 -3
  286. package/src/ingest/games-2d/tilemap-camera/vgai.game.json +3 -3
  287. package/src/ingest/games-react/react-rpg/vgai.game.json +3 -3
  288. package/src/ingest/types.ts +1 -1
  289. package/src/ingest-mode.ts +47 -52
  290. package/src/ingest-siblings.ts +23 -33
  291. package/src/initial-scene.ts +1 -2
  292. package/src/main.tsx +16 -8
  293. package/src/manifest-project.ts +10 -12
  294. package/src/model-thumbnail.ts +261 -26
  295. package/src/module-mode.ts +44 -50
  296. package/src/play-log-events.ts +26 -0
  297. package/src/play-mode.ts +495 -401
  298. package/src/play-scene-adoption.ts +23 -0
  299. package/src/playground/main.ts +68 -24
  300. package/src/playwright-shim.ts +473 -0
  301. package/src/project-manager.ts +90 -10
  302. package/src/project-operations.ts +112 -0
  303. package/src/project-story-discovery.ts +25 -0
  304. package/src/project-tool-discovery.ts +43 -0
  305. package/src/scene-serializer.ts +0 -6
  306. package/src/scene-sync.ts +7 -2
  307. package/src/script-loader.ts +3 -3
  308. package/src/skeleton-helper.ts +57 -0
  309. package/src/storage/seed.ts +68 -8
  310. package/src/stories/StoryPreviewMount.tsx +265 -0
  311. package/src/stories/action-stories.fixture.tsx +44 -0
  312. package/src/stories/button-stories.fixture.tsx +50 -0
  313. package/src/stories/compose-project-stories.ts +182 -0
  314. package/src/stories/loader-stories.fixture.tsx +53 -0
  315. package/src/stories/story-discovery.ts +130 -0
  316. package/src/stories/story-registry.ts +146 -0
  317. package/src/theme-css.ts +1049 -0
  318. package/src/theme-library.ts +238 -0
  319. package/src/theme-preference.ts +130 -0
  320. package/src/theme.ts +527 -0
  321. package/src/tool-loader.ts +80 -24
  322. package/src/ui-source/inspect.ts +27 -4
  323. package/src/ui-source/source-write-backend.ts +83 -22
  324. package/src/widgets/index.ts +9 -18
  325. package/src/workspace-aux-commands.ts +11 -0
  326. package/src/workspace-dock-controller.ts +64 -0
  327. package/src/workspace-document-ids.ts +5 -0
  328. package/src/workspace-document-registry.ts +367 -0
  329. package/src/workspace-state-persistence.ts +455 -0
  330. package/src/workspace-status-registry.ts +110 -0
  331. package/src/workspace-utility-commands.ts +49 -0
  332. package/src/workspace-utility-registry.ts +161 -0
  333. package/src/world-document-routing.ts +16 -0
  334. package/src/xstate-inspect/XStateMachineInspector.tsx +544 -0
  335. package/src/xstate-inspect/character-animation-machine.fixture.ts +74 -0
  336. package/src/xstate-inspect/use-live-actor-state.ts +39 -0
  337. package/src/xstate-inspect/xstate-graph.ts +265 -0
  338. package/src/xstate-inspect/xstate-layout.ts +76 -0
  339. package/src/z-index.ts +26 -0
  340. package/template/.claude/skills/editor/SKILL.md +129 -90
  341. package/template/AGENTS.md +1110 -0
  342. package/template/CLAUDE.md +1 -512
  343. package/template/IDIOMS.md +50 -0
  344. package/template/check-idioms.ts +706 -0
  345. package/template/manifest-entry-modules-plugin.ts +53 -0
  346. package/template/package.json +13 -4
  347. package/template/public/scenes/default.scn2d.json +6 -0
  348. package/template/public/scenes/default.vscn.json +7 -1
  349. package/template/public/scenes/untitled.vscn.json +10 -0
  350. package/template/scripts/playtest.ts +40 -0
  351. package/template/server/loopback.ts +75 -0
  352. package/template/server/rooms/arena-room.ts +69 -1
  353. package/template/server/rooms/game-room.ts +68 -1
  354. package/template/src/data/README.md +9 -4
  355. package/template/src/data/data-ref.ts +35 -0
  356. package/template/src/data/tuning.schema.ts +1 -1
  357. package/template/src/main.ts +41 -62
  358. package/template/src/manifest-entry-modules.d.ts +3 -0
  359. package/template/src/operations/README.md +88 -0
  360. package/template/src/runtime/pixi-adapter.ts +24 -0
  361. package/template/src/scripts/components/data-spinner.ts +43 -2
  362. package/template/src/scripts/components/spin-lap-math.ts +27 -0
  363. package/template/src/tools/README.md +5 -3
  364. package/template/src/tools/example.tool.tsx +13 -7
  365. package/template/src/ui/Button.stories.tsx +66 -0
  366. package/template/src/ui/RebindPanel.tsx +2 -6
  367. package/template/src/ui/game.tsx +17 -0
  368. package/template/tests/acceptance/example.spec.ts +64 -0
  369. package/template/tests/logic/example.test.ts +31 -0
  370. package/template/tests/logic/room.test.ts +100 -0
  371. package/template/tests/tsconfig.json +30 -0
  372. package/template/validate-manifest.ts +5 -5
  373. package/template/validate-scenes.ts +50 -18
  374. package/template/vgai.game.json +20 -13
  375. package/template/vite.config.ts +77 -16
  376. package/vite-plugin-ui-oid.ts +261 -17
  377. package/dist/assets/CCDIKSolver-Dz-ysxZF.js +0 -1
  378. package/dist/assets/SelectionBox-BXsv_6aH.js +0 -1
  379. package/dist/assets/browser-project-scripts-DOru4L1x.js +0 -2
  380. package/dist/assets/browserAll-CEkWYlFk.js +0 -1
  381. package/dist/assets/create-runtime-DYIWr3va.js +0 -1
  382. package/dist/assets/data-asset-Bb9sIM7i.js +0 -5
  383. package/dist/assets/game-8SDZPPgH.js +0 -1
  384. package/dist/assets/game-CevorxyV.js +0 -1
  385. package/dist/assets/game-Di7XlywD.js +0 -1
  386. package/dist/assets/game-DjnCaw8R.js +0 -1
  387. package/dist/assets/game-Q9A0aXSV.js +0 -1
  388. package/dist/assets/index-CPVV6pjw.js +0 -1
  389. package/dist/assets/index-CpOnYyeD.js +0 -213
  390. package/dist/assets/index-Dr8H_6Rl.js +0 -1
  391. package/dist/assets/index-_bCaipUk.js +0 -13650
  392. package/dist/assets/ingest-siblings-Dz8eEqrV.js +0 -1
  393. package/dist/assets/mount-manifest-Bhk0ohsl.js +0 -1
  394. package/dist/assets/scene-query-BivAHhJu.js +0 -1
  395. package/dist/assets/webworkerAll-BLrH0F6F.js +0 -1
  396. package/dist/assets/xstate-animation-binding-1NGLmw6y.js +0 -1
  397. package/dist/assets/xstate.esm-BgUD33nl.js +0 -3
  398. package/src/authoring/scene-ui-layer.ts +0 -485
  399. package/src/authoring/ui-authoring-adapter.ts +0 -325
  400. package/src/authoring/ui-hierarchy-menu.ts +0 -40
  401. package/src/authoring/ui-inspector-sections.tsx +0 -156
  402. package/src/authoring/undo-timeline.ts +0 -145
  403. package/src/components/BottomPanel.tsx +0 -326
  404. package/src/components/BuildPanel.tsx +0 -455
  405. package/src/components/FolderTree.tsx +0 -179
  406. package/src/components/RightPanel.tsx +0 -166
  407. package/src/components/WorldContextMenu.tsx +0 -257
  408. package/src/components/asset-viewers/AssetViewer.tsx +0 -38
  409. package/src/ingest/games-react/react-rpg/src/ui/game-state.tsx +0 -129
  410. package/src/scene-ui-mode/active-scene-ui.ts +0 -58
  411. package/src/scene-ui-mode/scene-ui-palette-panel.tsx +0 -152
  412. package/src/scene-ui-mode/scene-ui-timeline-tab.tsx +0 -29
  413. package/src/scene-ui-mode/ui-prefab-dialog-host.tsx +0 -74
  414. package/src/scene-ui-mode/ui-prefab-hierarchy-menu.ts +0 -49
  415. package/src/scene-ui-mode/ui-prefab-io.ts +0 -76
  416. package/src/ui-editor/inspector.tsx +0 -563
  417. package/src/ui-editor/node-ops.ts +0 -266
  418. package/src/ui-editor/overlay.tsx +0 -380
  419. package/src/ui-editor/palette.tsx +0 -114
  420. package/src/ui-editor/react-store.ts +0 -78
  421. package/src/ui-editor/timeline-panel.tsx +0 -443
  422. package/src/ui-editor/ui-edit-store.ts +0 -552
  423. package/src/ui-editor/ui-prefab.ts +0 -95
  424. package/template/project.json +0 -9
  425. package/template/src/scripts/main.ts +0 -94
  426. package/template/src/ui/DefaultHud.tsx +0 -40
  427. package/template/src/ui/game-state.tsx +0 -37
  428. package/template/src/ui/mount-default-hud.tsx +0 -19
  429. package/template/src/ui/mount-ui.ts +0 -15
  430. package/vite-plugin-react-world-provider.ts +0 -81
@@ -7,19 +7,43 @@
7
7
 
8
8
  import { type ChildProcess, execFile, spawn } from 'node:child_process';
9
9
  import { createHash, randomUUID } from 'node:crypto';
10
- import { existsSync } from 'node:fs';
10
+ import { existsSync, readFileSync } from 'node:fs';
11
11
  import { appendFile, mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
12
+ import type { ServerResponse } from 'node:http';
12
13
  import { homedir, platform } from 'node:os';
13
- import { dirname, join, relative, resolve, sep } from 'node:path';
14
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
14
15
  import chokidar, { type FSWatcher } from 'chokidar';
15
16
  import { type ScaffoldTemplate, scaffoldProject } from 'create-vgai-project';
16
17
  import type { Request, Response, Router } from 'express';
17
18
  import express from 'express';
19
+ import { GAME_MANIFEST_VERSION } from '../../engine/src/manifest/schema';
18
20
  import { applyAssetRename } from '../../engine/src/scene/asset-paths';
19
21
  import type { SceneFile } from '../../engine/src/scene/scene-types';
22
+ import {
23
+ assertEditorCompatibility,
24
+ assertProjectCompatibility,
25
+ type EditorServerCompatibility,
26
+ ProjectCompatibilityError,
27
+ } from '../src/editor-compatibility';
28
+ import { parseHarnessChatAction } from '../src/harness-chat-types';
20
29
  import { createAssetLibraryRouter } from './asset-library-routes';
21
30
  import { canonicalProjectRoot } from './canonical-path';
22
- import { addClient, broadcast, clientCount, removeClient } from './editor-sse';
31
+ import {
32
+ addClient,
33
+ broadcast,
34
+ clientCount,
35
+ controllerClientId,
36
+ removeClient,
37
+ sendToController,
38
+ updateClientControlHealth,
39
+ } from './editor-sse';
40
+ import { HarnessChatService } from './harness-chat-service';
41
+ import {
42
+ discoverProjectOperations,
43
+ executeProjectOperation,
44
+ type ProjectModuleLoader,
45
+ } from './project-operations';
46
+ import { classifyValidatableFile, validateProjectFile } from './project-validation';
23
47
  import { readProjectView } from './project-view';
24
48
  import type { CommandResult } from './server-utils';
25
49
  import {
@@ -33,6 +57,7 @@ import {
33
57
  isServableFsExtension,
34
58
  PACKAGED_NO_MONOREPO_TEMPLATE_ERROR,
35
59
  resolveInstalledPackageSrcDir,
60
+ resolveWatcherPollOptions,
36
61
  sanitizeContentDispositionFilename,
37
62
  validateManifestWrite,
38
63
  } from './server-utils';
@@ -64,8 +89,8 @@ async function loadRecentProjects(): Promise<RecentProject[]> {
64
89
  // Filter out projects whose paths no longer exist, populate thumbnails
65
90
  const valid: RecentProject[] = [];
66
91
  for (const p of projects) {
67
- // §1C: a manifest-only folder (no project.json) is still a valid
68
- // recent project — readProjectView is the one place this rule lives.
92
+ // A folder with a valid v2 manifest is a project; readProjectView is
93
+ // the one place this rule lives.
69
94
  const view = await readProjectView(p.path);
70
95
  if (!view) continue; // Path no longer exists / neither file describes it — skip
71
96
  // Check for thumbnail
@@ -114,6 +139,28 @@ export interface EditorServerOptions {
114
139
  serveScripts?: boolean;
115
140
  /** Called when a project is opened at runtime — allows the dev server to update Vite's fs.allow. */
116
141
  onProjectOpened?: ((projectPath: string) => void) | undefined;
142
+ /** Dev checkout only: mark this long-running process stale when server-loaded source changes. */
143
+ watchEngineSource?: boolean | undefined;
144
+ /** Node-side project-module loader. Vite's ssrLoadModule supplies this in
145
+ * dev and packaged editors so discoverable project operations can remain
146
+ * ordinary TypeScript using the project's own dependency graph. */
147
+ loadProjectModule?: ProjectModuleLoader | undefined;
148
+ }
149
+
150
+ function readRunningEngineVersion(engineRoot: string): string | null {
151
+ const candidates = [
152
+ join(engineRoot, 'packages', 'engine', 'package.json'),
153
+ join(engineRoot, 'node_modules', '@vgai', 'engine', 'package.json'),
154
+ ];
155
+ for (const path of candidates) {
156
+ try {
157
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as { version?: unknown };
158
+ if (typeof parsed.version === 'string') return parsed.version;
159
+ } catch {
160
+ // Try the next supported checkout/installed-package layout.
161
+ }
162
+ }
163
+ return null;
117
164
  }
118
165
 
119
166
  // ---------------------------------------------------------------------------
@@ -158,6 +205,14 @@ async function findFiles(dir: string, ext: string): Promise<string[]> {
158
205
  return results;
159
206
  }
160
207
 
208
+ async function unlinkIfPresent(path: string): Promise<void> {
209
+ try {
210
+ await unlink(path);
211
+ } catch (error) {
212
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
213
+ }
214
+ }
215
+
161
216
  async function scanDirForHashes(
162
217
  dir: string,
163
218
  hashCache: Map<string, string>,
@@ -209,12 +264,74 @@ const mimeTypes: Record<string, string> = {
209
264
  // Factory
210
265
  // ---------------------------------------------------------------------------
211
266
 
212
- export function createEditorServer(options: EditorServerOptions): Router {
267
+ export type EditorServerRouter = Router & { close(): Promise<void> };
268
+
269
+ export function createEditorServer(options: EditorServerOptions): EditorServerRouter {
213
270
  const { engineRoot } = options;
271
+ const serverStartedAt = new Date().toISOString();
272
+ const runningEngineVersion = readRunningEngineVersion(engineRoot);
273
+ let staleSource: { changedPath: string; changedAt: string } | null = null;
274
+ let engineSourceWatcher: FSWatcher | null = null;
275
+
276
+ if (options.watchEngineSource) {
277
+ const watchedPaths = [
278
+ join(engineRoot, 'packages', 'editor', 'server'),
279
+ join(engineRoot, 'packages', 'engine', 'src'),
280
+ join(engineRoot, 'packages', 'create-vgai-project', 'src'),
281
+ join(engineRoot, 'vite.config.ts'),
282
+ join(engineRoot, 'package.json'),
283
+ join(engineRoot, 'package-lock.json'),
284
+ join(engineRoot, 'tsconfig.json'),
285
+ join(engineRoot, 'tsconfig.server.json'),
286
+ join(engineRoot, 'packages', 'engine', 'package.json'),
287
+ join(engineRoot, 'packages', 'editor', 'package.json'),
288
+ ].filter(existsSync);
289
+ engineSourceWatcher = chokidar.watch(watchedPaths, {
290
+ ignoreInitial: true,
291
+ ignored: (watchedPath) => {
292
+ const name = basename(watchedPath);
293
+ return name.startsWith('.') || name.endsWith('~') || /\.(?:swp|swo|tmp|temp)$/i.test(name);
294
+ },
295
+ });
296
+ engineSourceWatcher.on('all', (_event, changedPath: string) => {
297
+ staleSource ??= {
298
+ changedPath: relative(engineRoot, resolve(changedPath)).split(sep).join('/'),
299
+ changedAt: new Date().toISOString(),
300
+ };
301
+ });
302
+ }
214
303
 
215
304
  // Mutable project state — updated by open-project endpoint
216
305
  let projectRoot = options.projectPath ?? engineRoot;
217
306
  let publicRoot = resolve(projectRoot, 'public');
307
+ const harnessChat = new HarnessChatService({
308
+ engineRoot,
309
+ getProjectRoot: () => projectRoot,
310
+ onChange: (snapshot) => broadcast('harness-chat', snapshot),
311
+ });
312
+
313
+ const compatibilityIdentity = (): EditorServerCompatibility => ({
314
+ apiVersion: 1,
315
+ engineVersion: runningEngineVersion,
316
+ manifestVersion: GAME_MANIFEST_VERSION,
317
+ startedAt: serverStartedAt,
318
+ source: staleSource ? { state: 'restart-required', ...staleSource } : { state: 'current' },
319
+ });
320
+
321
+ // #run2-cold-vite: last time this server served the editor's own INDEX
322
+ // page (as opposed to an API/SSE/asset request) to a browser. Detected by
323
+ // response Content-Type rather than by route/path, because the index page
324
+ // is served by three different mechanisms depending on mode (Vite's dev
325
+ // middleware at `/` in dev.ts, `express.static` + SPA fallback in
326
+ // prod.ts, the packaged server's own fallback) and none of those routes
327
+ // are owned by this file. Exposed on `/__editor/state` so the CLI's
328
+ // verified-open polling (`waitForVerifiedEditorOpen` in
329
+ // packages/vgai-cli/src/editor-sessions.ts) can tell "the browser tab
330
+ // arrived and is loading" apart from "the auto-open never reached a
331
+ // browser at all" — the distinction a cold Vite dep-optimize on drvfs
332
+ // needs, since that alone can blow past the old flat 15s timeout on a
333
+ // project's very first page load.
334
+ let lastIndexRequestAt: number | null = null;
218
335
 
219
336
  // ---- File hash cache + move detection ----
220
337
  const fileHashCache = new Map<string, string>();
@@ -277,6 +394,7 @@ export function createEditorServer(options: EditorServerOptions): Router {
277
394
  // ---- Chokidar watcher ----
278
395
  let watcher: FSWatcher | null = null;
279
396
  let srcWatcher: FSWatcher | null = null;
397
+ let manifestWatcher: FSWatcher | null = null;
280
398
 
281
399
  // Coalesce event bursts (e.g. a git checkout adding several files at once)
282
400
  // into one broadcast per event name, trailing ~250ms after the last event.
@@ -293,20 +411,165 @@ export function createEditorServer(options: EditorServerOptions): Router {
293
411
  );
294
412
  }
295
413
 
414
+ // ---- Validate-on-change (owner decision, GitHub #103) ----
415
+ //
416
+ // Hand-editing project files is an accepted authoring path — the
417
+ // requirement is that invalid output surfaces IMMEDIATELY and OBVIOUSLY
418
+ // instead of only erroring at next load. `project-validation.ts` owns the
419
+ // content check (reusing the engine's own Zod parsers, same ones
420
+ // validate-scenes.ts/validate-manifest.ts run); this section owns
421
+ // debouncing + the three report surfaces: the terminal, connected editor
422
+ // tabs (the existing `server-log` SSE event `editor-console.ts` already
423
+ // listens for — previously unused, since nothing ever broadcast it), and
424
+ // `/__editor/state`'s `projectValidation` field.
425
+ //
426
+ // A file is present in `projectValidation` ONLY while it is currently
427
+ // failing — a valid write removes its entry entirely ("ok" is represented
428
+ // by absence, not a stored value), which is what keeps the happy path
429
+ // silent both on the wire and in the terminal (rule 4: editor-originated
430
+ // autosaves validate clean by construction and must produce zero noise).
431
+ interface FileValidationFailure {
432
+ errors: string[];
433
+ at: number;
434
+ }
435
+ const projectValidation = new Map<string, FileValidationFailure>();
436
+ const validationDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
437
+ const VALIDATION_DEBOUNCE_MS = 150;
438
+ const MAX_PRINTED_ERRORS = 5;
439
+
440
+ /** Project-relative, forward-slash path — the key `projectValidation` and
441
+ * the terminal/SSE messages use. */
442
+ function relKey(absPath: string): string {
443
+ return relative(projectRoot, absPath).split(sep).join('/');
444
+ }
445
+
446
+ function printValidationFailure(rel: string, errors: string[]): void {
447
+ const shown = errors.slice(0, MAX_PRINTED_ERRORS);
448
+ const rest = errors.length - shown.length;
449
+ console.error(
450
+ `\n \x1b[31m\x1b[1m✖ Invalid project file\x1b[0m \x1b[36m${rel}\x1b[0m\n` +
451
+ shown.map((e) => ` \x1b[31m•\x1b[0m ${e}`).join('\n') +
452
+ (rest > 0 ? `\n … and ${rest} more issue(s)` : '') +
453
+ '\n',
454
+ );
455
+ }
456
+
457
+ function printValidationRecovery(rel: string): void {
458
+ console.log(` \x1b[32m✓ ${rel} is valid again\x1b[0m`);
459
+ }
460
+
461
+ function validationMessage(rel: string, errors: string[]): string {
462
+ const shown = errors.slice(0, MAX_PRINTED_ERRORS);
463
+ const rest = errors.length - shown.length;
464
+ return (
465
+ `Invalid project file ${rel}:\n` +
466
+ shown.map((e) => ` - ${e}`).join('\n') +
467
+ (rest > 0 ? `\n … and ${rest} more issue(s)` : '')
468
+ );
469
+ }
470
+
471
+ /** Run (already-debounced) validation for one absolute path and report the
472
+ * result on all three surfaces. Non-validatable paths are a silent no-op
473
+ * so callers can invoke this unconditionally from watcher events. */
474
+ async function runFileValidation(absPath: string): Promise<void> {
475
+ const kind = classifyValidatableFile(absPath);
476
+ if (!kind) return;
477
+ const rel = relKey(absPath);
478
+ const result = await validateProjectFile(absPath, kind);
479
+ const wasFailing = projectValidation.has(rel);
480
+
481
+ if (result.ok) {
482
+ if (wasFailing) {
483
+ projectValidation.delete(rel);
484
+ printValidationRecovery(rel);
485
+ broadcast('server-log', { level: 'info', message: `${rel} is valid again` });
486
+ }
487
+ // Clean write, never previously failing — the happy-path silence rule:
488
+ // no terminal line, no broadcast, no state entry.
489
+ return;
490
+ }
491
+
492
+ projectValidation.set(rel, { errors: result.errors, at: Date.now() });
493
+ printValidationFailure(rel, result.errors);
494
+ broadcast('server-log', { level: 'error', message: validationMessage(rel, result.errors) });
495
+ }
496
+
497
+ /** Debounce per absolute file path (~150ms) so a burst of writes to the
498
+ * same file (editors/OSes routinely write-then-touch, or write in two
499
+ * chunks) triggers exactly one validation pass. */
500
+ function scheduleFileValidation(absPath: string): void {
501
+ if (!classifyValidatableFile(absPath)) return;
502
+ const existing = validationDebounceTimers.get(absPath);
503
+ if (existing) clearTimeout(existing);
504
+ validationDebounceTimers.set(
505
+ absPath,
506
+ setTimeout(() => {
507
+ validationDebounceTimers.delete(absPath);
508
+ void runFileValidation(absPath);
509
+ }, VALIDATION_DEBOUNCE_MS),
510
+ );
511
+ }
512
+
513
+ /** A deleted file can no longer be "invalid" — drop any stale entry/timer
514
+ * so `projectValidation` doesn't keep reporting a ghost error. */
515
+ function clearFileValidation(absPath: string): void {
516
+ const timer = validationDebounceTimers.get(absPath);
517
+ if (timer) {
518
+ clearTimeout(timer);
519
+ validationDebounceTimers.delete(absPath);
520
+ }
521
+ projectValidation.delete(relKey(absPath));
522
+ }
523
+
524
+ async function validateSourceTree(dir: string): Promise<void> {
525
+ let entries;
526
+ try {
527
+ entries = await readdir(dir, { withFileTypes: true });
528
+ } catch {
529
+ return;
530
+ }
531
+ await Promise.all(
532
+ entries
533
+ .filter((entry) => !entry.name.startsWith('.'))
534
+ .map(async (entry) => {
535
+ const full = join(dir, entry.name);
536
+ if (entry.isDirectory()) return validateSourceTree(full);
537
+ if (entry.isFile()) return runFileValidation(full);
538
+ }),
539
+ );
540
+ }
541
+
296
542
  function startWatcher(): void {
297
543
  watcher?.close();
298
544
  srcWatcher?.close();
545
+ manifestWatcher?.close();
299
546
  srcWatcher = null;
547
+ manifestWatcher = null;
300
548
  fileHashCache.clear();
301
549
  fileSizeCache.clear();
302
550
  pendingUnlinks.length = 0;
551
+ // Validation state is project-scoped — a previous project's failures
552
+ // must not leak into (or block reporting for) the newly opened one.
553
+ for (const timer of validationDebounceTimers.values()) clearTimeout(timer);
554
+ validationDebounceTimers.clear();
555
+ projectValidation.clear();
303
556
 
304
557
  // Seed hash cache in background
305
558
  scanDirForHashes(publicRoot, fileHashCache, fileSizeCache);
306
559
 
560
+ // #131: on drvfs (WSL /mnt/<drive>) inotify never fires, so without
561
+ // polling every broadcast below is dead and the editor UI sits stale
562
+ // until a manual refresh. Auto-detection keys off the PROJECT root and is
563
+ // skipped in engine-repo mode (projectRoot === engineRoot), where
564
+ // `public/` includes the vendored-game trees — `VGAI_WATCH_POLL` still
565
+ // forces it there. Re-derived on every startWatcher() call so a project
566
+ // switch onto/off a drvfs mount re-decides correctly.
567
+ const pollOptions = resolveWatcherPollOptions(projectRoot === engineRoot ? null : projectRoot);
568
+
307
569
  watcher = chokidar.watch(publicRoot, {
308
570
  ignoreInitial: true,
309
571
  ignored: /(^|[/\\])\../, // ignore dotfiles
572
+ ...pollOptions,
310
573
  });
311
574
 
312
575
  watcher.on('change', async (filePath: string) => {
@@ -330,11 +593,17 @@ export function createEditorServer(options: EditorServerOptions): Router {
330
593
  /* file may have been deleted between events */
331
594
  }
332
595
  }
596
+
597
+ // Validate-on-change (#103): a hand-edited (or autosaved) .vscn.json/
598
+ // .prefab.json content change gets checked against the engine's own
599
+ // Zod parsers — see the "Validate-on-change" section above.
600
+ scheduleFileValidation(abs);
333
601
  });
334
602
 
335
603
  watcher.on('unlink', (filePath: string) => {
336
604
  const abs = resolve(filePath);
337
605
  if (!isPathInside(publicRoot, abs)) return;
606
+ clearFileValidation(abs);
338
607
  const hash = fileHashCache.get(abs);
339
608
  const size = fileSizeCache.get(abs);
340
609
  if (hash !== undefined && size !== undefined) {
@@ -348,6 +617,9 @@ export function createEditorServer(options: EditorServerOptions): Router {
348
617
  watcher.on('add', async (filePath: string) => {
349
618
  const abs = resolve(filePath);
350
619
  if (!isPathInside(publicRoot, abs)) return;
620
+ // A brand-new scene/prefab file (not just an edit of an existing one)
621
+ // deserves the same immediate check.
622
+ scheduleFileValidation(abs);
351
623
 
352
624
  try {
353
625
  const { hash, size } = await hashAndSize(abs);
@@ -382,30 +654,64 @@ export function createEditorServer(options: EditorServerOptions): Router {
382
654
  // packaged/prod (this module is shared by both), and on drvfs
383
655
  // (`/mnt/c`, no inotify) via the same `VGAI_WATCH_POLL` env var.
384
656
  //
385
- // add/unlink ONLY, never `change`: tool-content edits already ride the
386
- // existing `vgai:script-update` HMR path, and a data-file `change` event
387
- // would echo the DataPanel's own `/__editor/data-file` POST writes back
388
- // into a refetch, clobbering in-flight drafts.
657
+ // Tool/data/story lists use add/unlink. Operation modules also use change:
658
+ // they are Node-loaded rather than browser-HMR-loaded, so metadata and
659
+ // implementation edits require a fresh catalog request.
389
660
  const srcDir = join(projectRoot, 'src');
390
661
  if (projectRoot !== engineRoot && existsSync(srcDir)) {
391
- // Same parse as dev.ts's `startProjectPollWatcher` (VGAI_WATCH_POLL=1,
392
- // or =<ms> for the interval).
393
- const pollRaw = Number(process.env['VGAI_WATCH_POLL']);
662
+ // #131: same polling decision as the publicRoot watcher above (env
663
+ // contract matches dev.ts's poller; auto-on for drvfs projects).
394
664
  srcWatcher = chokidar.watch(srcDir, {
395
665
  ignoreInitial: true,
396
666
  ignored: /(^|[/\\])\../, // ignore dotfiles
397
- ...(process.env['VGAI_WATCH_POLL']
398
- ? { usePolling: true, interval: pollRaw > 1 ? pollRaw : 1000 }
399
- : {}),
667
+ ...pollOptions,
400
668
  });
401
669
  const onListChange = (filePath: string) => {
402
670
  const rel = relative(projectRoot, resolve(filePath)).split(sep).join('/');
403
671
  const kind = classifyProjectSrcPath(rel);
404
- if (kind === 'tools') broadcastDebounced('tool-files-changed', { path: rel });
405
- else if (kind === 'data') broadcastDebounced('data-files-changed', { path: rel });
672
+ const event = kind
673
+ ? {
674
+ tools: 'tool-files-changed',
675
+ data: 'data-files-changed',
676
+ stories: 'story-files-changed',
677
+ operations: 'operation-files-changed',
678
+ }[kind]
679
+ : undefined;
680
+ if (event) broadcastDebounced(event, { path: rel });
406
681
  };
407
682
  srcWatcher.on('add', onListChange);
408
683
  srcWatcher.on('unlink', onListChange);
684
+ // Tool edits ride Vite HMR and data edits have their own live-value
685
+ // path. Node-loaded project operations instead need a fresh catalog
686
+ // read when metadata or implementation changes in place.
687
+ srcWatcher.on('change', (filePath) => {
688
+ const rel = relative(projectRoot, resolve(filePath)).split(sep).join('/');
689
+ if (classifyProjectSrcPath(rel) === 'operations') {
690
+ broadcastDebounced('operation-files-changed', { path: rel });
691
+ }
692
+ });
693
+ srcWatcher.on('add', (filePath) => scheduleFileValidation(resolve(filePath)));
694
+ srcWatcher.on('change', (filePath) => scheduleFileValidation(resolve(filePath)));
695
+ srcWatcher.on('unlink', (filePath) => clearFileValidation(resolve(filePath)));
696
+ void validateSourceTree(srcDir);
697
+ }
698
+
699
+ // ---- Third watcher: <project>/vgai.game.json (#103 validate-on-change) ----
700
+ //
701
+ // The manifest lives at the PROJECT ROOT, outside both `publicRoot` and
702
+ // `src/` — neither watcher above ever sees it. It's a single fixed-name
703
+ // file, so this watches that exact path rather than a directory (chokidar
704
+ // supports watching a not-yet-existing file and reports its later `add`).
705
+ if (projectRoot !== engineRoot) {
706
+ const manifestPath = join(projectRoot, 'vgai.game.json');
707
+ // #131: same polling decision as the watchers above.
708
+ manifestWatcher = chokidar.watch(manifestPath, {
709
+ ignoreInitial: true,
710
+ ...pollOptions,
711
+ });
712
+ manifestWatcher.on('add', () => scheduleFileValidation(manifestPath));
713
+ manifestWatcher.on('change', () => scheduleFileValidation(manifestPath));
714
+ manifestWatcher.on('unlink', () => clearFileValidation(manifestPath));
409
715
  }
410
716
  }
411
717
 
@@ -428,11 +734,25 @@ export function createEditorServer(options: EditorServerOptions): Router {
428
734
 
429
735
  // ---- Editor command relay + state ----
430
736
  let editorState: Record<string, unknown> = {};
737
+ const editorStatesByClient = new Map<
738
+ string,
739
+ { state: Record<string, unknown>; updatedAt: number }
740
+ >();
741
+ // When `editorState` was last POSTed by a browser tab (epoch ms), so a
742
+ // caller reading a cached snapshot (no live SSE client, `connected: false`
743
+ // below) can say HOW STALE it is instead of just THAT it's stale. `null`
744
+ // until the first POST of this server's lifetime (or after a project
745
+ // switch clears the cache).
746
+ let editorStateUpdatedAt: number | null = null;
431
747
 
432
748
  // Pending command results — keyed by requestId, resolved by browser POST
433
749
  const pendingCommands = new Map<
434
750
  string,
435
- { resolve: (result: CommandResult) => void; timer: ReturnType<typeof setTimeout> }
751
+ {
752
+ resolve: (result: CommandResult) => void;
753
+ timer: ReturnType<typeof setTimeout>;
754
+ controller?: ServerResponse;
755
+ }
436
756
  >();
437
757
  const COMMAND_TIMEOUT_MS = 5000;
438
758
  // `play` is the one command whose ack is gated on the GAME, not the editor:
@@ -442,11 +762,70 @@ export function createEditorServer(options: EditorServerOptions): Router {
442
762
  // "editor connected but did not respond" while play in fact proceeded and
443
763
  // succeeded (hit dogfooding a voxel game whose worldgen alone outlives 5s).
444
764
  const PLAY_COMMAND_TIMEOUT_MS = 120_000;
765
+ // `stop`'s ack is gated on teardown the same way play's is gated on setup:
766
+ // `exitPlayMode()` disposes the whole game (component dispose chain, Rapier
767
+ // world free, renderer teardown). Under software rendering (SwiftShader on
768
+ // a headless/CI box) that routinely outlives the generic 5s window — the
769
+ // command reported "did not respond" while the stop in fact completed
770
+ // (dogfooded 2026-07-12 on a scaffolded project). 30s is generous for any
771
+ // teardown while still failing fast relative to play's 120s setup budget.
772
+ const STOP_COMMAND_TIMEOUT_MS = 30_000;
773
+ // Asset Lab capture loads a local model, renders four deterministic views,
774
+ // and encodes five PNGs before acknowledging. Give that bounded local job a
775
+ // real rendering budget instead of the generic interaction timeout.
776
+ const ASSET_PREVIEW_COMMAND_TIMEOUT_MS = 30_000;
777
+
778
+ /** Fail every in-flight command with one error — used when the last SSE
779
+ * client goes away: a reloaded page has no memory of the requestId it was
780
+ * supposed to answer, so waiting out the timer only delays the inevitable
781
+ * (and mislabels a reload as "editor did not respond"). */
782
+ function failPendingCommands(error: string): void {
783
+ for (const [id, pending] of pendingCommands) {
784
+ clearTimeout(pending.timer);
785
+ pendingCommands.delete(id);
786
+ pending.resolve({ ok: false, timedOut: true, error });
787
+ }
788
+ }
789
+
790
+ function failCommandsOwnedBy(controller: ServerResponse): void {
791
+ for (const [id, pending] of pendingCommands) {
792
+ if (pending.controller !== controller) continue;
793
+ clearTimeout(pending.timer);
794
+ pendingCommands.delete(id);
795
+ pending.resolve({
796
+ ok: false,
797
+ timedOut: true,
798
+ error: 'The editor tab controlling this command disconnected before reporting a result.',
799
+ });
800
+ }
801
+ }
445
802
 
446
803
  // ---- Router ----
447
- const router = express.Router();
804
+ const router = express.Router() as EditorServerRouter;
448
805
  router.use(express.json({ limit: '50mb' }));
449
806
 
807
+ // ---- Cold-Vite verified-open signal ----
808
+ // Stamp `lastIndexRequestAt` for any GET response that turns out to be
809
+ // genuine HTML (status 2xx, `Content-Type: text/html…`) — that is, purely
810
+ // observational, via `res.on('finish')`, so it applies no matter which
811
+ // downstream middleware (Vite, express.static, the SPA fallback) actually
812
+ // produced the response. API responses are JSON, SSE is
813
+ // `text/event-stream`, assets carry their own MIME types — none of those
814
+ // match, so only real page loads stamp.
815
+ router.use((req: Request, res: Response, next) => {
816
+ if (req.method === 'GET') {
817
+ res.on('finish', () => {
818
+ if (res.statusCode >= 200 && res.statusCode < 300) {
819
+ const contentType = res.get('Content-Type');
820
+ if (contentType?.includes('text/html')) {
821
+ lastIndexRequestAt = Date.now();
822
+ }
823
+ }
824
+ });
825
+ }
826
+ next();
827
+ });
828
+
450
829
  // ---- CSRF / drive-by protection (S3) ----
451
830
  // Mutating /__editor/* routes can write files, switch projects, and run npm
452
831
  // builds (RCE). A malicious web page could POST to a locally-bound editor; an
@@ -474,7 +853,7 @@ export function createEditorServer(options: EditorServerOptions): Router {
474
853
  );
475
854
 
476
855
  // ---- Project file serving middleware ----
477
- // Serves project.json from root and static files from project's public/
856
+ // Serves the v2 manifest from root and static files from project's public/
478
857
  router.use((req: Request, res: Response, next) => {
479
858
  // Only handle non-API requests when a project is open
480
859
  if (projectRoot === engineRoot) return next();
@@ -485,24 +864,11 @@ export function createEditorServer(options: EditorServerOptions): Router {
485
864
  if (pathname.startsWith('/@')) return next();
486
865
  if (pathname.startsWith('/node_modules')) return next();
487
866
 
488
- // Serve project.json (or its manifest-synthesized compat view — §1C)
489
- // from the project root, not public/.
490
- if (pathname === '/project.json') {
491
- readProjectView(projectRoot)
492
- .then((view) => {
493
- if (!view) return next();
494
- res.json(view);
495
- })
496
- .catch(() => next());
497
- return;
498
- }
499
-
500
867
  // Serve the RAW manifest (no view synthesis — the browser-side adapter
501
868
  // resolver parses it itself via the pure `@engine/manifest/load` half;
502
869
  // T3.3 slice 2 part C) from the project root, not public/. 404s (falls
503
870
  // through to `next()`, same as any other missing project-root file) for
504
- // legacy `project.json`-only projects — that is what makes the manifest
505
- // branch a no-op for them.
871
+ // folders without a valid manifest.
506
872
  if (pathname === '/vgai.game.json') {
507
873
  readFile(join(projectRoot, 'vgai.game.json'), 'utf-8')
508
874
  .then((raw) => {
@@ -530,6 +896,12 @@ export function createEditorServer(options: EditorServerOptions): Router {
530
896
  .catch(() => next());
531
897
  });
532
898
 
899
+ // ---- Browser/server compatibility identity ----
900
+ router.get('/__editor/compatibility', (_req: Request, res: Response) => {
901
+ res.set('Cache-Control', 'no-store');
902
+ res.json(compatibilityIdentity());
903
+ });
904
+
533
905
  // ---- Current project info ----
534
906
  router.get('/__editor/project', async (_req: Request, res: Response) => {
535
907
  if (projectRoot === engineRoot) {
@@ -570,37 +942,86 @@ export function createEditorServer(options: EditorServerOptions): Router {
570
942
  Connection: 'keep-alive',
571
943
  });
572
944
  res.write(':ok\n\n');
573
- addClient(res);
574
- req.on('close', () => removeClient(res));
945
+ const clientId = typeof req.query['clientId'] === 'string' ? req.query['clientId'] : undefined;
946
+ addClient(res, clientId);
947
+ req.on('close', () => {
948
+ removeClient(res);
949
+ failCommandsOwnedBy(res);
950
+ // Last tab gone with commands still in flight: nobody left can answer
951
+ // them (a reload mints a fresh page with no memory of the requestId),
952
+ // so fail them NOW with the real reason instead of letting the timer
953
+ // expire into a misleading "editor connected but did not respond".
954
+ // The classic trigger is a mid-command full page reload — e.g. Vite
955
+ // discovering/re-optimizing deps on a first play (dogfooded 2026-07-12:
956
+ // every first `vgai play` on a cold cache burned the full 120s window).
957
+ if (clientCount() === 0 && pendingCommands.size > 0) {
958
+ failPendingCommands(
959
+ 'Editor page disconnected while the command was pending — usually a mid-command page ' +
960
+ 'reload (e.g. Vite dep re-optimization on a cold cache) or a closed tab. ' +
961
+ 'Reload the editor tab and retry the command.',
962
+ );
963
+ }
964
+ });
575
965
  });
576
966
 
577
- // ---- Editor command relay ----
578
- router.post('/__editor/command', (req: Request, res: Response) => {
579
- const body = req.body as Record<string, unknown>;
967
+ // ---- Coding-harness Chat (Supercode) ----
968
+ // GET is a complete snapshot; mutations are one explicit action endpoint;
969
+ // live transcript/runtime changes ride the shared SSE connection above.
970
+ // Hiding/collapsing the browser panel calls neither route and therefore
971
+ // never changes ownership of a terminal-driven session.
972
+ router.get('/__editor/harness-chat', async (_req: Request, res: Response) => {
973
+ try {
974
+ res.json(await harnessChat.load(true));
975
+ } catch (error) {
976
+ res.status(503).json({ error: error instanceof Error ? error.message : String(error) });
977
+ }
978
+ });
979
+
980
+ router.post('/__editor/harness-chat/action', async (req: Request, res: Response) => {
981
+ try {
982
+ const action = parseHarnessChatAction(req.body);
983
+ res.json(await harnessChat.act(action));
984
+ } catch (error) {
985
+ res.status(400).json({
986
+ error: error instanceof Error ? error.message : String(error),
987
+ snapshot: harnessChat.snapshot(),
988
+ });
989
+ }
990
+ });
580
991
 
992
+ // ---- Editor command relay ----
993
+ /** The relay round trip, extracted (#146) so POST `/__editor/command` and
994
+ * the GET poke surface below share ONE implementation: fail fast with no
995
+ * connected tab, assign a request id, deliver to the controller tab over
996
+ * SSE, and resolve when `/__editor/command-result` reports back (or the
997
+ * per-type timeout fires — SC1: a timeout means an editor IS connected
998
+ * but did not answer, distinct from the no-editor case). */
999
+ function relayCommand(body: Record<string, unknown>): Promise<CommandResult> {
581
1000
  // Fail fast when no editor browser tab holds a live SSE connection. Without
582
1001
  // this, the command silently waits the full COMMAND_TIMEOUT_MS before a
583
1002
  // generic timeout — and the caller can't tell "no editor open" from "editor
584
1003
  // open but the command hung". `clientCount()` is the live SSE-client count,
585
1004
  // so a 0 here is unambiguous: open the editor in a browser first.
586
1005
  if (clientCount() === 0) {
587
- const { status, body: respBody } = commandResponseFor({
1006
+ return Promise.resolve({
588
1007
  ok: false,
589
1008
  timedOut: true,
590
1009
  error: 'No editor connected — open the editor in a browser tab, then retry.',
591
1010
  });
592
- res.status(status).json(respBody);
593
- return;
594
1011
  }
595
1012
 
596
1013
  // Assign a request ID so the browser can report the result back
597
1014
  const requestId = randomUUID();
598
1015
  const commandWithId = { ...body, _requestId: requestId };
599
1016
 
600
- // Create a promise that resolves when the browser reports the result.
601
- // SC1: a timeout here means an editor IS connected but did not answer in
602
- // time (the command hung or threw) — distinct from the no-editor case above.
603
- const timeoutMs = body['type'] === 'play' ? PLAY_COMMAND_TIMEOUT_MS : COMMAND_TIMEOUT_MS;
1017
+ const timeoutMs =
1018
+ body['type'] === 'play'
1019
+ ? PLAY_COMMAND_TIMEOUT_MS
1020
+ : body['type'] === 'stop'
1021
+ ? STOP_COMMAND_TIMEOUT_MS
1022
+ : body['type'] === 'capture-asset-preview'
1023
+ ? ASSET_PREVIEW_COMMAND_TIMEOUT_MS
1024
+ : COMMAND_TIMEOUT_MS;
604
1025
  const resultPromise = new Promise<CommandResult>((resolve) => {
605
1026
  const timer = setTimeout(() => {
606
1027
  pendingCommands.delete(requestId);
@@ -613,9 +1034,30 @@ export function createEditorServer(options: EditorServerOptions): Router {
613
1034
  pendingCommands.set(requestId, { resolve, timer });
614
1035
  });
615
1036
 
616
- broadcast('editor-command', commandWithId);
1037
+ // Commands mutate editor/game state, so exactly one browser tab owns each
1038
+ // command. Ordinary notifications still broadcast to every tab.
1039
+ const controller = sendToController('editor-command', commandWithId);
1040
+ if (controller) {
1041
+ const pending = pendingCommands.get(requestId);
1042
+ if (pending) pending.controller = controller;
1043
+ } else {
1044
+ const pending = pendingCommands.get(requestId);
1045
+ if (pending) {
1046
+ clearTimeout(pending.timer);
1047
+ pendingCommands.delete(requestId);
1048
+ pending.resolve({
1049
+ ok: false,
1050
+ timedOut: true,
1051
+ error: 'Editor disconnected before the command could be delivered.',
1052
+ });
1053
+ }
1054
+ }
1055
+
1056
+ return resultPromise;
1057
+ }
617
1058
 
618
- resultPromise.then((result) => {
1059
+ router.post('/__editor/command', (req: Request, res: Response) => {
1060
+ relayCommand(req.body as Record<string, unknown>).then((result) => {
619
1061
  const { status, body } = commandResponseFor(result);
620
1062
  res.status(status).json(body);
621
1063
  });
@@ -639,20 +1081,153 @@ export function createEditorServer(options: EditorServerOptions): Router {
639
1081
  });
640
1082
 
641
1083
  // ---- Editor state (browser reports / CLI reads) ----
642
- router.get('/__editor/state', (_req: Request, res: Response) => {
1084
+ router.get('/__editor/state', async (_req: Request, res: Response) => {
643
1085
  // `editorState` is the last snapshot a browser POSTed; it persists even
644
1086
  // after every tab closes. Stamp the response with the LIVE SSE client count
645
1087
  // so callers can distinguish "an editor is attached now" from "this is stale
646
1088
  // cache from a tab that has since gone away".
647
1089
  const connected = clientCount();
648
- res.json({ ...editorState, editorsConnected: connected, connected: connected > 0 });
1090
+ const controlledState = (() => {
1091
+ const clientId = controllerClientId();
1092
+ return clientId ? editorStatesByClient.get(clientId) : undefined;
1093
+ })();
1094
+ const state = controlledState?.state ?? editorState;
1095
+ const stateUpdatedAt = controlledState?.updatedAt ?? editorStateUpdatedAt;
1096
+ // #124: project identity, server-computed exactly like `projectValidation`
1097
+ // below (never part of the browser-POSTed snapshot) — lets a watcher/relay
1098
+ // holding only a port number tell which project that port serves. Mirrors
1099
+ // `/__editor/project`'s own `projectRoot === engineRoot` -> "no project"
1100
+ // convention rather than introducing a new one.
1101
+ const projectOpen = projectRoot !== engineRoot;
1102
+ const view = projectOpen ? await readProjectView(projectRoot) : null;
1103
+ const nameField = view?.['name'];
1104
+ res.json({
1105
+ ...state,
1106
+ editorsConnected: connected,
1107
+ connected: connected > 0,
1108
+ // #run2-cold-vite: null until the FIRST genuine index-page GET this
1109
+ // server has served (see the response-observing middleware above).
1110
+ lastIndexRequestAt,
1111
+ ...(stateUpdatedAt !== null ? { stateUpdatedAt } : {}),
1112
+ // #103: server-computed (never part of the browser-POSTed snapshot
1113
+ // above), so it's always current — a file present here is currently
1114
+ // failing validation; a clean project reports `{}`. `vgai status`
1115
+ // prints this whole object as-is, so no CLI changes were needed to
1116
+ // surface it.
1117
+ projectValidation: Object.fromEntries(projectValidation),
1118
+ projectRoot: projectOpen ? projectRoot : null,
1119
+ projectName: typeof nameField === 'string' ? nameField : null,
1120
+ });
649
1121
  });
650
1122
 
651
1123
  router.post('/__editor/state', (req: Request, res: Response) => {
652
- editorState = req.body as Record<string, unknown>;
1124
+ const reported = req.body as Record<string, unknown>;
1125
+ const clientId = typeof reported['_clientId'] === 'string' ? reported['_clientId'] : null;
1126
+ const { _clientId: _ignoredClientId, ...state } = reported;
1127
+ const updatedAt = Date.now();
1128
+ editorState = state;
1129
+ editorStateUpdatedAt = updatedAt;
1130
+ if (clientId) {
1131
+ editorStatesByClient.set(clientId, { state, updatedAt });
1132
+ const pageErrors = state['pageErrors'];
1133
+ const presence = state['presence'];
1134
+ const presenceRecord =
1135
+ presence && typeof presence === 'object' ? (presence as Record<string, unknown>) : null;
1136
+ updateClientControlHealth(clientId, {
1137
+ errorState: Array.isArray(pageErrors)
1138
+ ? pageErrors.length === 0
1139
+ ? 'healthy'
1140
+ : 'errored'
1141
+ : 'unknown',
1142
+ visible: presenceRecord?.['visibility'] === 'visible',
1143
+ focused: presenceRecord?.['focused'] === true,
1144
+ updatedAt,
1145
+ });
1146
+ }
653
1147
  res.json({ ok: true });
654
1148
  });
655
1149
 
1150
+ // ---- #146: the agent poke surface (docs/SHARED-SESSION-SPEC.md §3 rung 2)
1151
+ //
1152
+ // Plain-GET reads for the operations an agent repeats most — check state,
1153
+ // check validation, grab a frame. Deliberately curl-shaped: no JSON body to
1154
+ // quote, terse output, and bulky payloads returned as PATHS inside the
1155
+ // project (never blobs — an agent's context budget is metered, and a
1156
+ // sandboxed agent can read project-internal paths without permission
1157
+ // prompts). Mutations stay on POST `/__editor/command`. The index below is
1158
+ // the wire's self-discovery: arbitrary endpoint names are fine when
1159
+ // queryable, fatal when memorized.
1160
+ router.get('/__vgai', (_req: Request, res: Response) => {
1161
+ res.json({
1162
+ project: projectRoot !== engineRoot ? projectRoot : null,
1163
+ connected: clientCount() > 0,
1164
+ endpoints: {
1165
+ 'GET /__vgai': 'this index',
1166
+ 'GET /__vgai/state': 'every observable game-state provider (play mode must be running)',
1167
+ 'GET /__vgai/state/:provider': 'one provider, e.g. /__vgai/state/gemGame',
1168
+ 'GET /__vgai/validation':
1169
+ 'validate-on-save results for this project (failing: {} means clean)',
1170
+ 'GET /__vgai/screenshot':
1171
+ 'capture the live game canvas to <project>/.vgai/captures/<ts>.png and return its path',
1172
+ 'POST /__editor/command': 'the full command relay (bridge-call, play, stop, select, …)',
1173
+ },
1174
+ });
1175
+ });
1176
+
1177
+ // The edit→verify loop's other half: after writing a scene/manifest/asset
1178
+ // file, one cheap GET answers "was my last save accepted?" instead of
1179
+ // tailing the (detached) dev-server log. Same server-computed map
1180
+ // `/__editor/state` exposes as `projectValidation`, purpose-shaped.
1181
+ router.get('/__vgai/validation', (_req: Request, res: Response) => {
1182
+ res.json({
1183
+ clean: projectValidation.size === 0,
1184
+ failing: Object.fromEntries(projectValidation),
1185
+ });
1186
+ });
1187
+
1188
+ router.get('/__vgai/state', async (_req: Request, res: Response) => {
1189
+ const result = await relayCommand({ type: 'bridge-call', method: 'stateAll', callArgs: [] });
1190
+ const { status, body } = commandResponseFor(result);
1191
+ res.status(status).json(body);
1192
+ });
1193
+
1194
+ router.get('/__vgai/state/:provider', async (req: Request, res: Response) => {
1195
+ const result = await relayCommand({
1196
+ type: 'bridge-call',
1197
+ method: 'state',
1198
+ callArgs: [req.params['provider']],
1199
+ });
1200
+ const { status, body } = commandResponseFor(result);
1201
+ res.status(status).json(body);
1202
+ });
1203
+
1204
+ // Paths-not-blobs: the page hands back base64 over the relay
1205
+ // (`bridge-screenshot` — a pure "hand back the pixels" primitive), and THIS
1206
+ // side owns the filesystem, so the PNG lands under the project where the
1207
+ // agent's Read tool can open it without leaving the sandbox.
1208
+ router.get('/__vgai/screenshot', async (_req: Request, res: Response) => {
1209
+ if (projectRoot === engineRoot) {
1210
+ res.status(400).json({ ok: false, error: 'no project open' });
1211
+ return;
1212
+ }
1213
+ const result = await relayCommand({ type: 'bridge-screenshot' });
1214
+ if (!result.ok) {
1215
+ const { status, body } = commandResponseFor(result);
1216
+ res.status(status).json(body);
1217
+ return;
1218
+ }
1219
+ const base64 = result.data?.['base64'];
1220
+ if (typeof base64 !== 'string') {
1221
+ res.status(500).json({ ok: false, error: 'bridge-screenshot returned no image data' });
1222
+ return;
1223
+ }
1224
+ const capturesDir = join(projectRoot, '.vgai', 'captures');
1225
+ await mkdir(capturesDir, { recursive: true });
1226
+ const file = join(capturesDir, `capture-${new Date().toISOString().replace(/[:.]/g, '-')}.png`);
1227
+ await writeFile(file, Buffer.from(base64, 'base64'));
1228
+ res.json({ ok: true, path: file, relativePath: relative(projectRoot, file) });
1229
+ });
1230
+
656
1231
  // ---- Asset listing ----
657
1232
  router.get('/__editor/assets', async (req: Request, res: Response) => {
658
1233
  const dir = (req.query['dir'] as string) ?? '';
@@ -720,7 +1295,12 @@ export function createEditorServer(options: EditorServerOptions): Router {
720
1295
 
721
1296
  // ---- Save file ----
722
1297
  router.post('/__editor/save-file', async (req: Request, res: Response) => {
723
- const body = req.body as { path: string; content: string; encoding?: 'base64' };
1298
+ const body = req.body as {
1299
+ path: string;
1300
+ content?: string;
1301
+ encoding?: 'base64';
1302
+ delete?: boolean;
1303
+ };
724
1304
  const filePath = body.path ?? '';
725
1305
 
726
1306
  if (!filePath || filePath.includes('..')) {
@@ -735,6 +1315,15 @@ export function createEditorServer(options: EditorServerOptions): Router {
735
1315
  }
736
1316
 
737
1317
  try {
1318
+ if (body.delete) {
1319
+ await unlinkIfPresent(absPath);
1320
+ res.json({ ok: true, path: filePath });
1321
+ return;
1322
+ }
1323
+ if (typeof body.content !== 'string') {
1324
+ res.status(400).json({ error: 'Missing content.' });
1325
+ return;
1326
+ }
738
1327
  await mkdir(dirname(absPath), { recursive: true });
739
1328
  const data = body.encoding === 'base64' ? Buffer.from(body.content, 'base64') : body.content;
740
1329
  await writeFile(absPath, data, body.encoding === 'base64' ? undefined : 'utf-8');
@@ -915,7 +1504,7 @@ export function createEditorServer(options: EditorServerOptions): Router {
915
1504
  // T3.6: shared scaffolder — packages/create-vgai-project/src/scaffold.ts.
916
1505
  // Same implementation `npx create-vgai-project` and `vgai create` use
917
1506
  // (copy template, rewrite @vgai/engine file: dep + tsconfig @engine/*
918
- // paths/files array + vite alias + index.html title + project.json).
1507
+ // paths/files array + vite alias + index.html title + v2 manifest).
919
1508
  const result = scaffoldProject({
920
1509
  name,
921
1510
  targetDir: projectDir,
@@ -930,7 +1519,7 @@ export function createEditorServer(options: EditorServerOptions): Router {
930
1519
  // Add to recent projects
931
1520
  await addToRecentProjects(name, projectDir);
932
1521
 
933
- res.json({ ok: true, path: result.targetDir, config: result.projectConfig });
1522
+ res.json({ ok: true, path: result.targetDir, config: result.manifest });
934
1523
  } catch (err) {
935
1524
  res.status(500).json({ error: `Failed to create project: ${err}` });
936
1525
  }
@@ -1082,20 +1671,37 @@ export function createEditorServer(options: EditorServerOptions): Router {
1082
1671
  // disagree on a symlinked segment).
1083
1672
  const absPath = canonicalProjectRoot(newPath);
1084
1673
 
1085
- // Read project.json (or synthesize from vgai.game.json — §1C); accepts a
1086
- // manifest-only folder with no project.json.
1674
+ // Read the v2 game manifest and derive the editor project view.
1087
1675
  const config = await readProjectView(absPath);
1088
1676
  if (!config) {
1089
- res.status(400).json({ error: `No vgai.game.json or project.json at ${absPath}` });
1677
+ res.status(400).json({ error: `No valid vgai.game.json at ${absPath}` });
1090
1678
  return;
1091
1679
  }
1092
1680
 
1681
+ // Compatibility is enforced by the process that owns the mutable project
1682
+ // state, immediately before activation. The browser also preflights this
1683
+ // for fast feedback, but it cannot make a client-side check atomic and
1684
+ // non-browser API callers must not be able to bypass the contract.
1685
+ try {
1686
+ const compatibility = compatibilityIdentity();
1687
+ assertEditorCompatibility(compatibility);
1688
+ assertProjectCompatibility(config, compatibility);
1689
+ } catch (error) {
1690
+ if (error instanceof ProjectCompatibilityError) {
1691
+ res.status(409).json({ error: error.message, recovery: error.recovery });
1692
+ return;
1693
+ }
1694
+ throw error;
1695
+ }
1696
+
1093
1697
  // Update mutable state
1094
1698
  projectRoot = absPath;
1095
1699
  publicRoot = resolve(absPath, 'public');
1096
1700
 
1097
1701
  // Clear stale editor state from previous project
1098
1702
  editorState = {};
1703
+ editorStateUpdatedAt = null;
1704
+ editorStatesByClient.clear();
1099
1705
 
1100
1706
  // Ensure .vgai/ directory exists
1101
1707
  await mkdir(join(absPath, '.vgai'), { recursive: true });
@@ -1111,6 +1717,15 @@ export function createEditorServer(options: EditorServerOptions): Router {
1111
1717
 
1112
1718
  // Notify all connected editors
1113
1719
  broadcast('project-changed', { path: absPath, config });
1720
+ // Coding sessions are project-scoped. Tear down only editor-owned
1721
+ // Supercode runtime/follower state and rediscover the new folder; this
1722
+ // never touches an independently-running terminal harness process.
1723
+ void harnessChat.setWorkspace().catch((error) => {
1724
+ broadcast('server-log', {
1725
+ level: 'error',
1726
+ message: `Harness Chat could not switch projects: ${error instanceof Error ? error.message : String(error)}`,
1727
+ });
1728
+ });
1114
1729
 
1115
1730
  res.json({ ok: true, config });
1116
1731
  });
@@ -1368,9 +1983,9 @@ export function createEditorServer(options: EditorServerOptions): Router {
1368
1983
  res.json({ files });
1369
1984
  });
1370
1985
 
1371
- // D1 (spec §2.5): a Data-panel edit writes the `.data.json` IMMEDIATELY —
1372
- // game-is-the-document; undo is git. D5 RESOLVED here as the spec
1373
- // recommends: data writes are plain file writes and deliberately BYPASS the
1986
+ // D1 (spec §2.5): a Data-panel edit writes the `.data.json` IMMEDIATELY.
1987
+ // Project History captures exact bytes around this transport. Data writes
1988
+ // deliberately BYPASS the
1374
1989
  // play-mode overlay regime entirely (the overlay is scene-entity-only —
1375
1990
  // `.vgai/overlays/*` via `world-overlay.ts`/`ingest-overlay.ts`; nothing in
1376
1991
  // that path ever touches `src/data/`). A running game picks the write up
@@ -1407,6 +2022,7 @@ export function createEditorServer(options: EditorServerOptions): Router {
1407
2022
  }
1408
2023
  try {
1409
2024
  const absPath = join(projectRoot, relPath);
2025
+ await mkdir(dirname(absPath), { recursive: true });
1410
2026
  await writeFile(absPath, body.content, 'utf-8');
1411
2027
  res.json({ ok: true, path: relPath });
1412
2028
  } catch {
@@ -1414,6 +2030,51 @@ export function createEditorServer(options: EditorServerOptions): Router {
1414
2030
  }
1415
2031
  });
1416
2032
 
2033
+ router.get('/__editor/data-file', async (req: Request, res: Response) => {
2034
+ const relPath = (req.query['path'] as string) ?? '';
2035
+ if (
2036
+ !relPath ||
2037
+ relPath.includes('..') ||
2038
+ !relPath.startsWith('src/data/') ||
2039
+ !relPath.endsWith('.data.json')
2040
+ ) {
2041
+ res.status(400).json({ error: 'Invalid path — expected src/data/**/*.data.json.' });
2042
+ return;
2043
+ }
2044
+ if (projectRoot === engineRoot) {
2045
+ res.status(404).json({ error: 'No project open.' });
2046
+ return;
2047
+ }
2048
+ try {
2049
+ res.type('application/json').send(await readFile(join(projectRoot, relPath)));
2050
+ } catch {
2051
+ res.status(404).json({ error: 'Not found.' });
2052
+ }
2053
+ });
2054
+
2055
+ router.delete('/__editor/data-file', async (req: Request, res: Response) => {
2056
+ const relPath = (req.body as { path?: string }).path ?? '';
2057
+ if (
2058
+ !relPath ||
2059
+ relPath.includes('..') ||
2060
+ !relPath.startsWith('src/data/') ||
2061
+ !relPath.endsWith('.data.json')
2062
+ ) {
2063
+ res.status(400).json({ error: 'Invalid path — expected src/data/**/*.data.json.' });
2064
+ return;
2065
+ }
2066
+ if (projectRoot === engineRoot) {
2067
+ res.status(400).json({ error: 'No project open.' });
2068
+ return;
2069
+ }
2070
+ try {
2071
+ await unlinkIfPresent(join(projectRoot, relPath));
2072
+ res.json({ ok: true });
2073
+ } catch {
2074
+ res.status(500).json({ error: 'Failed to delete data file.' });
2075
+ }
2076
+ });
2077
+
1417
2078
  // ---- Project tools (W4, docs/DATA-TOOLS-DESIGN.md §3) ----
1418
2079
  //
1419
2080
  // Discovery for the dock's project-tool tabs: every
@@ -1437,6 +2098,108 @@ export function createEditorServer(options: EditorServerOptions): Router {
1437
2098
  });
1438
2099
  });
1439
2100
 
2101
+ // ---- Project operations (docs/PROJECT-OPERATIONS-DESIGN.md) ----
2102
+ //
2103
+ // Unlike project tools, operation modules are loaded on the NODE side.
2104
+ // Merely listing a Node-native asset/map build script must never import it
2105
+ // into the editor browser or make it reachable from a standalone game
2106
+ // bundle. Vite's ssrLoadModule is injected by the owning server and gives
2107
+ // us normal TS transform + project dependency resolution without a second
2108
+ // script runtime.
2109
+ router.get('/__editor/project-operations', async (_req: Request, res: Response) => {
2110
+ if (projectRoot === engineRoot) {
2111
+ res.json({ operations: [], loadErrors: [] });
2112
+ return;
2113
+ }
2114
+ res.json(await discoverProjectOperations(projectRoot, options.loadProjectModule));
2115
+ });
2116
+
2117
+ router.post('/__editor/project-operations/run', async (req: Request, res: Response) => {
2118
+ if (projectRoot === engineRoot) {
2119
+ res.status(400).json({
2120
+ ok: false,
2121
+ error: { code: 'NO_PROJECT_OPEN', message: 'Open a project before running an operation.' },
2122
+ });
2123
+ return;
2124
+ }
2125
+ const body = req.body as { name?: unknown; input?: unknown; confirm?: unknown };
2126
+ if (typeof body.name !== 'string' || body.name.trim() === '') {
2127
+ res.status(400).json({
2128
+ ok: false,
2129
+ error: { code: 'INVALID_OPERATION_NAME', message: 'Expected a non-empty operation name.' },
2130
+ });
2131
+ return;
2132
+ }
2133
+ const controller = new AbortController();
2134
+ req.once('aborted', () => controller.abort());
2135
+ const result = await executeProjectOperation({
2136
+ projectRoot,
2137
+ loadModule: options.loadProjectModule,
2138
+ name: body.name,
2139
+ input: Object.hasOwn(body, 'input') ? body.input : {},
2140
+ confirmed: body.confirm === true,
2141
+ signal: controller.signal,
2142
+ });
2143
+ // A project command may create an ordinary file anywhere below public/.
2144
+ // The public watcher validates files and detects moves, but it deliberately
2145
+ // does not broadcast every add/change. Tell every editor client to rescan
2146
+ // its Assets view after a successful declared mutation; no generator- or
2147
+ // asset-specific operation contract is needed.
2148
+ if (result.changedProject) broadcast('assets-changed', {});
2149
+ res.status(result.status).json(result.body);
2150
+ });
2151
+
2152
+ // ---- Story discovery (C3, spec §9 — "Add CSF Discovery and Portable
2153
+ // Story Mounting") ----
2154
+ //
2155
+ // Conventional `*.stories.tsx` / `*.stories.ts` files, colocated anywhere
2156
+ // under the project's `src/` (Storybook's own convention — unlike
2157
+ // `src/tools/`/`src/data/`, stories are not confined to one folder). Same
2158
+ // D3 physics as every other editor list here: a live folder scan, never a
2159
+ // cached manifest — add/remove a `*.stories.tsx` and the next refresh sees
2160
+ // it. Paths only: the CSF MODULE itself is loaded client-side through
2161
+ // Vite's `/@fs/` dynamic-import path (`story-discovery.ts`, mirroring
2162
+ // `tool-loader.ts`), so it arrives transpiled with HMR, and the editor
2163
+ // never stores a duplicate story definition — the CSF module stays the one
2164
+ // source of truth (spec §9 C3 "Story source remains the CSF module").
2165
+ //
2166
+ // `previewModulePath` rides along in the same response: the project's
2167
+ // OPTIONAL `.storybook/preview.tsx`/`.ts` (checked with `existsSync`, same
2168
+ // "server tells the truth about disk, client never guesses" contract as
2169
+ // the rest of this route), or `null` when the project has no `.storybook/`
2170
+ // folder — most projects don't. `story-discovery.ts`'s
2171
+ // `loadProjectPreviewAnnotations` used to blind-probe both candidate paths
2172
+ // client-side via `/@fs/` and swallow the import failure; that still
2173
+ // logged a real failed network request for every project without a
2174
+ // preview config (the common case), which is indistinguishable from a
2175
+ // genuine broken-editor error to anything watching the page's network/
2176
+ // console (e.g. `check-vgai-generated-project-p2p.ts`'s browser-proof).
2177
+ // Reporting existence here lets the client only ever import a path that is
2178
+ // actually there.
2179
+ router.get('/__editor/story-files', async (_req: Request, res: Response) => {
2180
+ if (projectRoot === engineRoot) {
2181
+ res.json({ files: [], previewModulePath: null });
2182
+ return;
2183
+ }
2184
+ const srcDir = join(projectRoot, 'src');
2185
+ const [tsxFiles, tsFiles] = await Promise.all([
2186
+ findFiles(srcDir, '.stories.tsx'),
2187
+ findFiles(srcDir, '.stories.ts'),
2188
+ ]);
2189
+ const found = [...tsxFiles, ...tsFiles].filter(
2190
+ (abs) => !abs.includes(`${sep}node_modules${sep}`),
2191
+ );
2192
+ const previewCandidates = ['.storybook/preview.tsx', '.storybook/preview.ts'];
2193
+ const previewModulePath =
2194
+ previewCandidates.find((candidate) => existsSync(join(projectRoot, candidate))) ?? null;
2195
+ res.json({
2196
+ files: found.sort().map((abs) => ({
2197
+ path: relative(projectRoot, abs).split('\\').join('/'),
2198
+ })),
2199
+ previewModulePath,
2200
+ });
2201
+ });
2202
+
1440
2203
  // ---- Manifest write (A4, D8 — the ONE manifest-write verb) ----
1441
2204
  // Unlike every other write route above, this one HARD-CODES its
1442
2205
  // destination: `join(projectRoot, 'vgai.game.json')`, NEVER derived from
@@ -1460,6 +2223,31 @@ export function createEditorServer(options: EditorServerOptions): Router {
1460
2223
  }
1461
2224
  });
1462
2225
 
2226
+ router.get('/__editor/manifest', async (_req: Request, res: Response) => {
2227
+ if (projectRoot === engineRoot) {
2228
+ res.status(404).json({ error: 'No project open.' });
2229
+ return;
2230
+ }
2231
+ try {
2232
+ res.type('application/json').send(await readFile(join(projectRoot, 'vgai.game.json')));
2233
+ } catch {
2234
+ res.status(404).json({ error: 'Not found.' });
2235
+ }
2236
+ });
2237
+
2238
+ router.delete('/__editor/manifest', async (_req: Request, res: Response) => {
2239
+ if (projectRoot === engineRoot) {
2240
+ res.status(400).json({ error: 'No project open.' });
2241
+ return;
2242
+ }
2243
+ try {
2244
+ await unlinkIfPresent(join(projectRoot, 'vgai.game.json'));
2245
+ res.json({ ok: true });
2246
+ } catch {
2247
+ res.status(500).json({ error: 'Failed to delete manifest.' });
2248
+ }
2249
+ });
2250
+
1463
2251
  // ---- Play-mode console log persistence ----
1464
2252
  const MAX_LOG_FILES = 20;
1465
2253
  let activeLogFile: string | null = null;
@@ -1533,5 +2321,44 @@ export function createEditorServer(options: EditorServerOptions): Router {
1533
2321
  res.json({ ok: true });
1534
2322
  });
1535
2323
 
2324
+ // ---- #125 item 2: probe lifecycle narration relay ----
2325
+ // `vgai probe` (packages/vgai-cli/src/index.ts's `runProbe`) fires
2326
+ // best-effort lifecycle events at this route so they land in the SAME live
2327
+ // editor console PR #120 wired the `server-log` SSE event into (see
2328
+ // `runFileValidation`'s `broadcast('server-log', …)` calls above) — a human
2329
+ // watching the editor sees "probe: run started (headed)" / "probe: 3/3
2330
+ // specs passed" without tailing a separate terminal. Deliberately dumb:
2331
+ // this is a narration RELAY, not an API — it validates shape, broadcasts,
2332
+ // and gets out of the way; no state is kept here, and a caller with
2333
+ // nothing to say just never POSTs (`vgai probe` skips this entirely when
2334
+ // no live session covers the project).
2335
+ router.post('/__editor/server-log', (req: Request, res: Response) => {
2336
+ const body = req.body as { level?: unknown; message?: unknown };
2337
+ const level = body.level;
2338
+ if (
2339
+ (level !== 'info' && level !== 'warn' && level !== 'error') ||
2340
+ typeof body.message !== 'string'
2341
+ ) {
2342
+ res
2343
+ .status(400)
2344
+ .json({ error: "Expected { level: 'info'|'warn'|'error', message: string }." });
2345
+ return;
2346
+ }
2347
+ broadcast('server-log', { level, message: body.message });
2348
+ res.status(204).end();
2349
+ });
2350
+
2351
+ router.close = async () => {
2352
+ for (const timer of validationDebounceTimers.values()) clearTimeout(timer);
2353
+ validationDebounceTimers.clear();
2354
+ await Promise.all([
2355
+ watcher?.close(),
2356
+ srcWatcher?.close(),
2357
+ manifestWatcher?.close(),
2358
+ engineSourceWatcher?.close(),
2359
+ harnessChat.close(),
2360
+ ]);
2361
+ };
2362
+
1536
2363
  return router;
1537
2364
  }