@dfosco/storyboard 0.5.0-alpha.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 (592) hide show
  1. package/commandpalette.config.json +152 -0
  2. package/dist/storyboard-ui.css +1 -0
  3. package/dist/storyboard-ui.js +21328 -0
  4. package/dist/storyboard-ui.js.map +1 -0
  5. package/dist/tailwind.css +2 -0
  6. package/dist/tiny-canvas.css +1 -0
  7. package/dist/tiny-canvas.js +389 -0
  8. package/package.json +121 -0
  9. package/paste.config.json +67 -0
  10. package/scaffold/AGENTS.md +432 -0
  11. package/scaffold/agents/prompt-agent.agent.md +181 -0
  12. package/scaffold/agents/terminal-agent.agent.md +351 -0
  13. package/scaffold/codex/config.toml +246 -0
  14. package/scaffold/deploy.yml +103 -0
  15. package/scaffold/githooks/pre-push +114 -0
  16. package/scaffold/gitignore +64 -0
  17. package/scaffold/manifest.json +56 -0
  18. package/scaffold/preview.yml +181 -0
  19. package/scaffold/scripts/link.sh +26 -0
  20. package/scaffold/scripts/unlink.sh +10 -0
  21. package/scaffold/skills/agent-browser/SKILL.md +260 -0
  22. package/scaffold/skills/canvas/SKILL.md +364 -0
  23. package/scaffold/skills/create/SKILL.md +501 -0
  24. package/scaffold/skills/ship/SKILL.md +237 -0
  25. package/scaffold/skills/storyboard/SKILL.md +360 -0
  26. package/scaffold/skills/update-storyboard/SKILL.md +16 -0
  27. package/scaffold/skills/update-storyboard/update-storyboard-packages.sh +26 -0
  28. package/scaffold/skills/vitest/GENERATION.md +5 -0
  29. package/scaffold/skills/vitest/SKILL.md +52 -0
  30. package/scaffold/skills/vitest/references/advanced-environments.md +264 -0
  31. package/scaffold/skills/vitest/references/advanced-projects.md +300 -0
  32. package/scaffold/skills/vitest/references/advanced-type-testing.md +237 -0
  33. package/scaffold/skills/vitest/references/advanced-vi.md +249 -0
  34. package/scaffold/skills/vitest/references/core-cli.md +166 -0
  35. package/scaffold/skills/vitest/references/core-config.md +174 -0
  36. package/scaffold/skills/vitest/references/core-describe.md +193 -0
  37. package/scaffold/skills/vitest/references/core-expect.md +219 -0
  38. package/scaffold/skills/vitest/references/core-hooks.md +244 -0
  39. package/scaffold/skills/vitest/references/core-test-api.md +233 -0
  40. package/scaffold/skills/vitest/references/features-concurrency.md +250 -0
  41. package/scaffold/skills/vitest/references/features-context.md +238 -0
  42. package/scaffold/skills/vitest/references/features-coverage.md +207 -0
  43. package/scaffold/skills/vitest/references/features-filtering.md +211 -0
  44. package/scaffold/skills/vitest/references/features-mocking.md +265 -0
  45. package/scaffold/skills/vitest/references/features-snapshots.md +207 -0
  46. package/scaffold/skills/worktree/SKILL.md +93 -0
  47. package/scaffold/storyboard.config.json +44 -0
  48. package/src/canvas/Canvas.jsx +78 -0
  49. package/src/canvas/Draggable.jsx +235 -0
  50. package/src/canvas/index.d.ts +41 -0
  51. package/src/canvas/index.js +6 -0
  52. package/src/canvas/style.css +118 -0
  53. package/src/canvas/useResetCanvas.js +17 -0
  54. package/src/canvas/utils.js +136 -0
  55. package/src/core/assets/fonts/IoskeleyMono-Bold.woff2 +0 -0
  56. package/src/core/assets/fonts/IoskeleyMono-Italic.woff2 +0 -0
  57. package/src/core/assets/fonts/IoskeleyMono-Medium.woff2 +0 -0
  58. package/src/core/assets/fonts/IoskeleyMono-Regular.woff2 +0 -0
  59. package/src/core/assets/fonts/IoskeleyMono-SemiBold.woff2 +0 -0
  60. package/src/core/autosync/server.js +714 -0
  61. package/src/core/autosync/server.test.js +158 -0
  62. package/src/core/canvas/__tests__/agent-integration.test.js +596 -0
  63. package/src/core/canvas/__tests__/helpers/browser.js +95 -0
  64. package/src/core/canvas/__tests__/helpers/canvas-api.js +129 -0
  65. package/src/core/canvas/__tests__/helpers/perf.js +118 -0
  66. package/src/core/canvas/__tests__/helpers/setup.js +176 -0
  67. package/src/core/canvas/__tests__/helpers/tmux.js +130 -0
  68. package/src/core/canvas/__tests__/helpers/transcript.js +132 -0
  69. package/src/core/canvas/__tests__/terminal-integration.test.js +177 -0
  70. package/src/core/canvas/collision.js +292 -0
  71. package/src/core/canvas/collision.test.js +371 -0
  72. package/src/core/canvas/compact.js +83 -0
  73. package/src/core/canvas/deriveCanvasId.test.js +40 -0
  74. package/src/core/canvas/githubEmbeds.js +527 -0
  75. package/src/core/canvas/githubEmbeds.test.js +302 -0
  76. package/src/core/canvas/hot-pool.js +766 -0
  77. package/src/core/canvas/identity.js +107 -0
  78. package/src/core/canvas/identity.test.js +100 -0
  79. package/src/core/canvas/materializer.js +259 -0
  80. package/src/core/canvas/materializer.test.js +356 -0
  81. package/src/core/canvas/selectedWidgets.js +270 -0
  82. package/src/core/canvas/selectedWidgets.test.js +321 -0
  83. package/src/core/canvas/server.js +3134 -0
  84. package/src/core/canvas/server.test.js +379 -0
  85. package/src/core/canvas/terminal-config.js +330 -0
  86. package/src/core/canvas/terminal-registry.js +465 -0
  87. package/src/core/canvas/terminal-server.js +1436 -0
  88. package/src/core/canvas/writeGuard.js +53 -0
  89. package/src/core/cli/agent.js +85 -0
  90. package/src/core/cli/branch.js +386 -0
  91. package/src/core/cli/canvasAdd.js +241 -0
  92. package/src/core/cli/canvasBatch.js +98 -0
  93. package/src/core/cli/canvasBounds.js +160 -0
  94. package/src/core/cli/canvasRead.js +236 -0
  95. package/src/core/cli/canvasUpdate.js +179 -0
  96. package/src/core/cli/code.js +67 -0
  97. package/src/core/cli/compact.js +62 -0
  98. package/src/core/cli/create.js +674 -0
  99. package/src/core/cli/dev-helpers.js +53 -0
  100. package/src/core/cli/dev-helpers.test.js +53 -0
  101. package/src/core/cli/dev.js +430 -0
  102. package/src/core/cli/exit.js +38 -0
  103. package/src/core/cli/flags.js +174 -0
  104. package/src/core/cli/flags.test.js +155 -0
  105. package/src/core/cli/index.js +233 -0
  106. package/src/core/cli/intro.js +37 -0
  107. package/src/core/cli/proxy.js +319 -0
  108. package/src/core/cli/proxy.test.js +63 -0
  109. package/src/core/cli/schemas.js +223 -0
  110. package/src/core/cli/server.js +192 -0
  111. package/src/core/cli/serverUrl.js +61 -0
  112. package/src/core/cli/sessions.js +459 -0
  113. package/src/core/cli/setup.js +404 -0
  114. package/src/core/cli/terminal-commands.js +287 -0
  115. package/src/core/cli/terminal-messaging.js +231 -0
  116. package/src/core/cli/terminal-welcome.js +515 -0
  117. package/src/core/cli/updateVersion.js +124 -0
  118. package/src/core/comments/api.js +284 -0
  119. package/src/core/comments/api.test.js +282 -0
  120. package/src/core/comments/auth.js +151 -0
  121. package/src/core/comments/auth.test.js +167 -0
  122. package/src/core/comments/commentCache.js +109 -0
  123. package/src/core/comments/commentCache.test.js +48 -0
  124. package/src/core/comments/commentDrafts.js +68 -0
  125. package/src/core/comments/commentMode.js +63 -0
  126. package/src/core/comments/commentMode.test.js +90 -0
  127. package/src/core/comments/config.js +47 -0
  128. package/src/core/comments/config.test.js +77 -0
  129. package/src/core/comments/graphql.js +65 -0
  130. package/src/core/comments/graphql.test.js +95 -0
  131. package/src/core/comments/index.js +42 -0
  132. package/src/core/comments/metadata.js +52 -0
  133. package/src/core/comments/metadata.test.js +110 -0
  134. package/src/core/comments/queries.js +245 -0
  135. package/src/core/comments/ui/AuthModal.jsx +114 -0
  136. package/src/core/comments/ui/CommentOverlay.js +52 -0
  137. package/src/core/comments/ui/CommentWindow.jsx +329 -0
  138. package/src/core/comments/ui/CommentsDrawer.jsx +102 -0
  139. package/src/core/comments/ui/Composer.jsx +64 -0
  140. package/src/core/comments/ui/authModal.js +66 -0
  141. package/src/core/comments/ui/authModal.test.js +76 -0
  142. package/src/core/comments/ui/comment-cursor-dark.svg +1 -0
  143. package/src/core/comments/ui/comment-cursor.svg +1 -0
  144. package/src/core/comments/ui/comment-layout.css +142 -0
  145. package/src/core/comments/ui/commentWindow.js +121 -0
  146. package/src/core/comments/ui/comments.css +242 -0
  147. package/src/core/comments/ui/commentsDrawer.js +84 -0
  148. package/src/core/comments/ui/composer.js +136 -0
  149. package/src/core/comments/ui/index.js +14 -0
  150. package/src/core/comments/ui/mount.js +687 -0
  151. package/src/core/comments/ui/mount.test.js +336 -0
  152. package/src/core/data/dotPath.js +53 -0
  153. package/src/core/data/dotPath.test.js +114 -0
  154. package/src/core/data/loader.js +409 -0
  155. package/src/core/data/loader.test.js +599 -0
  156. package/src/core/data/viewfinder.js +363 -0
  157. package/src/core/data/viewfinder.test.js +456 -0
  158. package/src/core/devtools/devtools-consumer.js +28 -0
  159. package/src/core/devtools/devtools.js +144 -0
  160. package/src/core/devtools/devtools.test.js +75 -0
  161. package/src/core/devtools/sceneDebug.js +112 -0
  162. package/src/core/devtools/sceneDebug.test.js +141 -0
  163. package/src/core/index.js +124 -0
  164. package/src/core/inspector/fiberWalker.js +239 -0
  165. package/src/core/inspector/highlighter.js +275 -0
  166. package/src/core/inspector/mouseMode.js +259 -0
  167. package/src/core/lib/components/ui/alert/alert-action.jsx +11 -0
  168. package/src/core/lib/components/ui/alert/alert-description.jsx +11 -0
  169. package/src/core/lib/components/ui/alert/alert-title.jsx +11 -0
  170. package/src/core/lib/components/ui/alert/alert.jsx +25 -0
  171. package/src/core/lib/components/ui/alert/index.js +17 -0
  172. package/src/core/lib/components/ui/avatar/avatar-badge.jsx +22 -0
  173. package/src/core/lib/components/ui/avatar/avatar-fallback.jsx +18 -0
  174. package/src/core/lib/components/ui/avatar/avatar-group-count.jsx +19 -0
  175. package/src/core/lib/components/ui/avatar/avatar-group.jsx +19 -0
  176. package/src/core/lib/components/ui/avatar/avatar-image.jsx +15 -0
  177. package/src/core/lib/components/ui/avatar/avatar.jsx +19 -0
  178. package/src/core/lib/components/ui/avatar/index.js +22 -0
  179. package/src/core/lib/components/ui/badge/badge.jsx +31 -0
  180. package/src/core/lib/components/ui/badge/index.js +2 -0
  181. package/src/core/lib/components/ui/button/button.jsx +100 -0
  182. package/src/core/lib/components/ui/button/index.js +12 -0
  183. package/src/core/lib/components/ui/card/card-action.jsx +11 -0
  184. package/src/core/lib/components/ui/card/card-content.jsx +11 -0
  185. package/src/core/lib/components/ui/card/card-description.jsx +11 -0
  186. package/src/core/lib/components/ui/card/card-footer.jsx +11 -0
  187. package/src/core/lib/components/ui/card/card-header.jsx +19 -0
  188. package/src/core/lib/components/ui/card/card-title.jsx +11 -0
  189. package/src/core/lib/components/ui/card/card.jsx +17 -0
  190. package/src/core/lib/components/ui/card/index.js +25 -0
  191. package/src/core/lib/components/ui/checkbox/checkbox.jsx +29 -0
  192. package/src/core/lib/components/ui/checkbox/index.js +6 -0
  193. package/src/core/lib/components/ui/collapsible/collapsible-content.jsx +7 -0
  194. package/src/core/lib/components/ui/collapsible/collapsible-trigger.jsx +7 -0
  195. package/src/core/lib/components/ui/collapsible/collapsible.jsx +7 -0
  196. package/src/core/lib/components/ui/collapsible/index.js +13 -0
  197. package/src/core/lib/components/ui/dialog/dialog-close.jsx +7 -0
  198. package/src/core/lib/components/ui/dialog/dialog-content.jsx +34 -0
  199. package/src/core/lib/components/ui/dialog/dialog-description.jsx +15 -0
  200. package/src/core/lib/components/ui/dialog/dialog-footer.jsx +23 -0
  201. package/src/core/lib/components/ui/dialog/dialog-header.jsx +11 -0
  202. package/src/core/lib/components/ui/dialog/dialog-overlay.jsx +15 -0
  203. package/src/core/lib/components/ui/dialog/dialog-portal.jsx +4 -0
  204. package/src/core/lib/components/ui/dialog/dialog-title.jsx +15 -0
  205. package/src/core/lib/components/ui/dialog/dialog-trigger.jsx +7 -0
  206. package/src/core/lib/components/ui/dialog/dialog.jsx +4 -0
  207. package/src/core/lib/components/ui/dialog/index.js +34 -0
  208. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.jsx +8 -0
  209. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.jsx +30 -0
  210. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-content.jsx +22 -0
  211. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.jsx +16 -0
  212. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-group.jsx +7 -0
  213. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-item.jsx +20 -0
  214. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-label.jsx +17 -0
  215. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-portal.jsx +4 -0
  216. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.jsx +7 -0
  217. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.jsx +29 -0
  218. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-separator.jsx +15 -0
  219. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.jsx +16 -0
  220. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.jsx +15 -0
  221. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.jsx +23 -0
  222. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-sub.jsx +4 -0
  223. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +7 -0
  224. package/src/core/lib/components/ui/dropdown-menu/dropdown-menu.jsx +4 -0
  225. package/src/core/lib/components/ui/dropdown-menu/index.js +54 -0
  226. package/src/core/lib/components/ui/input/index.js +7 -0
  227. package/src/core/lib/components/ui/input/input.jsx +19 -0
  228. package/src/core/lib/components/ui/label/index.js +7 -0
  229. package/src/core/lib/components/ui/label/label.jsx +19 -0
  230. package/src/core/lib/components/ui/panel/index.js +24 -0
  231. package/src/core/lib/components/ui/panel/panel-body.jsx +11 -0
  232. package/src/core/lib/components/ui/panel/panel-close.jsx +16 -0
  233. package/src/core/lib/components/ui/panel/panel-content.jsx +29 -0
  234. package/src/core/lib/components/ui/panel/panel-footer.jsx +11 -0
  235. package/src/core/lib/components/ui/panel/panel-header.jsx +11 -0
  236. package/src/core/lib/components/ui/panel/panel-title.jsx +12 -0
  237. package/src/core/lib/components/ui/panel/panel.jsx +4 -0
  238. package/src/core/lib/components/ui/popover/index.js +28 -0
  239. package/src/core/lib/components/ui/popover/popover-close.jsx +7 -0
  240. package/src/core/lib/components/ui/popover/popover-content.jsx +22 -0
  241. package/src/core/lib/components/ui/popover/popover-description.jsx +11 -0
  242. package/src/core/lib/components/ui/popover/popover-header.jsx +11 -0
  243. package/src/core/lib/components/ui/popover/popover-portal.jsx +4 -0
  244. package/src/core/lib/components/ui/popover/popover-title.jsx +11 -0
  245. package/src/core/lib/components/ui/popover/popover-trigger.jsx +8 -0
  246. package/src/core/lib/components/ui/popover/popover.jsx +4 -0
  247. package/src/core/lib/components/ui/searchable-list.jsx +160 -0
  248. package/src/core/lib/components/ui/select/index.js +37 -0
  249. package/src/core/lib/components/ui/select/select-content.jsx +30 -0
  250. package/src/core/lib/components/ui/select/select-group-heading.jsx +17 -0
  251. package/src/core/lib/components/ui/select/select-group.jsx +15 -0
  252. package/src/core/lib/components/ui/select/select-item.jsx +26 -0
  253. package/src/core/lib/components/ui/select/select-label.jsx +11 -0
  254. package/src/core/lib/components/ui/select/select-portal.jsx +4 -0
  255. package/src/core/lib/components/ui/select/select-scroll-down-button.jsx +18 -0
  256. package/src/core/lib/components/ui/select/select-scroll-up-button.jsx +18 -0
  257. package/src/core/lib/components/ui/select/select-separator.jsx +15 -0
  258. package/src/core/lib/components/ui/select/select-trigger.jsx +25 -0
  259. package/src/core/lib/components/ui/select/select.jsx +4 -0
  260. package/src/core/lib/components/ui/separator/index.js +7 -0
  261. package/src/core/lib/components/ui/separator/separator.jsx +22 -0
  262. package/src/core/lib/components/ui/sheet/index.js +34 -0
  263. package/src/core/lib/components/ui/sheet/sheet-close.jsx +7 -0
  264. package/src/core/lib/components/ui/sheet/sheet-content.jsx +35 -0
  265. package/src/core/lib/components/ui/sheet/sheet-description.jsx +15 -0
  266. package/src/core/lib/components/ui/sheet/sheet-footer.jsx +11 -0
  267. package/src/core/lib/components/ui/sheet/sheet-header.jsx +11 -0
  268. package/src/core/lib/components/ui/sheet/sheet-overlay.jsx +15 -0
  269. package/src/core/lib/components/ui/sheet/sheet-portal.jsx +4 -0
  270. package/src/core/lib/components/ui/sheet/sheet-title.jsx +15 -0
  271. package/src/core/lib/components/ui/sheet/sheet-trigger.jsx +7 -0
  272. package/src/core/lib/components/ui/sheet/sheet.jsx +4 -0
  273. package/src/core/lib/components/ui/textarea/index.js +7 -0
  274. package/src/core/lib/components/ui/textarea/textarea.jsx +18 -0
  275. package/src/core/lib/components/ui/toggle/index.js +8 -0
  276. package/src/core/lib/components/ui/toggle/toggle.jsx +36 -0
  277. package/src/core/lib/components/ui/toggle-group/index.js +10 -0
  278. package/src/core/lib/components/ui/toggle-group/toggle-group-item.jsx +29 -0
  279. package/src/core/lib/components/ui/toggle-group/toggle-group.jsx +43 -0
  280. package/src/core/lib/components/ui/tooltip/index.js +3 -0
  281. package/src/core/lib/components/ui/tooltip/tooltip-content.jsx +21 -0
  282. package/src/core/lib/components/ui/tooltip/tooltip-trigger.jsx +23 -0
  283. package/src/core/lib/components/ui/tooltip/tooltip.jsx +11 -0
  284. package/src/core/lib/components/ui/trigger-button/index.js +6 -0
  285. package/src/core/lib/components/ui/trigger-button/trigger-button.css +38 -0
  286. package/src/core/lib/components/ui/trigger-button/trigger-button.jsx +63 -0
  287. package/src/core/lib/utils/index.js +6 -0
  288. package/src/core/logger/devLogger.js +238 -0
  289. package/src/core/logger/devLogger.test.js +193 -0
  290. package/src/core/modes/modes.css +98 -0
  291. package/src/core/modes/modes.js +492 -0
  292. package/src/core/modes/modes.test.js +562 -0
  293. package/src/core/mountStoryboardCore.js +478 -0
  294. package/src/core/rename-watcher/config.json +23 -0
  295. package/src/core/rename-watcher/watcher.js +531 -0
  296. package/src/core/scaffold.js +100 -0
  297. package/src/core/server/index.js +391 -0
  298. package/src/core/session/bodyClasses.js +128 -0
  299. package/src/core/session/bodyClasses.test.js +192 -0
  300. package/src/core/session/hashSubscribe.js +19 -0
  301. package/src/core/session/hashSubscribe.test.js +62 -0
  302. package/src/core/session/hideMode.js +424 -0
  303. package/src/core/session/hideMode.test.js +268 -0
  304. package/src/core/session/interceptHideParams.js +35 -0
  305. package/src/core/session/interceptHideParams.test.js +90 -0
  306. package/src/core/session/localStorage.js +134 -0
  307. package/src/core/session/localStorage.test.js +148 -0
  308. package/src/core/session/session.js +76 -0
  309. package/src/core/session/session.test.js +91 -0
  310. package/src/core/stores/canvasConfig.js +134 -0
  311. package/src/core/stores/canvasConfig.test.js +120 -0
  312. package/src/core/stores/commandActions.js +284 -0
  313. package/src/core/stores/commandPaletteConfig.js +31 -0
  314. package/src/core/stores/configSchema.js +232 -0
  315. package/src/core/stores/configSchema.test.js +72 -0
  316. package/src/core/stores/configStore.js +161 -0
  317. package/src/core/stores/customerModeConfig.js +30 -0
  318. package/src/core/stores/featureFlags.js +127 -0
  319. package/src/core/stores/paletteProviders.js +360 -0
  320. package/src/core/stores/paletteProviders.test.js +186 -0
  321. package/src/core/stores/plugins.js +40 -0
  322. package/src/core/stores/plugins.test.js +68 -0
  323. package/src/core/stores/recentArtifacts.js +68 -0
  324. package/src/core/stores/recentArtifacts.test.js +71 -0
  325. package/src/core/stores/sidePanelStore.ts +143 -0
  326. package/src/core/stores/themeStore.ts +291 -0
  327. package/src/core/stores/toolRegistry.js +227 -0
  328. package/src/core/stores/toolStateStore.js +183 -0
  329. package/src/core/stores/toolStateStore.test.js +220 -0
  330. package/src/core/stores/toolbarConfigStore.js +165 -0
  331. package/src/core/stores/uiConfig.js +64 -0
  332. package/src/core/stores/uiConfig.test.js +63 -0
  333. package/src/core/styles/tailwind.css +204 -0
  334. package/src/core/tools/handlers/autosync.js +12 -0
  335. package/src/core/tools/handlers/canvasAddWidget.js +11 -0
  336. package/src/core/tools/handlers/canvasAgents.js +20 -0
  337. package/src/core/tools/handlers/canvasToolbar.js +56 -0
  338. package/src/core/tools/handlers/commandPalette.js +9 -0
  339. package/src/core/tools/handlers/comments.js +16 -0
  340. package/src/core/tools/handlers/create.js +39 -0
  341. package/src/core/tools/handlers/devtools.js +122 -0
  342. package/src/core/tools/handlers/devtools.test.js +87 -0
  343. package/src/core/tools/handlers/featureFlags.js +21 -0
  344. package/src/core/tools/handlers/flows.js +68 -0
  345. package/src/core/tools/handlers/hideChrome.js +9 -0
  346. package/src/core/tools/handlers/hideToolbars.js +25 -0
  347. package/src/core/tools/handlers/inspector.js +19 -0
  348. package/src/core/tools/handlers/paletteTheme.js +35 -0
  349. package/src/core/tools/handlers/theme.js +9 -0
  350. package/src/core/tools/registry.js +26 -0
  351. package/src/core/tools/surfaces/canvasToolbar.js +10 -0
  352. package/src/core/tools/surfaces/commandList.js +10 -0
  353. package/src/core/tools/surfaces/mainToolbar.js +11 -0
  354. package/src/core/tools/surfaces/registry.js +19 -0
  355. package/src/core/ui/ActionMenuButton.jsx +114 -0
  356. package/src/core/ui/AutosyncMenuButton.css +67 -0
  357. package/src/core/ui/AutosyncMenuButton.jsx +242 -0
  358. package/src/core/ui/BranchSelect.jsx +29 -0
  359. package/src/core/ui/BranchSelect.module.css +30 -0
  360. package/src/core/ui/CanvasAgentsMenu.jsx +89 -0
  361. package/src/core/ui/CanvasCreateMenu.jsx +611 -0
  362. package/src/core/ui/CanvasSnap.css +27 -0
  363. package/src/core/ui/CanvasSnap.jsx +51 -0
  364. package/src/core/ui/CanvasUndoRedo.css +36 -0
  365. package/src/core/ui/CanvasUndoRedo.jsx +62 -0
  366. package/src/core/ui/CanvasZoomControl.css +53 -0
  367. package/src/core/ui/CanvasZoomControl.jsx +49 -0
  368. package/src/core/ui/CanvasZoomToFit.css +18 -0
  369. package/src/core/ui/CanvasZoomToFit.jsx +26 -0
  370. package/src/core/ui/CommandMenu.css +8 -0
  371. package/src/core/ui/CommandMenu.jsx +287 -0
  372. package/src/core/ui/CommandPalette.jsx +35 -0
  373. package/src/core/ui/CommandPaletteTrigger.jsx +25 -0
  374. package/src/core/ui/CommentsMenuButton.jsx +40 -0
  375. package/src/core/ui/CoreUIBar.css +47 -0
  376. package/src/core/ui/CoreUIBar.jsx +905 -0
  377. package/src/core/ui/CreateMenuButton.jsx +117 -0
  378. package/src/core/ui/HideChromeTrigger.jsx +48 -0
  379. package/src/core/ui/Icon.jsx +279 -0
  380. package/src/core/ui/InspectorPanel.css +109 -0
  381. package/src/core/ui/InspectorPanel.jsx +632 -0
  382. package/src/core/ui/PwaInstallBanner.css +42 -0
  383. package/src/core/ui/PwaInstallBanner.jsx +124 -0
  384. package/src/core/ui/SidePanel.jsx +261 -0
  385. package/src/core/ui/ThemeMenuButton.jsx +139 -0
  386. package/src/core/ui/core-ui-colors.css +129 -0
  387. package/src/core/ui/design-modes.ts +7 -0
  388. package/src/core/ui/sidepanel.css +301 -0
  389. package/src/core/ui/viewfinder.ts +7 -0
  390. package/src/core/ui-entry.js +30 -0
  391. package/src/core/utils/fuzzySearch.js +117 -0
  392. package/src/core/utils/fuzzySearch.test.js +119 -0
  393. package/src/core/utils/mobileViewport.js +57 -0
  394. package/src/core/utils/mobileViewport.test.js +68 -0
  395. package/src/core/utils/prodMode.js +38 -0
  396. package/src/core/utils/smoothCorners.js +20 -0
  397. package/src/core/vite/docs-handler.js +155 -0
  398. package/src/core/vite/server-plugin.js +797 -0
  399. package/src/core/workshop/features/createCanvas/CreateCanvasForm.jsx +260 -0
  400. package/src/core/workshop/features/createCanvas/index.js +14 -0
  401. package/src/core/workshop/features/createFlow/CreateFlowForm.jsx +334 -0
  402. package/src/core/workshop/features/createFlow/index.js +19 -0
  403. package/src/core/workshop/features/createFlow/server.js +663 -0
  404. package/src/core/workshop/features/createPage/CreatePageForm.jsx +304 -0
  405. package/src/core/workshop/features/createPage/index.js +11 -0
  406. package/src/core/workshop/features/createPrototype/CreatePrototypeForm.jsx +289 -0
  407. package/src/core/workshop/features/createPrototype/index.js +19 -0
  408. package/src/core/workshop/features/createPrototype/server.js +433 -0
  409. package/src/core/workshop/features/createStory/CreateStoryForm.jsx +208 -0
  410. package/src/core/workshop/features/createStory/index.js +14 -0
  411. package/src/core/workshop/features/registry-server.js +22 -0
  412. package/src/core/workshop/features/registry.js +28 -0
  413. package/src/core/workshop/features/templateIndex.js +155 -0
  414. package/src/core/workshop/ui/WorkshopPanel.jsx +98 -0
  415. package/src/core/workshop/ui/mount.ts +6 -0
  416. package/src/core/worktree/port.js +268 -0
  417. package/src/core/worktree/port.test.js +222 -0
  418. package/src/core/worktree/serverRegistry.js +120 -0
  419. package/src/internals/AuthModal/AuthModal.jsx +132 -0
  420. package/src/internals/AuthModal/AuthModal.module.css +221 -0
  421. package/src/internals/BranchBar/BranchBar.jsx +87 -0
  422. package/src/internals/BranchBar/BranchBar.module.css +247 -0
  423. package/src/internals/BranchBar/useBranches.js +93 -0
  424. package/src/internals/BranchBar/useBranches.test.js +68 -0
  425. package/src/internals/CommandPalette/CommandPalette.jsx +1361 -0
  426. package/src/internals/CommandPalette/CreateDialog.jsx +219 -0
  427. package/src/internals/CommandPalette/command-palette.css +180 -0
  428. package/src/internals/FlowError.module.css +30 -0
  429. package/src/internals/Icon.jsx +279 -0
  430. package/src/internals/StoryboardContext.js +3 -0
  431. package/src/internals/Viewfinder.jsx +1479 -0
  432. package/src/internals/Viewfinder.module.css +1540 -0
  433. package/src/internals/Workspace.jsx +7 -0
  434. package/src/internals/__mocks__/virtual-storyboard-data-index.js +4 -0
  435. package/src/internals/canvas/CanvasControls.jsx +112 -0
  436. package/src/internals/canvas/CanvasControls.module.css +135 -0
  437. package/src/internals/canvas/CanvasPage.bridge.test.jsx +387 -0
  438. package/src/internals/canvas/CanvasPage.dragdrop.test.jsx +350 -0
  439. package/src/internals/canvas/CanvasPage.jsx +3092 -0
  440. package/src/internals/canvas/CanvasPage.module.css +187 -0
  441. package/src/internals/canvas/CanvasPage.multiselect.test.jsx +358 -0
  442. package/src/internals/canvas/CanvasToolbar.jsx +73 -0
  443. package/src/internals/canvas/CanvasToolbar.module.css +92 -0
  444. package/src/internals/canvas/ComponentErrorBoundary.jsx +50 -0
  445. package/src/internals/canvas/ConnectorLayer.jsx +208 -0
  446. package/src/internals/canvas/ConnectorLayer.module.css +129 -0
  447. package/src/internals/canvas/MarqueeOverlay.jsx +20 -0
  448. package/src/internals/canvas/PageSelector.jsx +587 -0
  449. package/src/internals/canvas/PageSelector.module.css +261 -0
  450. package/src/internals/canvas/PageSelector.test.jsx +113 -0
  451. package/src/internals/canvas/WebGLContextPool.jsx +292 -0
  452. package/src/internals/canvas/WebGLContextPool.test.jsx +165 -0
  453. package/src/internals/canvas/canvasApi.js +164 -0
  454. package/src/internals/canvas/canvasReloadGuard.js +37 -0
  455. package/src/internals/canvas/canvasReloadGuard.test.js +27 -0
  456. package/src/internals/canvas/canvasTheme.js +118 -0
  457. package/src/internals/canvas/componentIsolate.jsx +165 -0
  458. package/src/internals/canvas/componentSetIsolate.jsx +257 -0
  459. package/src/internals/canvas/computeCanvasBounds.test.js +121 -0
  460. package/src/internals/canvas/connectorGeometry.js +132 -0
  461. package/src/internals/canvas/hotPoolDevLogs.js +25 -0
  462. package/src/internals/canvas/textSelection.js +10 -0
  463. package/src/internals/canvas/textSelection.test.js +26 -0
  464. package/src/internals/canvas/useCanvas.js +126 -0
  465. package/src/internals/canvas/useCanvas.test.js +26 -0
  466. package/src/internals/canvas/useMarqueeSelect.js +213 -0
  467. package/src/internals/canvas/useMarqueeSelect.test.js +78 -0
  468. package/src/internals/canvas/useUndoRedo.js +86 -0
  469. package/src/internals/canvas/useUndoRedo.test.js +231 -0
  470. package/src/internals/canvas/widgets/CodePenEmbed.jsx +293 -0
  471. package/src/internals/canvas/widgets/CodePenEmbed.module.css +161 -0
  472. package/src/internals/canvas/widgets/ComponentSetWidget.jsx +2 -0
  473. package/src/internals/canvas/widgets/ComponentSetWidget.module.css +89 -0
  474. package/src/internals/canvas/widgets/ComponentWidget.jsx +14 -0
  475. package/src/internals/canvas/widgets/ComponentWidget.module.css +0 -0
  476. package/src/internals/canvas/widgets/CropOverlay.jsx +179 -0
  477. package/src/internals/canvas/widgets/CropOverlay.module.css +154 -0
  478. package/src/internals/canvas/widgets/ExpandedPane.jsx +474 -0
  479. package/src/internals/canvas/widgets/ExpandedPane.module.css +179 -0
  480. package/src/internals/canvas/widgets/ExpandedPane.test.jsx +240 -0
  481. package/src/internals/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  482. package/src/internals/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  483. package/src/internals/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  484. package/src/internals/canvas/widgets/FigmaEmbed.jsx +296 -0
  485. package/src/internals/canvas/widgets/FigmaEmbed.module.css +222 -0
  486. package/src/internals/canvas/widgets/FrozenTerminalOverlay.jsx +151 -0
  487. package/src/internals/canvas/widgets/FrozenTerminalOverlay.module.css +83 -0
  488. package/src/internals/canvas/widgets/ImageWidget.jsx +287 -0
  489. package/src/internals/canvas/widgets/ImageWidget.module.css +81 -0
  490. package/src/internals/canvas/widgets/LinkPreview.jsx +439 -0
  491. package/src/internals/canvas/widgets/LinkPreview.module.css +585 -0
  492. package/src/internals/canvas/widgets/LinkPreview.test.jsx +193 -0
  493. package/src/internals/canvas/widgets/MarkdownBlock.jsx +354 -0
  494. package/src/internals/canvas/widgets/MarkdownBlock.module.css +377 -0
  495. package/src/internals/canvas/widgets/MarkdownBlock.test.jsx +92 -0
  496. package/src/internals/canvas/widgets/PromptWidget.jsx +428 -0
  497. package/src/internals/canvas/widgets/PromptWidget.module.css +273 -0
  498. package/src/internals/canvas/widgets/PrototypeEmbed.jsx +463 -0
  499. package/src/internals/canvas/widgets/PrototypeEmbed.module.css +579 -0
  500. package/src/internals/canvas/widgets/PrototypeEmbed.test.jsx +10 -0
  501. package/src/internals/canvas/widgets/ResizeHandle.jsx +67 -0
  502. package/src/internals/canvas/widgets/ResizeHandle.module.css +29 -0
  503. package/src/internals/canvas/widgets/StickyNote.jsx +92 -0
  504. package/src/internals/canvas/widgets/StickyNote.module.css +70 -0
  505. package/src/internals/canvas/widgets/StickyNote.test.jsx +116 -0
  506. package/src/internals/canvas/widgets/StorySetWidget.jsx +208 -0
  507. package/src/internals/canvas/widgets/StorySetWidget.module.css +89 -0
  508. package/src/internals/canvas/widgets/StoryWidget.jsx +334 -0
  509. package/src/internals/canvas/widgets/StoryWidget.module.css +211 -0
  510. package/src/internals/canvas/widgets/TerminalReadWidget.jsx +146 -0
  511. package/src/internals/canvas/widgets/TerminalReadWidget.module.css +94 -0
  512. package/src/internals/canvas/widgets/TerminalWidget.jsx +704 -0
  513. package/src/internals/canvas/widgets/TerminalWidget.module.css +444 -0
  514. package/src/internals/canvas/widgets/TilesWidget.jsx +300 -0
  515. package/src/internals/canvas/widgets/TilesWidget.module.css +133 -0
  516. package/src/internals/canvas/widgets/WidgetChrome.jsx +580 -0
  517. package/src/internals/canvas/widgets/WidgetChrome.module.css +421 -0
  518. package/src/internals/canvas/widgets/WidgetWrapper.jsx +15 -0
  519. package/src/internals/canvas/widgets/WidgetWrapper.module.css +25 -0
  520. package/src/internals/canvas/widgets/codepenUrl.js +75 -0
  521. package/src/internals/canvas/widgets/codepenUrl.test.js +76 -0
  522. package/src/internals/canvas/widgets/embedInteraction.test.jsx +173 -0
  523. package/src/internals/canvas/widgets/embedOverlay.module.css +35 -0
  524. package/src/internals/canvas/widgets/embedTheme.js +148 -0
  525. package/src/internals/canvas/widgets/expandUtils.js +559 -0
  526. package/src/internals/canvas/widgets/expandUtils.test.js +155 -0
  527. package/src/internals/canvas/widgets/figmaUrl.js +118 -0
  528. package/src/internals/canvas/widgets/figmaUrl.test.js +139 -0
  529. package/src/internals/canvas/widgets/githubUrl.js +82 -0
  530. package/src/internals/canvas/widgets/githubUrl.test.js +74 -0
  531. package/src/internals/canvas/widgets/iframeDevLogs.js +49 -0
  532. package/src/internals/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  533. package/src/internals/canvas/widgets/index.js +42 -0
  534. package/src/internals/canvas/widgets/pasteRules.js +295 -0
  535. package/src/internals/canvas/widgets/pasteRules.test.js +474 -0
  536. package/src/internals/canvas/widgets/snapshotDisplay.test.jsx +211 -0
  537. package/src/internals/canvas/widgets/tilePool.js +23 -0
  538. package/src/internals/canvas/widgets/tiles/diagonal-bl.png +0 -0
  539. package/src/internals/canvas/widgets/tiles/diagonal-br.png +0 -0
  540. package/src/internals/canvas/widgets/tiles/diagonal-tl.png +0 -0
  541. package/src/internals/canvas/widgets/tiles/leaf.png +0 -0
  542. package/src/internals/canvas/widgets/tiles/quarter-tl.png +0 -0
  543. package/src/internals/canvas/widgets/tiles/quarter-tr.png +0 -0
  544. package/src/internals/canvas/widgets/tiles/solid-a.png +0 -0
  545. package/src/internals/canvas/widgets/tiles/solid-b.png +0 -0
  546. package/src/internals/canvas/widgets/widgetConfig.js +291 -0
  547. package/src/internals/canvas/widgets/widgetConfig.test.js +68 -0
  548. package/src/internals/canvas/widgets/widgetIcons.jsx +190 -0
  549. package/src/internals/canvas/widgets/widgetProps.js +133 -0
  550. package/src/internals/context/FormContext.js +13 -0
  551. package/src/internals/context/FormContext.test.js +48 -0
  552. package/src/internals/context.jsx +481 -0
  553. package/src/internals/context.test.jsx +296 -0
  554. package/src/internals/hashPreserver.js +73 -0
  555. package/src/internals/hashPreserver.test.js +107 -0
  556. package/src/internals/hooks/useConfig.js +14 -0
  557. package/src/internals/hooks/useFeatureFlag.js +14 -0
  558. package/src/internals/hooks/useFlows.js +50 -0
  559. package/src/internals/hooks/useFlows.test.js +134 -0
  560. package/src/internals/hooks/useHideMode.js +31 -0
  561. package/src/internals/hooks/useHideMode.test.js +43 -0
  562. package/src/internals/hooks/useLocalStorage.js +57 -0
  563. package/src/internals/hooks/useLocalStorage.test.js +75 -0
  564. package/src/internals/hooks/useMode.js +43 -0
  565. package/src/internals/hooks/useObject.js +101 -0
  566. package/src/internals/hooks/useObject.test.js +74 -0
  567. package/src/internals/hooks/useOverride.js +84 -0
  568. package/src/internals/hooks/useOverride.test.js +71 -0
  569. package/src/internals/hooks/usePrototypeReloadGuard.js +64 -0
  570. package/src/internals/hooks/useRecord.js +158 -0
  571. package/src/internals/hooks/useRecord.test.js +221 -0
  572. package/src/internals/hooks/useScene.js +38 -0
  573. package/src/internals/hooks/useScene.test.js +66 -0
  574. package/src/internals/hooks/useSceneData.js +108 -0
  575. package/src/internals/hooks/useSceneData.test.js +136 -0
  576. package/src/internals/hooks/useSession.js +4 -0
  577. package/src/internals/hooks/useSession.test.js +8 -0
  578. package/src/internals/hooks/useThemeState.js +61 -0
  579. package/src/internals/hooks/useThemeState.test.js +66 -0
  580. package/src/internals/hooks/useUndoRedo.js +28 -0
  581. package/src/internals/hooks/useUndoRedo.test.js +64 -0
  582. package/src/internals/index.js +58 -0
  583. package/src/internals/story/ComponentSetPage.jsx +198 -0
  584. package/src/internals/story/ComponentSetPage.module.css +129 -0
  585. package/src/internals/story/StoryPage.jsx +147 -0
  586. package/src/internals/story/StoryPage.module.css +18 -0
  587. package/src/internals/test-utils.js +45 -0
  588. package/src/internals/vite/data-plugin.js +1508 -0
  589. package/src/internals/vite/data-plugin.test.js +1223 -0
  590. package/src/test-utils.js +44 -0
  591. package/toolbar.config.json +271 -0
  592. package/widgets.config.json +1537 -0
@@ -0,0 +1,3134 @@
1
+ /**
2
+ * Canvas Server API — CRUD operations for .canvas.jsonl files.
3
+ *
4
+ * Canvas data is stored as an append-only JSONL event stream.
5
+ * Each line is a JSON event object. The first line is always a
6
+ * `canvas_created` event containing the full initial state.
7
+ * Subsequent lines are atomic change events. Current state is
8
+ * derived by replaying the stream via the materializer.
9
+ *
10
+ * Routes (mounted at /_storyboard/canvas/):
11
+ * GET /read — read materialized canvas state
12
+ * GET /list — list all canvases
13
+ * GET /folders — list canvas folders
14
+ * PUT /update — append update events (widgets, sources, settings)
15
+ * PUT /rename-page — rename a canvas page file
16
+ * PUT /reorder-pages — save page order for a canvas folder
17
+ * GET /page-order — read page order for a folder
18
+ * PUT /update-folder-meta — update folder .meta.json title
19
+ * POST /widget — append a widget_added event
20
+ * PATCH /widget — update a single widget's props/position
21
+ * DELETE /widget — append a widget_removed event
22
+ * POST /connector — append a connector_added event
23
+ * DELETE /connector — append a connector_removed event
24
+ * POST /batch — execute multiple operations in one request (refs, single HMR push)
25
+ * POST /create — create a new .canvas.jsonl file
26
+ * GET /stories — list all .story.{jsx,tsx} files with exports
27
+ * POST /create-story — scaffold a new .story.{jsx,tsx} file
28
+ * GET /github/available — check if local gh CLI is installed
29
+ * POST /github/embed — fetch GitHub issue/discussion/PR/comment metadata via gh
30
+ * POST /image — upload a pasted image to src/canvas/images/
31
+ * GET /images/* — serve an image file from src/canvas/images/
32
+ * POST /image/toggle-private — toggle ~prefix on image filename
33
+ * GET /terminal-buffer/:id — read private terminal buffer (with ?length=N)
34
+ * GET /terminal-snapshot/:id — read public terminal snapshot
35
+ */
36
+
37
+ import fs from 'node:fs'
38
+ import path from 'node:path'
39
+ import { Buffer } from 'node:buffer'
40
+ import { materializeFromText, serializeEvent } from './materializer.js'
41
+ import { toCanvasId, parseCanvasId } from './identity.js'
42
+ import {
43
+ GH_INSTALL_URL,
44
+ GitHubEmbedError,
45
+ fetchGitHubEmbedSnapshot,
46
+ isGhCliAvailable,
47
+ isGitHubEmbedUrl,
48
+ } from './githubEmbeds.js'
49
+ import { stampBounds, stampBoundsAll, resolvePosition, getWidgetBounds } from './collision.js'
50
+ import { markCanvasWrite, unmarkCanvasWrite } from './writeGuard.js'
51
+ import { devLog } from '../logger/devLogger.js'
52
+ import widgetsConfig from '../../../widgets.config.json' with { type: 'json' }
53
+
54
+ /**
55
+ * Read the prompt widget's execution config from widgets.config.json.
56
+ * Returns { default, agents } where each agent has a command template.
57
+ */
58
+ function getPromptExecution() {
59
+ return widgetsConfig?.widgets?.prompt?.execution || null
60
+ }
61
+
62
+ /**
63
+ * Build the CLI command for a prompt spawn.
64
+ * Reads the prompt widget's execution.agents config and interpolates ${prompt}.
65
+ */
66
+ function buildPromptCmd({ prompt, envFile, agentId }) {
67
+ const execution = getPromptExecution()
68
+
69
+ if (!execution) {
70
+ // Bare fallback — no execution config found
71
+ const escaped = prompt.replace(/"/g, '\\"')
72
+ return `source ${envFile} && copilot -p "${escaped}" --allow-all`
73
+ }
74
+
75
+ const id = agentId || execution.default
76
+ const agent = execution.agents?.[id]
77
+
78
+ if (!agent?.command) {
79
+ return null // This agent doesn't have a prompt command
80
+ }
81
+
82
+ const escaped = prompt.replace(/"/g, '\\"')
83
+ const cmd = agent.command.replace('${prompt}', escaped)
84
+ return `source ${envFile} && ${cmd}`
85
+ }
86
+
87
+ /**
88
+ * Scan src/canvas/ for directories containing .meta.json files.
89
+ * Returns an object keyed by directory name (without .folder suffix).
90
+ */
91
+ function findCanvasMeta(root) {
92
+ const canvasDir = path.join(root, 'src', 'canvas')
93
+ const groups = {}
94
+ if (!fs.existsSync(canvasDir)) return groups
95
+
96
+ const entries = fs.readdirSync(canvasDir, { withFileTypes: true })
97
+ for (const entry of entries) {
98
+ if (!entry.isDirectory()) continue
99
+ const dirName = entry.name.replace(/\.folder$/, '')
100
+ const metaPath = path.join(canvasDir, entry.name, `${dirName}.meta.json`)
101
+ if (fs.existsSync(metaPath)) {
102
+ try {
103
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'))
104
+ groups[dirName] = meta
105
+ } catch { /* skip invalid meta */ }
106
+ }
107
+ }
108
+ return groups
109
+ }
110
+
111
+ /**
112
+ * Read .meta.json from a canvas folder directory.
113
+ */
114
+ function readFolderMeta(folderDir) {
115
+ const dirName = path.basename(folderDir).replace(/\.folder$/, '')
116
+ const metaPath = path.join(folderDir, `${dirName}.meta.json`)
117
+ if (fs.existsSync(metaPath)) {
118
+ try { return JSON.parse(fs.readFileSync(metaPath, 'utf-8')) } catch { /* ignore */ }
119
+ }
120
+ return {}
121
+ }
122
+
123
+ /**
124
+ * Write .meta.json to a canvas folder directory.
125
+ */
126
+ function writeFolderMeta(folderDir, meta) {
127
+ const dirName = path.basename(folderDir).replace(/\.folder$/, '')
128
+ const metaPath = path.join(folderDir, `${dirName}.meta.json`)
129
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf-8')
130
+ }
131
+
132
+ /**
133
+ * Recursively find all .canvas.jsonl files in the project.
134
+ */
135
+ function findCanvasFiles(root) {
136
+ const results = []
137
+ const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees'])
138
+
139
+ function walk(dir, rel) {
140
+ let entries
141
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
142
+ for (const entry of entries) {
143
+ if (ignore.has(entry.name)) continue
144
+ const fullPath = path.join(dir, entry.name)
145
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name
146
+ if (entry.isDirectory()) {
147
+ walk(fullPath, relPath)
148
+ } else if (entry.name.endsWith('.canvas.jsonl')) {
149
+ results.push(relPath)
150
+ }
151
+ }
152
+ }
153
+
154
+ walk(root, '')
155
+ return results
156
+ }
157
+
158
+ /**
159
+ * Recursively find all .story.{jsx,tsx} files in routable directories
160
+ * (src/canvas/ and src/components/) and extract their named exports.
161
+ */
162
+ function findStoryFiles(root) {
163
+ const results = []
164
+ const ignore = new Set(['node_modules', 'dist', '.git', '.worktrees'])
165
+ const ROUTABLE_DIRS = ['src/canvas', 'src/components']
166
+
167
+ function walk(dir, rel) {
168
+ let entries
169
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
170
+ for (const entry of entries) {
171
+ if (ignore.has(entry.name)) continue
172
+ if (entry.name.startsWith('_')) continue
173
+ const fullPath = path.join(dir, entry.name)
174
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name
175
+ if (entry.isDirectory()) {
176
+ walk(fullPath, relPath)
177
+ } else if (/\.story\.(jsx|tsx)$/.test(entry.name)) {
178
+ const name = entry.name.replace(/\.story\.(jsx|tsx)$/, '')
179
+ const exports = parseExportNames(fullPath)
180
+ results.push({ name, path: relPath, exports })
181
+ }
182
+ }
183
+ }
184
+
185
+ for (const dir of ROUTABLE_DIRS) {
186
+ const absDir = path.join(root, dir)
187
+ if (fs.existsSync(absDir)) {
188
+ walk(absDir, dir)
189
+ }
190
+ }
191
+ return results
192
+ }
193
+
194
+ /**
195
+ * Parse named function/const exports from a JSX/TSX file.
196
+ */
197
+ function parseExportNames(filePath) {
198
+ try {
199
+ const src = fs.readFileSync(filePath, 'utf-8')
200
+ const names = []
201
+ const re = /export\s+(?:function|const|class)\s+([A-Z]\w*)/g
202
+ let m
203
+ while ((m = re.exec(src)) !== null) names.push(m[1])
204
+ return names
205
+ } catch { return [] }
206
+ }
207
+
208
+ /**
209
+ * Find a canvas JSONL file by canonical ID.
210
+ * Only matches canonical path-based IDs from toCanvasId().
211
+ */
212
+ function findCanvasPath(root, canvasId) {
213
+ const files = findCanvasFiles(root)
214
+
215
+ for (const file of files) {
216
+ const id = toCanvasId(file)
217
+ if (id === canvasId) {
218
+ return path.resolve(root, file)
219
+ }
220
+ }
221
+
222
+ return null
223
+ }
224
+
225
+ /**
226
+ * Read a .canvas.jsonl file and materialize its current state.
227
+ */
228
+ function readCanvas(filePath) {
229
+ const raw = fs.readFileSync(filePath, 'utf-8')
230
+ return materializeFromText(raw)
231
+ }
232
+
233
+ /**
234
+ * Append a single event line to a .canvas.jsonl file.
235
+ */
236
+ function appendEventRaw(filePath, event) {
237
+ fs.appendFileSync(filePath, serializeEvent(event) + '\n', 'utf-8')
238
+ }
239
+
240
+ /**
241
+ * Generate a unique widget ID.
242
+ */
243
+ function generateWidgetId(type) {
244
+ const suffix = Math.random().toString(36).slice(2, 8)
245
+ return `${type}-${suffix}`
246
+ }
247
+
248
+ /**
249
+ * Create the canvas API route handler.
250
+ */
251
+ export function createCanvasHandler(ctx) {
252
+ const { root, sendJson, hotPool } = ctx
253
+
254
+ /**
255
+ * Compute a target position relative to a reference widget.
256
+ * @param {object} refWidget — widget to position near (must have position + type/props)
257
+ * @param {string} direction — 'right' | 'left' | 'above' | 'below'
258
+ * @param {string} newType — type of the widget being created (for size defaults)
259
+ * @param {object} newProps — props of the widget being created
260
+ * @param {number} gap — spacing between widgets (default 40)
261
+ * @returns {{ x: number, y: number }}
262
+ */
263
+ function computeNearPosition(refWidget, direction = 'right', newType = 'sticky-note', newProps = {}, gap = 40) {
264
+ const refBounds = getWidgetBounds(refWidget)
265
+ const newDefaults = getWidgetBounds({ type: newType, props: newProps, position: { x: 0, y: 0 } })
266
+ switch (direction) {
267
+ case 'left':
268
+ return { x: refBounds.x - newDefaults.width - gap, y: refBounds.y }
269
+ case 'above':
270
+ return { x: refBounds.x, y: refBounds.y - newDefaults.height - gap }
271
+ case 'below':
272
+ return { x: refBounds.x, y: refBounds.y + refBounds.height + gap }
273
+ case 'right':
274
+ default:
275
+ return { x: refBounds.x + refBounds.width + gap, y: refBounds.y }
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Compute a smart default position when no --near or explicit x,y is given.
281
+ * Priority chain:
282
+ * 1. Active agent/terminal (source widget ID from request)
283
+ * 2. User-selected widget (from .selectedwidgets.json, same canvas)
284
+ * 3. Viewport center (from .selectedwidgets.json)
285
+ * 4. Last widget on canvas
286
+ * 5. Origin (0, 0) — empty canvas, no viewport
287
+ *
288
+ * @param {object[]} canvasWidgets — current widgets on the canvas
289
+ * @param {string} type — widget type being created
290
+ * @param {object} props — widget props
291
+ * @param {string} projectRoot — project root directory
292
+ * @param {string|null} canvasName — canvas ID for matching selectedwidgets context
293
+ * @param {string|null} sourceWidgetId — caller's widget ID (agent/terminal creating this widget)
294
+ */
295
+ async function computeAutoPosition(canvasWidgets, type, props, projectRoot, canvasName, sourceWidgetId) {
296
+ const widgetMap = new Map((canvasWidgets || []).map(w => [w.id, w]))
297
+
298
+ // 1. Place near the source agent/terminal widget
299
+ if (sourceWidgetId && widgetMap.has(sourceWidgetId)) {
300
+ return computeNearPosition(widgetMap.get(sourceWidgetId), 'right', type, props)
301
+ }
302
+
303
+ // 2–3. Read .selectedwidgets.json for selection + viewport context
304
+ try {
305
+ const { readSelectedWidgets } = await import('./selectedWidgets.js')
306
+ const sw = readSelectedWidgets(projectRoot)
307
+ if (sw && sw.canvasId === canvasName) {
308
+ // 2. Place near the selected widget
309
+ if (sw.selectedWidgetIds?.length > 0) {
310
+ const selectedId = sw.selectedWidgetIds[0]
311
+ if (widgetMap.has(selectedId)) {
312
+ return computeNearPosition(widgetMap.get(selectedId), 'right', type, props)
313
+ }
314
+ }
315
+
316
+ // 3. Place at viewport center
317
+ const vp = sw.viewport
318
+ if (vp && vp.centerX != null && vp.centerY != null) {
319
+ return { x: Math.round(vp.centerX / 24) * 24, y: Math.round(vp.centerY / 24) * 24 }
320
+ }
321
+ }
322
+ } catch { /* selectedWidgets bridge may not be initialized */ }
323
+
324
+ // 4. Place near the last widget on the canvas
325
+ if (canvasWidgets && canvasWidgets.length > 0) {
326
+ const lastWidget = canvasWidgets[canvasWidgets.length - 1]
327
+ return computeNearPosition(lastWidget, 'right', type, props)
328
+ }
329
+
330
+ // 5. Truly empty canvas, no viewport
331
+ return { x: 0, y: 0 }
332
+ }
333
+
334
+ /**
335
+ * Update terminal configs when connectors change.
336
+ * Finds all terminal widgets in the canvas, computes their connected widget IDs
337
+ * from the current connector list, and updates their config files.
338
+ */
339
+ async function updateTerminalConnectionsForCanvas(root, canvasName, canvasData, connectors) {
340
+ try {
341
+ const { updateTerminalConnections, initTerminalConfig } = await import('./terminal-config.js')
342
+ const { execSync } = await import('node:child_process')
343
+ initTerminalConfig(root)
344
+
345
+ let branch = 'unknown'
346
+ try {
347
+ branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
348
+ } catch { /* empty */ }
349
+
350
+ const widgets = canvasData.widgets || []
351
+ const widgetMap = new Map(widgets.map(w => [w.id, w]))
352
+ const terminalWidgets = widgets.filter((w) => w.type === 'terminal' || w.type === 'agent' || w.type === 'prompt')
353
+
354
+ for (const tw of terminalWidgets) {
355
+ const connectedIds = new Set()
356
+ const messagingPeers = []
357
+ for (const conn of connectors) {
358
+ let peerId = null
359
+ let direction = null
360
+ if (conn.start?.widgetId === tw.id) {
361
+ peerId = conn.end?.widgetId
362
+ direction = 'outgoing' // tw → peer
363
+ }
364
+ if (conn.end?.widgetId === tw.id) {
365
+ peerId = conn.start?.widgetId
366
+ direction = 'incoming' // peer → tw
367
+ }
368
+ if (peerId) {
369
+ connectedIds.add(peerId)
370
+ const mode = conn.meta?.messagingMode || 'none'
371
+ if (mode !== 'none') {
372
+ const peerWidget = widgetMap.get(peerId)
373
+ if (peerWidget && (peerWidget.type === 'terminal' || peerWidget.type === 'agent')) {
374
+ const canSend = mode === 'two-way' || (mode === 'one-way' && direction === 'outgoing')
375
+ const canReceive = mode === 'two-way' || (mode === 'one-way' && direction === 'incoming')
376
+ messagingPeers.push({
377
+ widgetId: peerId,
378
+ displayName: peerWidget.props?.prettyName || peerId,
379
+ configPath: `.storyboard/terminals/${peerId}.json`,
380
+ type: peerWidget.type,
381
+ canSend,
382
+ canReceive,
383
+ mode,
384
+ })
385
+ }
386
+ }
387
+ }
388
+ }
389
+ connectedIds.delete(undefined)
390
+ connectedIds.delete(null)
391
+
392
+ // Resolve full widget objects for connected widgets
393
+ const connectedWidgets = [...connectedIds]
394
+ .map(id => widgetMap.get(id))
395
+ .filter(Boolean)
396
+ .map(w => ({ id: w.id, type: w.type, props: w.props, position: w.position }))
397
+
398
+ // Build messaging section if there are messaging-enabled peers
399
+ const messaging = messagingPeers.length > 0 ? { peers: messagingPeers } : null
400
+
401
+ updateTerminalConnections({
402
+ branch,
403
+ canvasId: canvasName,
404
+ widgetId: tw.id,
405
+ connectedWidgets,
406
+ widgetProps: tw.props || null,
407
+ messaging,
408
+ })
409
+ }
410
+ } catch (err) {
411
+ devLog().logEvent('warn', 'Failed to update terminal connections', { error: err.message })
412
+ }
413
+ }
414
+
415
+ // Append an event to an existing canvas file.
416
+ // Marks the file in the write guard so the data plugin's watcher handler
417
+ // skips sending a duplicate HMR event (the server pushes its own via
418
+ // pushCanvasUpdate after the write).
419
+ function appendEvent(filePath, event) {
420
+ markCanvasWrite(filePath)
421
+ appendEventRaw(filePath, event)
422
+ // Unmark after enough time for the watcher to fire and be suppressed.
423
+ // macOS FSEvents latency is typically 100-500ms; 1s covers edge cases.
424
+ setTimeout(() => unmarkCanvasWrite(filePath), 1000)
425
+ }
426
+
427
+ /**
428
+ * Prepare a terminal/agent widget: auto-assign displayName and pre-reserve identity.
429
+ * Shared by POST /widget and batch create-widget.
430
+ * @param {{ type: string, props: Object }} opts
431
+ * @param {string} widgetId
432
+ * @param {string} canvasName
433
+ * @param {import('node:http').IncomingMessage} [req]
434
+ */
435
+ async function prepareTerminalWidget({ type, props, widgetId, canvasName, req }) {
436
+ if (type !== 'terminal' && type !== 'agent') return
437
+
438
+ if (!props.prettyName) {
439
+ try {
440
+ const { generateFriendlyName } = await import('./terminal-registry.js')
441
+ props.prettyName = generateFriendlyName()
442
+ } catch { /* registry not initialized yet */ }
443
+ }
444
+
445
+ try {
446
+ const { preReserveTerminalIdentity, initTerminalConfig } = await import('./terminal-config.js')
447
+ initTerminalConfig(root)
448
+ let branch = 'unknown'
449
+ try {
450
+ const { execSync } = await import('node:child_process')
451
+ branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
452
+ } catch { /* empty */ }
453
+ const serverUrl = `http://localhost:${req?.socket?.localPort || 1234}`
454
+ preReserveTerminalIdentity({
455
+ widgetId,
456
+ preDisplayName: props.prettyName || null,
457
+ canvasId: canvasName,
458
+ branch,
459
+ serverUrl,
460
+ })
461
+ } catch { /* best effort */ }
462
+ }
463
+
464
+ /**
465
+ * Resolve which hot pool to use for a widget type + props.
466
+ * Agent widgets use their agentId as pool ID; terminals use 'terminal'.
467
+ */
468
+ function resolvePoolId(type, props) {
469
+ if (type === 'agent' && props?.agentId) return props.agentId
470
+ return 'terminal'
471
+ }
472
+
473
+ /**
474
+ * Try to acquire a warm session from the hot pool.
475
+ * @param {Object|null} hotPool — HotPoolManager instance
476
+ * @param {string} poolId — pool to acquire from
477
+ * @param {string} [mode] — 'auto' (default), 'hot', or 'cold'
478
+ * @returns {Object|null} acquired session or null
479
+ */
480
+ function acquireFromPool(hotPool, poolId, mode) {
481
+ if (!hotPool || mode === 'cold') return null
482
+ const effectiveMode = mode || 'auto'
483
+ if (effectiveMode === 'cold') return null
484
+ if (!hotPool.has(poolId)) return null
485
+ return hotPool.acquire(poolId) || null
486
+ }
487
+
488
+ /**
489
+ * Push live canvas update to connected clients via Vite HMR.
490
+ * Reads the full materialized state from disk and sends it as a custom
491
+ * event so useCanvas can update in-place without a page refresh.
492
+ */
493
+ function pushCanvasUpdate(canvasName, filePath, viteWs) {
494
+ if (!viteWs) return
495
+ try {
496
+ const data = readCanvas(filePath)
497
+ viteWs.send({
498
+ type: 'custom',
499
+ event: 'storyboard:canvas-file-changed',
500
+ data: { canvasId: canvasName, name: canvasName, metadata: data },
501
+ })
502
+
503
+ // Refresh terminal config files on every canvas change so agents
504
+ // always see up-to-date connectedWidgets and widget props.
505
+ updateTerminalConnectionsForCanvas(root, canvasName, data, data.connectors || [])
506
+ } catch { /* best effort — watcher will catch it eventually */ }
507
+ }
508
+
509
+ // Write a new JSONL file with a single creation event.
510
+ // New files are detected naturally by Vite's watcher as an `add` event,
511
+ // which correctly triggers a full reload to register new routes.
512
+ function writeNewCanvas(filePath, event) {
513
+ fs.writeFileSync(filePath, serializeEvent(event) + '\n', 'utf-8')
514
+ }
515
+
516
+ return async (req, res, { body, path: routePath, method, __viteWs }) => {
517
+ // GET /folders — list available canvas folders
518
+ if (routePath === '/folders' && method === 'GET') {
519
+ const canvasDir = path.join(root, 'src', 'canvas')
520
+ let folders = []
521
+ try {
522
+ if (fs.existsSync(canvasDir)) {
523
+ const entries = fs.readdirSync(canvasDir, { withFileTypes: true })
524
+ // .folder directories (existing behavior)
525
+ const folderDirs = entries
526
+ .filter((d) => d.isDirectory() && d.name.endsWith('.folder'))
527
+ .map((d) => d.name.replace('.folder', ''))
528
+ // Plain directories containing .canvas.jsonl files
529
+ const plainDirs = entries
530
+ .filter((d) => {
531
+ if (!d.isDirectory() || d.name.endsWith('.folder') || d.name.startsWith('_')) return false
532
+ const files = fs.readdirSync(path.join(canvasDir, d.name))
533
+ return files.some((f) => f.endsWith('.canvas.jsonl'))
534
+ })
535
+ .map((d) => d.name)
536
+ folders = [...folderDirs, ...plainDirs]
537
+ }
538
+ } catch { /* empty */ }
539
+ sendJson(res, 200, { folders })
540
+ return
541
+ }
542
+
543
+ // GET /read?name=... — read materialized canvas data from disk
544
+ if (routePath.startsWith('/read') && method === 'GET') {
545
+ const url = new URL(routePath, 'http://localhost')
546
+ const name = url.searchParams.get('name')
547
+ if (!name) {
548
+ sendJson(res, 400, { error: 'Canvas name is required (?name=...)' })
549
+ return
550
+ }
551
+ const filePath = findCanvasPath(root, name)
552
+ if (!filePath) {
553
+ sendJson(res, 404, { error: `Canvas "${name}" not found` })
554
+ return
555
+ }
556
+ try {
557
+ const data = readCanvas(filePath)
558
+ const widgetFilter = url.searchParams.get('widget')
559
+ if (widgetFilter) {
560
+ const widget = (data.widgets || []).find((w) => w.id === widgetFilter)
561
+ if (!widget) {
562
+ sendJson(res, 404, { error: `Widget "${widgetFilter}" not found in canvas "${name}"` })
563
+ return
564
+ }
565
+ sendJson(res, 200, { ...data, widgets: [widget] })
566
+ } else {
567
+ sendJson(res, 200, data)
568
+ }
569
+ } catch (err) {
570
+ sendJson(res, 500, { error: `Failed to read canvas: ${err.message}` })
571
+ }
572
+ return
573
+ }
574
+
575
+ // GET /list — list all canvases
576
+ if (routePath === '/list' && method === 'GET') {
577
+ const files = findCanvasFiles(root)
578
+ const canvases = files.map((file) => {
579
+ const id = toCanvasId(file)
580
+ if (!id) return null
581
+ const { segments } = parseCanvasId(id)
582
+ const group = segments.length > 1 ? segments.slice(0, -1).join('/') : null
583
+ try {
584
+ const data = readCanvas(path.resolve(root, file))
585
+ return {
586
+ name: id,
587
+ title: data.title || segments[segments.length - 1],
588
+ path: file,
589
+ widgetCount: (data.widgets || []).length + (data.sources || []).length,
590
+ group,
591
+ }
592
+ } catch {
593
+ return { name: id, title: segments[segments.length - 1], path: file, widgetCount: 0, group }
594
+ }
595
+ }).filter(Boolean)
596
+ const groups = findCanvasMeta(root)
597
+
598
+ // Sort canvases within each group by saved pageOrder from .meta.json
599
+ const groupOrderMaps = new Map()
600
+ for (const [groupName, meta] of Object.entries(groups)) {
601
+ if (Array.isArray(meta.pageOrder)) {
602
+ const orderMap = new Map()
603
+ meta.pageOrder.forEach((entry, idx) => {
604
+ if (typeof entry === 'string' && !entry.startsWith('sep-')) orderMap.set(entry, idx)
605
+ })
606
+ groupOrderMaps.set(groupName, orderMap)
607
+ }
608
+ }
609
+ if (groupOrderMaps.size > 0) {
610
+ canvases.sort((a, b) => {
611
+ if (a.group !== b.group) return 0
612
+ const orderMap = a.group ? groupOrderMaps.get(a.group) : null
613
+ if (!orderMap) return 0
614
+ const ai = orderMap.has(a.name) ? orderMap.get(a.name) : Infinity
615
+ const bi = orderMap.has(b.name) ? orderMap.get(b.name) : Infinity
616
+ return ai - bi
617
+ })
618
+ }
619
+
620
+ sendJson(res, 200, { canvases, groups })
621
+ return
622
+ }
623
+
624
+ // PUT /update — append update events to the canvas stream
625
+ if (routePath === '/update' && method === 'PUT') {
626
+ const { name, widgets, sources, settings, connectors } = body
627
+
628
+ if (!name) {
629
+ sendJson(res, 400, { error: 'Canvas name is required' })
630
+ return
631
+ }
632
+
633
+ const filePath = findCanvasPath(root, name)
634
+ if (!filePath) {
635
+ sendJson(res, 404, { error: `Canvas "${name}" not found` })
636
+ return
637
+ }
638
+
639
+ try {
640
+ const ts = new Date().toISOString()
641
+
642
+ if (widgets) {
643
+ // Guard against accidental canvas wipes: if the incoming widget count
644
+ // is much smaller than the current canvas, reject unless explicitly confirmed.
645
+ // This protects against agents/scripts that accidentally send a partial widget
646
+ // array to the widgets_replaced endpoint (which replaces ALL widgets).
647
+ const current = readCanvas(filePath)
648
+ const currentCount = (current.widgets || []).length
649
+ if (currentCount > 1 && widgets.length < currentCount * 0.5 && body.replaceAll !== true) {
650
+ sendJson(res, 400, {
651
+ error: `Refusing to replace ${currentCount} widgets with ${widgets.length}. `
652
+ + `This would delete ${currentCount - widgets.length} widgets. `
653
+ + `Use PATCH /_storyboard/canvas/widget to update individual widgets, `
654
+ + `or pass "replaceAll": true to confirm full replacement.`,
655
+ })
656
+ return
657
+ }
658
+ const stamped = stampBoundsAll(widgets)
659
+ appendEvent(filePath, { event: 'widgets_replaced', timestamp: ts, widgets: stamped })
660
+ }
661
+
662
+ if (sources) {
663
+ appendEvent(filePath, { event: 'source_updated', timestamp: ts, sources })
664
+ }
665
+
666
+ if (connectors) {
667
+ appendEvent(filePath, { event: 'connectors_replaced', timestamp: ts, connectors })
668
+ }
669
+
670
+ if (settings) {
671
+ const filtered = {}
672
+ for (const [key, value] of Object.entries(settings)) {
673
+ if (['title', 'description', 'grid', 'gridSize', 'colorMode', 'dotted', 'centered', 'author', 'snapToGrid'].includes(key)) {
674
+ filtered[key] = value
675
+ }
676
+ }
677
+ if (Object.keys(filtered).length > 0) {
678
+ appendEvent(filePath, { event: 'settings_updated', timestamp: ts, settings: filtered })
679
+ }
680
+ }
681
+
682
+ sendJson(res, 200, { success: true, name })
683
+ pushCanvasUpdate(name, filePath, __viteWs)
684
+ } catch (err) {
685
+ sendJson(res, 500, { error: `Failed to update canvas: ${err.message}` })
686
+ }
687
+ return
688
+ }
689
+
690
+ // POST /widget — append a widget_added event
691
+ if (routePath === '/widget' && method === 'POST') {
692
+ const { name, type, props = {}, pool, near, direction, resolve, source } = body
693
+ let position = body.position || { x: 0, y: 0 }
694
+
695
+ // Detect whether the caller provided an explicit position.
696
+ // `near === false` is the explicit opt-out ("put it exactly here").
697
+ const hasExplicitPosition = body.position && (body.position.x !== 0 || body.position.y !== 0)
698
+ const hasNearOptOut = near === false
699
+ const needsAutoPosition = !near && !hasExplicitPosition && !hasNearOptOut
700
+
701
+ if (!name) {
702
+ sendJson(res, 400, { error: 'Canvas name is required' })
703
+ return
704
+ }
705
+ if (!type) {
706
+ sendJson(res, 400, { error: 'Widget type is required' })
707
+ return
708
+ }
709
+
710
+ const filePath = findCanvasPath(root, name)
711
+ if (!filePath) {
712
+ sendJson(res, 404, { error: `Canvas "${name}" not found` })
713
+ return
714
+ }
715
+
716
+ try {
717
+ // Always read canvas when we need near, resolve, or auto-positioning
718
+ const needsCanvasRead = near || resolve || needsAutoPosition
719
+ let canvasWidgets = null
720
+ let canvasData = null
721
+ if (needsCanvasRead) {
722
+ canvasData = readCanvas(filePath)
723
+ canvasWidgets = canvasData.widgets || []
724
+ }
725
+
726
+ if (near) {
727
+ const refWidget = canvasWidgets.find((w) => w.id === near)
728
+ if (!refWidget) {
729
+ sendJson(res, 400, { error: `Widget "${near}" not found (--near)` })
730
+ return
731
+ }
732
+ position = computeNearPosition(refWidget, direction || 'right', type, props)
733
+ }
734
+
735
+ // Auto-position: no --near, no explicit x,y → smart default
736
+ if (needsAutoPosition && !near) {
737
+ position = await computeAutoPosition(canvasWidgets, type, props, root, name, source || null)
738
+ }
739
+
740
+ if (near || resolve || needsAutoPosition) {
741
+ const resolved = resolvePosition({
742
+ x: position.x, y: position.y, type, props,
743
+ widgets: canvasWidgets,
744
+ gridSize: (canvasData && canvasData.gridSize) || 24,
745
+ })
746
+ position = { x: resolved.x, y: resolved.y }
747
+ }
748
+
749
+ const widgetId = generateWidgetId(type)
750
+
751
+ await prepareTerminalWidget({ type, props, widgetId, canvasName: name, req })
752
+
753
+ // Hot pool acquisition for terminal/agent widgets
754
+ let hotSession = null
755
+ if ((type === 'terminal' || type === 'agent') && pool !== 'cold') {
756
+ const poolId = resolvePoolId(type, props)
757
+ hotSession = acquireFromPool(hotPool, poolId, pool)
758
+ if (!hotSession && pool === 'hot') {
759
+ sendJson(res, 409, { error: `No warm sessions available in pool "${poolId}"` })
760
+ return
761
+ }
762
+ }
763
+
764
+ const widget = stampBounds({ id: widgetId, type, position, props })
765
+
766
+ appendEvent(filePath, {
767
+ event: 'widget_added',
768
+ timestamp: new Date().toISOString(),
769
+ widget,
770
+ })
771
+
772
+ const response = { success: true, widget }
773
+ if (hotSession) response.hotSession = { id: hotSession.id, tmuxName: hotSession.tmuxName || null, webglReady: !!hotSession.webglReady }
774
+ sendJson(res, 201, response)
775
+ pushCanvasUpdate(name, filePath, __viteWs)
776
+ } catch (err) {
777
+ sendJson(res, 500, { error: `Failed to add widget: ${err.message}` })
778
+ }
779
+ return
780
+ }
781
+
782
+ // DELETE /widget — append a widget_removed event
783
+ if (routePath === '/widget' && method === 'DELETE') {
784
+ const { name, widgetId } = body
785
+
786
+ if (!name || !widgetId) {
787
+ sendJson(res, 400, { error: 'Canvas name and widgetId are required' })
788
+ return
789
+ }
790
+
791
+ const filePath = findCanvasPath(root, name)
792
+ if (!filePath) {
793
+ sendJson(res, 404, { error: `Canvas "${name}" not found` })
794
+ return
795
+ }
796
+
797
+ try {
798
+ // Verify the widget exists before appending the removal event
799
+ const data = readCanvas(filePath)
800
+ const widget = (data.widgets || []).find((w) => w.id === widgetId)
801
+ if (!widget) {
802
+ sendJson(res, 404, { error: `Widget "${widgetId}" not found in canvas "${name}"` })
803
+ return
804
+ }
805
+
806
+ appendEvent(filePath, {
807
+ event: 'widget_removed',
808
+ timestamp: new Date().toISOString(),
809
+ widgetId,
810
+ })
811
+
812
+ // Orphan terminal session when a terminal widget is deleted (not killed)
813
+ if (widget.type === 'terminal' || widget.type === 'agent') {
814
+ try {
815
+ const { orphanTerminalSession } = await import('./terminal-server.js')
816
+ orphanTerminalSession(widgetId)
817
+ } catch (err) {
818
+ devLog().logEvent('warn', `Failed to orphan terminal session for ${widgetId}`, { widgetId, error: err.message })
819
+ }
820
+ }
821
+
822
+ sendJson(res, 200, { success: true, removed: 1 })
823
+ pushCanvasUpdate(name, filePath, __viteWs)
824
+ } catch (err) {
825
+ sendJson(res, 500, { error: `Failed to remove widget: ${err.message}` })
826
+ }
827
+ return
828
+ }
829
+
830
+ // PATCH /widget — update a single widget's props
831
+ if (routePath === '/widget' && method === 'PATCH') {
832
+ const { name, widgetId, props, position } = body
833
+
834
+ if (!name || !widgetId) {
835
+ sendJson(res, 400, { error: 'Canvas name and widgetId are required' })
836
+ return
837
+ }
838
+ if (!props && !position) {
839
+ sendJson(res, 400, { error: 'At least one of props or position is required' })
840
+ return
841
+ }
842
+
843
+ const filePath = findCanvasPath(root, name)
844
+ if (!filePath) {
845
+ sendJson(res, 404, { error: `Canvas "${name}" not found` })
846
+ return
847
+ }
848
+
849
+ try {
850
+ const data = readCanvas(filePath)
851
+ const widget = (data.widgets || []).find((w) => w.id === widgetId)
852
+ if (!widget) {
853
+ sendJson(res, 404, { error: `Widget "${widgetId}" not found in canvas "${name}"` })
854
+ return
855
+ }
856
+
857
+ const ts = new Date().toISOString()
858
+
859
+ if (props) {
860
+ appendEvent(filePath, {
861
+ event: 'widget_updated',
862
+ timestamp: ts,
863
+ widgetId,
864
+ props,
865
+ })
866
+ }
867
+
868
+ if (position) {
869
+ // Merge with existing position so partial updates (only --x or --y) are safe
870
+ const mergedPosition = { ...widget.position, ...position }
871
+ appendEvent(filePath, {
872
+ event: 'widget_moved',
873
+ timestamp: ts,
874
+ widgetId,
875
+ position: mergedPosition,
876
+ })
877
+ }
878
+
879
+ // Return the merged widget for convenience
880
+ const merged = {
881
+ ...widget,
882
+ props: { ...widget.props, ...(props || {}) },
883
+ position: position ? { ...widget.position, ...position } : widget.position,
884
+ }
885
+ sendJson(res, 200, { success: true, widget: merged })
886
+ pushCanvasUpdate(name, filePath, __viteWs)
887
+ } catch (err) {
888
+ sendJson(res, 500, { error: `Failed to update widget: ${err.message}` })
889
+ }
890
+ return
891
+ }
892
+
893
+ // POST /connector — append a connector_added event
894
+ if (routePath === '/connector' && method === 'POST') {
895
+ const { name, startWidgetId, startAnchor, endWidgetId, endAnchor, connectorType = 'default' } = body
896
+
897
+ if (!name) {
898
+ sendJson(res, 400, { error: 'Canvas name is required' })
899
+ return
900
+ }
901
+ if (!startWidgetId || !endWidgetId) {
902
+ sendJson(res, 400, { error: 'startWidgetId and endWidgetId are required' })
903
+ return
904
+ }
905
+ const validAnchors = ['top', 'bottom', 'left', 'right']
906
+ if (!validAnchors.includes(startAnchor) || !validAnchors.includes(endAnchor)) {
907
+ sendJson(res, 400, { error: `Anchors must be one of: ${validAnchors.join(', ')}` })
908
+ return
909
+ }
910
+ if (startWidgetId === endWidgetId) {
911
+ sendJson(res, 400, { error: 'Cannot connect a widget to itself' })
912
+ return
913
+ }
914
+
915
+ const filePath = findCanvasPath(root, name)
916
+ if (!filePath) {
917
+ sendJson(res, 404, { error: `Canvas "${name}" not found` })
918
+ return
919
+ }
920
+
921
+ try {
922
+ const data = readCanvas(filePath)
923
+ const widgetIds = new Set((data.widgets || []).map((w) => w.id))
924
+ if (!widgetIds.has(startWidgetId)) {
925
+ sendJson(res, 404, { error: `Widget "${startWidgetId}" not found` })
926
+ return
927
+ }
928
+ if (!widgetIds.has(endWidgetId)) {
929
+ sendJson(res, 404, { error: `Widget "${endWidgetId}" not found` })
930
+ return
931
+ }
932
+
933
+ const connectorId = generateWidgetId('connector')
934
+ const connector = {
935
+ id: connectorId,
936
+ type: 'connector',
937
+ connectorType,
938
+ start: { widgetId: startWidgetId, anchor: startAnchor },
939
+ end: { widgetId: endWidgetId, anchor: endAnchor },
940
+ meta: {},
941
+ }
942
+
943
+ appendEvent(filePath, {
944
+ event: 'connector_added',
945
+ timestamp: new Date().toISOString(),
946
+ connector,
947
+ })
948
+
949
+ sendJson(res, 201, { success: true, connector })
950
+ pushCanvasUpdate(name, filePath, __viteWs)
951
+ } catch (err) {
952
+ sendJson(res, 500, { error: `Failed to add connector: ${err.message}` })
953
+ }
954
+ return
955
+ }
956
+
957
+ // PATCH /connector — update connector anchors and/or meta
958
+ if (routePath === '/connector' && method === 'PATCH') {
959
+ const { name, connectorId, meta, startAnchor, endAnchor } = body
960
+
961
+ if (!name || !connectorId) {
962
+ sendJson(res, 400, { error: 'Canvas name and connectorId are required' })
963
+ return
964
+ }
965
+
966
+ const filePath = findCanvasPath(root, name)
967
+ if (!filePath) {
968
+ sendJson(res, 404, { error: `Canvas "${name}" not found` })
969
+ return
970
+ }
971
+
972
+ try {
973
+ const data = readCanvas(filePath)
974
+ const connector = (data.connectors || []).find((c) => c.id === connectorId)
975
+ if (!connector) {
976
+ sendJson(res, 404, { error: `Connector "${connectorId}" not found in canvas "${name}"` })
977
+ return
978
+ }
979
+
980
+ const validAnchors = ['top', 'right', 'bottom', 'left']
981
+ if (startAnchor && !validAnchors.includes(startAnchor)) {
982
+ sendJson(res, 400, { error: `Invalid startAnchor "${startAnchor}". Must be one of: ${validAnchors.join(', ')}` })
983
+ return
984
+ }
985
+ if (endAnchor && !validAnchors.includes(endAnchor)) {
986
+ sendJson(res, 400, { error: `Invalid endAnchor "${endAnchor}". Must be one of: ${validAnchors.join(', ')}` })
987
+ return
988
+ }
989
+
990
+ const updates = {}
991
+ if (meta) updates.meta = { ...meta }
992
+ if (startAnchor) updates.startAnchor = startAnchor
993
+ if (endAnchor) updates.endAnchor = endAnchor
994
+
995
+ appendEvent(filePath, {
996
+ event: 'connector_updated',
997
+ timestamp: new Date().toISOString(),
998
+ connectorId,
999
+ updates,
1000
+ })
1001
+
1002
+ sendJson(res, 200, { success: true })
1003
+ pushCanvasUpdate(name, filePath, __viteWs)
1004
+
1005
+ // Inject messaging skill into both terminals when mode changes
1006
+ if (meta?.messagingMode || meta?.messaging) {
1007
+ const widgets = data.widgets || []
1008
+ const startWidget = widgets.find((w) => w.id === connector.start?.widgetId)
1009
+ const endWidget = widgets.find((w) => w.id === connector.end?.widgetId)
1010
+ const isTerminalType = (w) => w && (w.type === 'terminal' || w.type === 'agent')
1011
+
1012
+ if (isTerminalType(startWidget) && isTerminalType(endWidget)) {
1013
+ try {
1014
+ const { execSync } = await import('node:child_process')
1015
+ const { findTmuxNameForWidget } = await import('./terminal-registry.js')
1016
+
1017
+ // Resolve effective mode per widget
1018
+ const getMode = (w) => {
1019
+ if (meta.messagingMode === 'two-way') return 'two-way'
1020
+ if (meta.messaging?.[w.id]) return meta.messaging[w.id]
1021
+ return 'none'
1022
+ }
1023
+
1024
+ const pairs = [
1025
+ { widget: startWidget, peer: endWidget },
1026
+ { widget: endWidget, peer: startWidget },
1027
+ ]
1028
+
1029
+ for (const { widget: w, peer } of pairs) {
1030
+ const tmuxName = findTmuxNameForWidget(w.id)
1031
+ if (!tmuxName) continue
1032
+
1033
+ const peerName = peer.props?.prettyName || peer.id
1034
+ const mode = getMode(w)
1035
+ let skillMsg
1036
+
1037
+ if (mode === 'two-way') {
1038
+ skillMsg = `📡 [Two-way messaging ACTIVE with ${peerName}]\n\nYou are in a live conversation with ${peerName} (${peer.id}). Messages appear as: 📩 ${peerName}: <their message>\n\nWhen you receive a message:\n1. Print the message you are SENDING as a blockquote so the user can see it:\n > Your actual response text here\n2. Then save and send in one step:\n npx storyboard terminal output --summary "summary" --content "your response" && npx storyboard terminal send ${peer.id} "your response"\n\nIMPORTANT: Always show your actual response text as a > blockquote BEFORE the shell command. The user needs to see what you said, not just "I fired back with a joke."\n\nDo NOT ask for permission. Respond immediately. Keep it concise.`
1039
+ } else if (mode === 'one-way') {
1040
+ skillMsg = `📡 [One-way messaging → to ${peerName}]\nYou can send messages to ${peerName} (${peer.id}).\n\nAfter EVERY response, save your output:\n npx storyboard terminal output --summary "summary" --content "your full response"\n\nTo send a message:\n npx storyboard terminal send ${peer.id} "message"`
1041
+ } else {
1042
+ skillMsg = `📡 [Messaging with ${peerName} disabled]`
1043
+ }
1044
+
1045
+ try {
1046
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(skillMsg)}`, { stdio: 'ignore' })
1047
+ execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
1048
+ } catch { /* tmux session may not be active */ }
1049
+ }
1050
+ } catch { /* best effort */ }
1051
+ }
1052
+ }
1053
+ } catch (err) {
1054
+ sendJson(res, 500, { error: `Failed to update connector: ${err.message}` })
1055
+ }
1056
+ return
1057
+ }
1058
+
1059
+ // DELETE /connector — append a connector_removed event
1060
+ if (routePath === '/connector' && method === 'DELETE') {
1061
+ const { name, connectorId } = body
1062
+
1063
+ if (!name || !connectorId) {
1064
+ sendJson(res, 400, { error: 'Canvas name and connectorId are required' })
1065
+ return
1066
+ }
1067
+
1068
+ const filePath = findCanvasPath(root, name)
1069
+ if (!filePath) {
1070
+ sendJson(res, 404, { error: `Canvas "${name}" not found` })
1071
+ return
1072
+ }
1073
+
1074
+ try {
1075
+ const data = readCanvas(filePath)
1076
+ const exists = (data.connectors || []).some((c) => c.id === connectorId)
1077
+ if (!exists) {
1078
+ sendJson(res, 404, { error: `Connector "${connectorId}" not found in canvas "${name}"` })
1079
+ return
1080
+ }
1081
+
1082
+ appendEvent(filePath, {
1083
+ event: 'connector_removed',
1084
+ timestamp: new Date().toISOString(),
1085
+ connectorId,
1086
+ })
1087
+
1088
+ sendJson(res, 200, { success: true, removed: 1 })
1089
+ pushCanvasUpdate(name, filePath, __viteWs)
1090
+ } catch (err) {
1091
+ sendJson(res, 500, { error: `Failed to remove connector: ${err.message}` })
1092
+ }
1093
+ return
1094
+ }
1095
+
1096
+ // POST /broadcast — toggle broadcast messaging for a widget and its connections.
1097
+ // Default: direct neighbors only. passThrough: true → BFS full connected component.
1098
+ if (routePath === '/broadcast' && method === 'POST') {
1099
+ const { name, widgetId, mode = 'two-way', passThrough = false } = body
1100
+
1101
+ if (!name || !widgetId) {
1102
+ sendJson(res, 400, { error: 'Canvas name and widgetId are required' })
1103
+ return
1104
+ }
1105
+ if (mode !== 'two-way' && mode !== 'one-way' && mode !== 'none') {
1106
+ sendJson(res, 400, { error: 'mode must be "two-way", "one-way", or "none"' })
1107
+ return
1108
+ }
1109
+
1110
+ const filePath = findCanvasPath(root, name)
1111
+ if (!filePath) {
1112
+ sendJson(res, 404, { error: `Canvas "${name}" not found` })
1113
+ return
1114
+ }
1115
+
1116
+ try {
1117
+ const data = readCanvas(filePath)
1118
+ const widgets = data.widgets || []
1119
+ const connectors = data.connectors || []
1120
+ const widgetMap = new Map(widgets.map((w) => [w.id, w]))
1121
+
1122
+ const sourceWidget = widgetMap.get(widgetId)
1123
+ if (!sourceWidget) {
1124
+ sendJson(res, 404, { error: `Widget "${widgetId}" not found` })
1125
+ return
1126
+ }
1127
+
1128
+ const isTerminalType = (w) => w && (w.type === 'terminal' || w.type === 'agent')
1129
+
1130
+ // Find connectors to update via BFS (or direct neighbors only)
1131
+ const affectedConnectorIds = new Set()
1132
+ const affectedWidgetIds = new Set([widgetId])
1133
+
1134
+ if (passThrough) {
1135
+ // BFS: traverse entire connected component of terminal/agent widgets
1136
+ const visited = new Set([widgetId])
1137
+ const queue = [widgetId]
1138
+ while (queue.length > 0) {
1139
+ const current = queue.shift()
1140
+ for (const conn of connectors) {
1141
+ let peerId = null
1142
+ if (conn.start?.widgetId === current && conn.end?.widgetId) peerId = conn.end.widgetId
1143
+ if (conn.end?.widgetId === current && conn.start?.widgetId) peerId = conn.start.widgetId
1144
+ if (!peerId || visited.has(peerId)) continue
1145
+ const peer = widgetMap.get(peerId)
1146
+ if (!isTerminalType(peer)) continue
1147
+ affectedConnectorIds.add(conn.id)
1148
+ affectedWidgetIds.add(peerId)
1149
+ visited.add(peerId)
1150
+ queue.push(peerId)
1151
+ }
1152
+ }
1153
+ } else {
1154
+ // Direct neighbors only
1155
+ for (const conn of connectors) {
1156
+ let peerId = null
1157
+ if (conn.start?.widgetId === widgetId && conn.end?.widgetId) peerId = conn.end.widgetId
1158
+ if (conn.end?.widgetId === widgetId && conn.start?.widgetId) peerId = conn.start.widgetId
1159
+ if (!peerId) continue
1160
+ const peer = widgetMap.get(peerId)
1161
+ if (!isTerminalType(peer)) continue
1162
+ affectedConnectorIds.add(conn.id)
1163
+ affectedWidgetIds.add(peerId)
1164
+ }
1165
+ }
1166
+
1167
+ // Update all affected connectors
1168
+ const ts = new Date().toISOString()
1169
+ const messagingMode = mode === 'none' ? null : mode
1170
+ for (const connId of affectedConnectorIds) {
1171
+ appendEvent(filePath, {
1172
+ event: 'connector_updated',
1173
+ timestamp: ts,
1174
+ connectorId: connId,
1175
+ updates: { meta: { messagingMode } },
1176
+ })
1177
+ }
1178
+
1179
+ sendJson(res, 200, {
1180
+ success: true,
1181
+ affectedConnectors: [...affectedConnectorIds],
1182
+ affectedWidgets: [...affectedWidgetIds],
1183
+ })
1184
+ pushCanvasUpdate(name, filePath, __viteWs)
1185
+
1186
+ // Inject messaging skill into affected terminals
1187
+ if (affectedConnectorIds.size > 0) {
1188
+ try {
1189
+ const { execSync } = await import('node:child_process')
1190
+ const { findTmuxNameForWidget } = await import('./terminal-registry.js')
1191
+
1192
+ for (const wId of affectedWidgetIds) {
1193
+ const w = widgetMap.get(wId)
1194
+ if (!isTerminalType(w)) continue
1195
+ const tmuxName = findTmuxNameForWidget(wId)
1196
+ if (!tmuxName) continue
1197
+
1198
+ // Build peer list for this widget
1199
+ const peers = []
1200
+ for (const conn of connectors) {
1201
+ let peerId = null
1202
+ if (conn.start?.widgetId === wId) peerId = conn.end?.widgetId
1203
+ if (conn.end?.widgetId === wId) peerId = conn.start?.widgetId
1204
+ if (peerId && affectedWidgetIds.has(peerId) && peerId !== wId) {
1205
+ const peer = widgetMap.get(peerId)
1206
+ if (peer) peers.push(peer)
1207
+ }
1208
+ }
1209
+
1210
+ if (mode === 'none') {
1211
+ const msg = '📡 [Broadcast disabled]'
1212
+ try {
1213
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(msg)}`, { stdio: 'ignore' })
1214
+ execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
1215
+ } catch { /* session may not be active */ }
1216
+ } else {
1217
+ const peerNames = peers.map((p) => p.props?.prettyName || p.id).join(', ')
1218
+ const msg = `📡 [Broadcast ${mode} ACTIVE with ${peerNames}]`
1219
+ try {
1220
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(msg)}`, { stdio: 'ignore' })
1221
+ execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
1222
+ } catch { /* session may not be active */ }
1223
+ }
1224
+ }
1225
+ } catch { /* best effort */ }
1226
+ }
1227
+ } catch (err) {
1228
+ sendJson(res, 500, { error: `Failed to update broadcast: ${err.message}` })
1229
+ }
1230
+ return
1231
+ }
1232
+
1233
+ // POST /batch — execute multiple canvas operations in a single request.
1234
+ // Reads the canvas once, appends all events, pushes ONE HMR update at the end.
1235
+ // Operations reference earlier results via $index (auto) or $refName (opt-in).
1236
+ if (routePath === '/batch' && method === 'POST') {
1237
+ const { name, operations } = body
1238
+
1239
+ if (!name) {
1240
+ sendJson(res, 400, { error: 'Canvas name is required' })
1241
+ return
1242
+ }
1243
+ if (!Array.isArray(operations) || operations.length === 0) {
1244
+ sendJson(res, 400, { error: 'operations must be a non-empty array' })
1245
+ return
1246
+ }
1247
+ if (operations.length > 200) {
1248
+ sendJson(res, 400, { error: 'Maximum 200 operations per batch' })
1249
+ return
1250
+ }
1251
+
1252
+ const filePath = findCanvasPath(root, name)
1253
+ if (!filePath) {
1254
+ sendJson(res, 404, { error: `Canvas "${name}" not found` })
1255
+ return
1256
+ }
1257
+
1258
+ try {
1259
+ const canvasData = readCanvas(filePath)
1260
+ const widgetIds = new Set((canvasData.widgets || []).map((w) => w.id))
1261
+ const connectorIds = new Set((canvasData.connectors || []).map((c) => c.id))
1262
+ const widgetMap = new Map((canvasData.widgets || []).map((w) => [w.id, { ...w }]))
1263
+ const connectorMap = new Map((canvasData.connectors || []).map((c) => [c.id, { ...c }]))
1264
+
1265
+ const refs = {}
1266
+ const results = []
1267
+ const validAnchors = ['top', 'bottom', 'left', 'right']
1268
+
1269
+ // Resolve $ref strings — "$0", "$myName", etc.
1270
+ function resolveRef(val) {
1271
+ if (typeof val !== 'string' || !val.startsWith('$')) return val
1272
+ const refName = val.slice(1)
1273
+ if (refs[refName] !== undefined) return refs[refName]
1274
+ throw new Error(`Unknown ref "${val}"`)
1275
+ }
1276
+
1277
+ for (let i = 0; i < operations.length; i++) {
1278
+ const op = operations[i]
1279
+ const ts = new Date().toISOString()
1280
+
1281
+ try {
1282
+ switch (op.op) {
1283
+ case 'create-widget': {
1284
+ const { type, props = {}, ref, pool, near, direction, resolve: doResolve, source: opSource } = op
1285
+ let position = op.position || { x: 0, y: 0 }
1286
+ if (!type) throw new Error('type is required')
1287
+
1288
+ // Detect whether an explicit position was provided
1289
+ const hasExplicitPos = op.position && (op.position.x !== 0 || op.position.y !== 0)
1290
+ const hasNearOptOut = near === false
1291
+ const needsAuto = !near && !hasExplicitPos && !hasNearOptOut
1292
+
1293
+ // --near: compute position relative to a reference widget
1294
+ if (near) {
1295
+ const nearId = resolveRef(near)
1296
+ const refWidget = widgetMap.get(nearId)
1297
+ if (!refWidget) throw new Error(`Widget "${nearId}" not found (near)`)
1298
+ position = computeNearPosition(refWidget, direction || 'right', type, props)
1299
+ }
1300
+
1301
+ // Auto-position: no --near, no explicit x,y → smart default
1302
+ if (needsAuto && !near) {
1303
+ const currentWidgets = Array.from(widgetMap.values())
1304
+ position = await computeAutoPosition(currentWidgets, type, props, root, name, opSource || null)
1305
+ }
1306
+
1307
+ // Collision resolution: uses live widgetMap (includes earlier batch creates)
1308
+ if (near || doResolve || needsAuto) {
1309
+ const resolved = resolvePosition({
1310
+ x: position.x, y: position.y, type, props,
1311
+ widgets: Array.from(widgetMap.values()),
1312
+ gridSize: canvasData.gridSize || 24,
1313
+ })
1314
+ position = { x: resolved.x, y: resolved.y }
1315
+ }
1316
+
1317
+ const widgetId = generateWidgetId(type)
1318
+ await prepareTerminalWidget({ type, props, widgetId, canvasName: name, req })
1319
+
1320
+ let hotSession = null
1321
+ if ((type === 'terminal' || type === 'agent') && pool !== 'cold') {
1322
+ const poolId = resolvePoolId(type, props)
1323
+ hotSession = acquireFromPool(hotPool, poolId, pool)
1324
+ if (!hotSession && pool === 'hot') throw new Error(`No warm sessions available in pool "${poolId}"`)
1325
+ }
1326
+
1327
+ const widget = stampBounds({ id: widgetId, type, position, props })
1328
+
1329
+ appendEvent(filePath, { event: 'widget_added', timestamp: ts, widget })
1330
+
1331
+ widgetIds.add(widgetId)
1332
+ widgetMap.set(widgetId, widget)
1333
+ refs[String(i)] = widgetId
1334
+ if (ref) refs[ref] = widgetId
1335
+
1336
+ const result = { index: i, op: 'create-widget', ref: ref || undefined, widgetId, widget }
1337
+ if (hotSession) result.hotSession = { id: hotSession.id, tmuxName: hotSession.tmuxName || null, webglReady: !!hotSession.webglReady }
1338
+ results.push(result)
1339
+ break
1340
+ }
1341
+
1342
+ case 'update-widget': {
1343
+ const widgetId = resolveRef(op.widgetId)
1344
+ const { props } = op
1345
+ if (!widgetId) throw new Error('widgetId is required')
1346
+ if (!props) throw new Error('props is required')
1347
+ if (!widgetIds.has(widgetId)) throw new Error(`Widget "${widgetId}" not found`)
1348
+
1349
+ appendEvent(filePath, { event: 'widget_updated', timestamp: ts, widgetId, props })
1350
+
1351
+ const existing = widgetMap.get(widgetId)
1352
+ if (existing) existing.props = { ...existing.props, ...props }
1353
+
1354
+ results.push({ index: i, op: 'update-widget', widgetId, success: true })
1355
+ break
1356
+ }
1357
+
1358
+ case 'move-widget': {
1359
+ const widgetId = resolveRef(op.widgetId)
1360
+ const { position } = op
1361
+ if (!widgetId) throw new Error('widgetId is required')
1362
+ if (!position) throw new Error('position is required')
1363
+ if (!widgetIds.has(widgetId)) throw new Error(`Widget "${widgetId}" not found`)
1364
+
1365
+ const existing = widgetMap.get(widgetId)
1366
+ const mergedPosition = { ...(existing?.position || {}), ...position }
1367
+
1368
+ appendEvent(filePath, { event: 'widget_moved', timestamp: ts, widgetId, position: mergedPosition })
1369
+
1370
+ if (existing) existing.position = mergedPosition
1371
+
1372
+ results.push({ index: i, op: 'move-widget', widgetId, success: true })
1373
+ break
1374
+ }
1375
+
1376
+ case 'delete-widget': {
1377
+ const widgetId = resolveRef(op.widgetId)
1378
+ if (!widgetId) throw new Error('widgetId is required')
1379
+ if (!widgetIds.has(widgetId)) throw new Error(`Widget "${widgetId}" not found`)
1380
+
1381
+ appendEvent(filePath, { event: 'widget_removed', timestamp: ts, widgetId })
1382
+
1383
+ widgetIds.delete(widgetId)
1384
+ widgetMap.delete(widgetId)
1385
+
1386
+ results.push({ index: i, op: 'delete-widget', widgetId, success: true })
1387
+ break
1388
+ }
1389
+
1390
+ case 'create-connector': {
1391
+ const startWidgetId = resolveRef(op.startWidgetId)
1392
+ const endWidgetId = resolveRef(op.endWidgetId)
1393
+ const { startAnchor = 'right', endAnchor = 'left', connectorType = 'default', ref } = op
1394
+
1395
+ if (!startWidgetId || !endWidgetId) throw new Error('startWidgetId and endWidgetId are required')
1396
+ if (!validAnchors.includes(startAnchor) || !validAnchors.includes(endAnchor)) {
1397
+ throw new Error(`Anchors must be one of: ${validAnchors.join(', ')}`)
1398
+ }
1399
+ if (startWidgetId === endWidgetId) throw new Error('Cannot connect a widget to itself')
1400
+ if (!widgetIds.has(startWidgetId)) throw new Error(`Widget "${startWidgetId}" not found`)
1401
+ if (!widgetIds.has(endWidgetId)) throw new Error(`Widget "${endWidgetId}" not found`)
1402
+
1403
+ const connectorId = generateWidgetId('connector')
1404
+ const connector = {
1405
+ id: connectorId,
1406
+ type: 'connector',
1407
+ connectorType,
1408
+ start: { widgetId: startWidgetId, anchor: startAnchor },
1409
+ end: { widgetId: endWidgetId, anchor: endAnchor },
1410
+ meta: {},
1411
+ }
1412
+
1413
+ appendEvent(filePath, { event: 'connector_added', timestamp: ts, connector })
1414
+
1415
+ connectorIds.add(connectorId)
1416
+ connectorMap.set(connectorId, connector)
1417
+ refs[String(i)] = connectorId
1418
+ if (ref) refs[ref] = connectorId
1419
+
1420
+ results.push({ index: i, op: 'create-connector', ref: ref || undefined, connectorId, success: true })
1421
+ break
1422
+ }
1423
+
1424
+ case 'delete-connector': {
1425
+ const connectorId = resolveRef(op.connectorId)
1426
+ if (!connectorId) throw new Error('connectorId is required')
1427
+ if (!connectorIds.has(connectorId)) throw new Error(`Connector "${connectorId}" not found`)
1428
+
1429
+ appendEvent(filePath, { event: 'connector_removed', timestamp: ts, connectorId })
1430
+ connectorIds.delete(connectorId)
1431
+ connectorMap.delete(connectorId)
1432
+
1433
+ results.push({ index: i, op: 'delete-connector', connectorId, success: true })
1434
+ break
1435
+ }
1436
+
1437
+ case 'update-connector': {
1438
+ const connectorId = resolveRef(op.connectorId)
1439
+ const { meta } = op
1440
+ if (!connectorId) throw new Error('connectorId is required')
1441
+ if (!meta) throw new Error('meta is required')
1442
+ if (!connectorIds.has(connectorId)) throw new Error(`Connector "${connectorId}" not found`)
1443
+
1444
+ appendEvent(filePath, { event: 'connector_updated', timestamp: ts, connectorId, updates: { meta } })
1445
+
1446
+ const existing = connectorMap.get(connectorId)
1447
+ if (existing) existing.meta = { ...(existing.meta || {}), ...meta }
1448
+
1449
+ results.push({ index: i, op: 'update-connector', connectorId, success: true })
1450
+ break
1451
+ }
1452
+
1453
+ case 'broadcast': {
1454
+ const wId = resolveRef(op.widgetId)
1455
+ const mode = op.mode || 'two-way'
1456
+ const passThrough = !!op.passThrough
1457
+ if (!wId) throw new Error('widgetId is required')
1458
+ if (!widgetIds.has(wId)) throw new Error(`Widget "${wId}" not found`)
1459
+
1460
+ const isTerminalType = (w) => w && (w.type === 'terminal' || w.type === 'agent')
1461
+ const allConnectors = [...connectorMap.values()]
1462
+ const affectedConnectorIds = new Set()
1463
+ const affectedWidgetIds = new Set([wId])
1464
+
1465
+ if (passThrough) {
1466
+ const visited = new Set([wId])
1467
+ const queue = [wId]
1468
+ while (queue.length > 0) {
1469
+ const current = queue.shift()
1470
+ for (const conn of allConnectors) {
1471
+ let peerId = null
1472
+ if (conn.start?.widgetId === current && conn.end?.widgetId) peerId = conn.end.widgetId
1473
+ if (conn.end?.widgetId === current && conn.start?.widgetId) peerId = conn.start.widgetId
1474
+ if (!peerId || visited.has(peerId)) continue
1475
+ const peer = widgetMap.get(peerId)
1476
+ if (!isTerminalType(peer)) continue
1477
+ affectedConnectorIds.add(conn.id)
1478
+ affectedWidgetIds.add(peerId)
1479
+ visited.add(peerId)
1480
+ queue.push(peerId)
1481
+ }
1482
+ }
1483
+ } else {
1484
+ for (const conn of allConnectors) {
1485
+ let peerId = null
1486
+ if (conn.start?.widgetId === wId && conn.end?.widgetId) peerId = conn.end.widgetId
1487
+ if (conn.end?.widgetId === wId && conn.start?.widgetId) peerId = conn.start.widgetId
1488
+ if (!peerId) continue
1489
+ const peer = widgetMap.get(peerId)
1490
+ if (!isTerminalType(peer)) continue
1491
+ affectedConnectorIds.add(conn.id)
1492
+ affectedWidgetIds.add(peerId)
1493
+ }
1494
+ }
1495
+
1496
+ const messagingMode = mode === 'none' ? null : mode
1497
+ for (const connId of affectedConnectorIds) {
1498
+ appendEvent(filePath, { event: 'connector_updated', timestamp: ts, connectorId: connId, updates: { meta: { messagingMode } } })
1499
+ const conn = connectorMap.get(connId)
1500
+ if (conn) conn.meta = { ...(conn.meta || {}), messagingMode }
1501
+ }
1502
+
1503
+ results.push({
1504
+ index: i, op: 'broadcast',
1505
+ affectedConnectors: [...affectedConnectorIds],
1506
+ affectedWidgets: [...affectedWidgetIds],
1507
+ success: true,
1508
+ })
1509
+ break
1510
+ }
1511
+
1512
+ default:
1513
+ throw new Error(`Unknown operation "${op.op}"`)
1514
+ }
1515
+ } catch (opErr) {
1516
+ // Fail-fast: push what we have so far, then return the error
1517
+ pushCanvasUpdate(name, filePath, __viteWs)
1518
+ sendJson(res, 400, {
1519
+ success: false,
1520
+ error: `Operation ${i} (${op.op}) failed: ${opErr.message}`,
1521
+ failedAt: i,
1522
+ results,
1523
+ refs,
1524
+ })
1525
+ return
1526
+ }
1527
+ }
1528
+
1529
+ sendJson(res, 200, { success: true, results, refs })
1530
+ pushCanvasUpdate(name, filePath, __viteWs)
1531
+ } catch (err) {
1532
+ sendJson(res, 500, { error: `Batch failed: ${err.message}` })
1533
+ }
1534
+ return
1535
+ }
1536
+
1537
+ // PUT /rename-page — rename a canvas page file
1538
+ if (routePath === '/rename-page' && method === 'PUT') {
1539
+ const { name, newTitle } = body
1540
+
1541
+ if (!name || !newTitle) {
1542
+ sendJson(res, 400, { error: 'Canvas name and newTitle are required' })
1543
+ return
1544
+ }
1545
+
1546
+ const filePath = findCanvasPath(root, name)
1547
+ if (!filePath) {
1548
+ sendJson(res, 404, { error: `Canvas "${name}" not found` })
1549
+ return
1550
+ }
1551
+
1552
+ const kebab = newTitle
1553
+ .replace(/[^a-zA-Z0-9\s_-]/g, '')
1554
+ .trim()
1555
+ .replace(/[\s_]+/g, '-')
1556
+ .toLowerCase()
1557
+ .replace(/-+/g, '-')
1558
+ .replace(/^-|-$/g, '')
1559
+
1560
+ if (!kebab) {
1561
+ sendJson(res, 400, { error: 'newTitle must contain at least one alphanumeric character' })
1562
+ return
1563
+ }
1564
+
1565
+ try {
1566
+ const dir = path.dirname(filePath)
1567
+ const newFilename = `${kebab}.canvas.jsonl`
1568
+ const newPath = path.join(dir, newFilename)
1569
+
1570
+ if (newPath !== filePath && fs.existsSync(newPath)) {
1571
+ sendJson(res, 409, { error: `A canvas file named "${newFilename}" already exists in this directory` })
1572
+ return
1573
+ }
1574
+
1575
+ fs.renameSync(filePath, newPath)
1576
+
1577
+ const newCanonicalId = toCanvasId(path.relative(root, newPath).replace(/\\/g, '/'))
1578
+
1579
+ appendEvent(newPath, {
1580
+ event: 'settings_updated',
1581
+ timestamp: new Date().toISOString(),
1582
+ settings: { title: newTitle },
1583
+ })
1584
+
1585
+ // Update pageOrder in .meta.json if it exists
1586
+ const metaForOrder = readFolderMeta(dir)
1587
+ if (metaForOrder?.pageOrder) {
1588
+ try {
1589
+ const updated = metaForOrder.pageOrder.map((entry) =>
1590
+ typeof entry === 'string' && entry === name ? newCanonicalId : entry
1591
+ )
1592
+ metaForOrder.pageOrder = updated
1593
+ writeFolderMeta(dir, metaForOrder)
1594
+ } catch { /* skip */ }
1595
+ }
1596
+
1597
+ sendJson(res, 200, { success: true, name: newCanonicalId, route: '/canvas/' + newCanonicalId })
1598
+ } catch (err) {
1599
+ sendJson(res, 500, { error: `Failed to rename page: ${err.message}` })
1600
+ }
1601
+ return
1602
+ }
1603
+
1604
+ // PUT /reorder-pages — save page order for a canvas folder
1605
+ if (routePath === '/reorder-pages' && method === 'PUT') {
1606
+ const { folder, order } = body
1607
+
1608
+ if (!folder || !Array.isArray(order)) {
1609
+ sendJson(res, 400, { error: 'folder (string) and order (array) are required' })
1610
+ return
1611
+ }
1612
+
1613
+ const canvasDir = path.join(root, 'src', 'canvas')
1614
+ const folderDir = fs.existsSync(path.join(canvasDir, `${folder}.folder`))
1615
+ ? path.join(canvasDir, `${folder}.folder`)
1616
+ : fs.existsSync(path.join(canvasDir, folder))
1617
+ ? path.join(canvasDir, folder)
1618
+ : null
1619
+
1620
+ if (!folderDir) {
1621
+ sendJson(res, 404, { error: `Folder "${folder}" not found` })
1622
+ return
1623
+ }
1624
+
1625
+ try {
1626
+ const meta = readFolderMeta(folderDir)
1627
+ meta.pageOrder = order
1628
+ writeFolderMeta(folderDir, meta)
1629
+ sendJson(res, 200, { success: true })
1630
+ } catch (err) {
1631
+ sendJson(res, 500, { error: `Failed to save page order: ${err.message}` })
1632
+ }
1633
+ return
1634
+ }
1635
+
1636
+ // GET /page-order?folder=... — read page order for a folder
1637
+ if (routePath.startsWith('/page-order') && method === 'GET') {
1638
+ const pageOrderUrl = new URL(routePath, 'http://localhost')
1639
+ const folder = pageOrderUrl.searchParams.get('folder')
1640
+
1641
+ if (!folder) {
1642
+ sendJson(res, 400, { error: 'folder query parameter is required' })
1643
+ return
1644
+ }
1645
+
1646
+ const canvasDir = path.join(root, 'src', 'canvas')
1647
+ const folderDir = fs.existsSync(path.join(canvasDir, `${folder}.folder`))
1648
+ ? path.join(canvasDir, `${folder}.folder`)
1649
+ : fs.existsSync(path.join(canvasDir, folder))
1650
+ ? path.join(canvasDir, folder)
1651
+ : null
1652
+
1653
+ if (!folderDir) {
1654
+ sendJson(res, 404, { error: `Folder "${folder}" not found` })
1655
+ return
1656
+ }
1657
+
1658
+ try {
1659
+ const meta = readFolderMeta(folderDir)
1660
+ sendJson(res, 200, { order: meta?.pageOrder || null })
1661
+ } catch (err) {
1662
+ sendJson(res, 500, { error: `Failed to read page order: ${err.message}` })
1663
+ }
1664
+ return
1665
+ }
1666
+
1667
+ // PUT /update-folder-meta — update folder .meta.json title
1668
+ if (routePath === '/update-folder-meta' && method === 'PUT') {
1669
+ const { folder, title } = body
1670
+
1671
+ if (!folder || !title) {
1672
+ sendJson(res, 400, { error: 'folder and title are required' })
1673
+ return
1674
+ }
1675
+
1676
+ const kebab = title
1677
+ .replace(/[^a-zA-Z0-9\s_-]/g, '')
1678
+ .trim()
1679
+ .replace(/[\s_]+/g, '-')
1680
+ .toLowerCase()
1681
+ .replace(/-+/g, '-')
1682
+ .replace(/^-|-$/g, '')
1683
+
1684
+ if (!kebab) {
1685
+ sendJson(res, 400, { error: 'title must contain at least one alphanumeric character' })
1686
+ return
1687
+ }
1688
+
1689
+ const canvasDir = path.join(root, 'src', 'canvas')
1690
+ const isFolderSuffix = fs.existsSync(path.join(canvasDir, `${folder}.folder`))
1691
+ const folderDir = isFolderSuffix
1692
+ ? path.join(canvasDir, `${folder}.folder`)
1693
+ : fs.existsSync(path.join(canvasDir, folder))
1694
+ ? path.join(canvasDir, folder)
1695
+ : null
1696
+
1697
+ if (!folderDir) {
1698
+ sendJson(res, 404, { error: `Folder "${folder}" not found` })
1699
+ return
1700
+ }
1701
+
1702
+ try {
1703
+ const meta = readFolderMeta(folderDir)
1704
+ const dirName = path.basename(folderDir).replace(/\.folder$/, '')
1705
+ meta.title = title
1706
+
1707
+ // Rename folder directory if the kebab name differs
1708
+ const needsRename = kebab !== dirName
1709
+ let newDirName = dirName
1710
+
1711
+ if (needsRename) {
1712
+ const suffix = isFolderSuffix ? '.folder' : ''
1713
+ const newFolderDir = path.join(canvasDir, `${kebab}${suffix}`)
1714
+ if (fs.existsSync(newFolderDir)) {
1715
+ sendJson(res, 409, { error: `A folder named "${kebab}" already exists` })
1716
+ return
1717
+ }
1718
+ // Write updated meta, rename file to match new dir name, rename dir
1719
+ writeFolderMeta(folderDir, meta)
1720
+ const metaPath = path.join(folderDir, `${dirName}.meta.json`)
1721
+ const newMetaPath = path.join(folderDir, `${kebab}.meta.json`)
1722
+ if (newMetaPath !== metaPath) {
1723
+ fs.renameSync(metaPath, newMetaPath)
1724
+ }
1725
+ fs.renameSync(folderDir, newFolderDir)
1726
+ newDirName = kebab
1727
+ } else {
1728
+ writeFolderMeta(folderDir, meta)
1729
+ }
1730
+
1731
+ sendJson(res, 200, { success: true, folder: newDirName, renamed: needsRename })
1732
+ } catch (err) {
1733
+ sendJson(res, 500, { error: `Failed to update folder meta: ${err.message}` })
1734
+ }
1735
+ return
1736
+ }
1737
+
1738
+ // POST /duplicate — duplicate an existing canvas page with its widgets
1739
+ if (routePath === '/duplicate' && method === 'POST') {
1740
+ const { name, newTitle } = body
1741
+
1742
+ if (!name || !newTitle) {
1743
+ sendJson(res, 400, { error: 'Canvas name and newTitle are required' })
1744
+ return
1745
+ }
1746
+
1747
+ const filePath = findCanvasPath(root, name)
1748
+ if (!filePath) {
1749
+ sendJson(res, 404, { error: `Canvas "${name}" not found` })
1750
+ return
1751
+ }
1752
+
1753
+ const kebab = newTitle
1754
+ .replace(/[^a-zA-Z0-9\s_-]/g, '')
1755
+ .trim()
1756
+ .replace(/[\s_]+/g, '-')
1757
+ .toLowerCase()
1758
+ .replace(/-+/g, '-')
1759
+ .replace(/^-|-$/g, '')
1760
+
1761
+ if (!kebab) {
1762
+ sendJson(res, 400, { error: 'newTitle must contain at least one alphanumeric character' })
1763
+ return
1764
+ }
1765
+
1766
+ try {
1767
+ const sourceData = readCanvas(filePath)
1768
+ const dir = path.dirname(filePath)
1769
+ const newFilename = `${kebab}.canvas.jsonl`
1770
+ const newPath = path.join(dir, newFilename)
1771
+
1772
+ if (fs.existsSync(newPath)) {
1773
+ sendJson(res, 409, { error: `A canvas file named "${newFilename}" already exists` })
1774
+ return
1775
+ }
1776
+
1777
+ // Re-ID all widgets to avoid collisions
1778
+ const widgets = (sourceData.widgets || []).map(w => ({
1779
+ ...w,
1780
+ id: generateWidgetId(w.type || 'widget'),
1781
+ }))
1782
+
1783
+ const creationEvent = {
1784
+ event: 'canvas_created',
1785
+ timestamp: new Date().toISOString(),
1786
+ title: newTitle,
1787
+ grid: sourceData.grid ?? true,
1788
+ gridSize: sourceData.gridSize ?? 24,
1789
+ colorMode: sourceData.colorMode ?? 'auto',
1790
+ widgets,
1791
+ }
1792
+
1793
+ writeNewCanvas(newPath, creationEvent)
1794
+
1795
+ const relPath = path.relative(root, newPath).replace(/\\/g, '/')
1796
+ const canonicalName = toCanvasId(relPath) || kebab
1797
+
1798
+ sendJson(res, 201, {
1799
+ success: true,
1800
+ name: canonicalName,
1801
+ path: relPath,
1802
+ route: `/canvas/${canonicalName}`,
1803
+ })
1804
+ } catch (err) {
1805
+ sendJson(res, 500, { error: `Failed to duplicate canvas: ${err.message}` })
1806
+ }
1807
+ return
1808
+ }
1809
+
1810
+ // POST /create — create a new .canvas.jsonl file
1811
+ // Supports `convertFrom` to convert a single-page canvas into a multi-page folder.
1812
+ if (routePath === '/create' && method === 'POST') {
1813
+ const {
1814
+ name,
1815
+ title,
1816
+ folder,
1817
+ convertFrom,
1818
+ author,
1819
+ description,
1820
+ meta,
1821
+ grid = true,
1822
+ gridSize = 24,
1823
+ colorMode = 'auto',
1824
+ } = body
1825
+
1826
+ if (!name || typeof name !== 'string') {
1827
+ sendJson(res, 400, { error: 'Canvas name is required' })
1828
+ return
1829
+ }
1830
+
1831
+ const kebab = name
1832
+ .replace(/[^a-zA-Z0-9\s_-]/g, '')
1833
+ .trim()
1834
+ .replace(/[\s_]+/g, '-')
1835
+ .toLowerCase()
1836
+ .replace(/-+/g, '-')
1837
+ .replace(/^-|-$/g, '')
1838
+
1839
+ if (!kebab) {
1840
+ sendJson(res, 400, { error: 'Name must contain at least one alphanumeric character' })
1841
+ return
1842
+ }
1843
+
1844
+ // ── Convert single-page canvas to multi-page folder ──────────────
1845
+ if (convertFrom && typeof convertFrom === 'string') {
1846
+ // Only allow flat root canvases (no path segments, no proto:)
1847
+ if (convertFrom.includes('/') || convertFrom.startsWith('proto:')) {
1848
+ sendJson(res, 400, { error: 'convertFrom only supports flat root canvases (no path segments or proto: prefix)' })
1849
+ return
1850
+ }
1851
+
1852
+ const canvasDir = path.join(root, 'src', 'canvas')
1853
+ const existingPath = findCanvasPath(root, convertFrom)
1854
+ if (!existingPath) {
1855
+ sendJson(res, 404, { error: `Canvas "${convertFrom}" not found` })
1856
+ return
1857
+ }
1858
+
1859
+ // Verify it's actually a flat file in src/canvas/ (not already in a folder)
1860
+ const existingRel = path.relative(canvasDir, existingPath).replace(/\\/g, '/')
1861
+ if (existingRel.includes('/')) {
1862
+ sendJson(res, 400, { error: `Canvas "${convertFrom}" is already inside a folder` })
1863
+ return
1864
+ }
1865
+
1866
+ const newDir = path.join(canvasDir, convertFrom)
1867
+ const dotFolderDir = path.join(canvasDir, `${convertFrom}.folder`)
1868
+
1869
+ // Preflight: check for collisions
1870
+ if (fs.existsSync(newDir)) {
1871
+ sendJson(res, 409, { error: `Directory "${convertFrom}" already exists in src/canvas/` })
1872
+ return
1873
+ }
1874
+ if (fs.existsSync(dotFolderDir)) {
1875
+ sendJson(res, 409, { error: `Directory "${convertFrom}.folder" already exists in src/canvas/` })
1876
+ return
1877
+ }
1878
+
1879
+ // Read the existing canvas to extract metadata for .meta.json
1880
+ let existingData
1881
+ try {
1882
+ existingData = readCanvas(existingPath)
1883
+ } catch (err) {
1884
+ sendJson(res, 500, { error: `Failed to read existing canvas: ${err.message}` })
1885
+ return
1886
+ }
1887
+
1888
+ const existingBasename = path.basename(existingPath)
1889
+
1890
+ const movedCanvasPath = path.join(newDir, existingBasename)
1891
+ const newPagePath = path.join(newDir, `${kebab}.canvas.jsonl`)
1892
+
1893
+ if (existingBasename === `${kebab}.canvas.jsonl`) {
1894
+ sendJson(res, 409, { error: `New page name "${kebab}" collides with existing canvas filename` })
1895
+ return
1896
+ }
1897
+
1898
+ // Perform the conversion with rollback on failure
1899
+ const rollbackOps = []
1900
+ try {
1901
+ // 1. Create the directory
1902
+ fs.mkdirSync(newDir, { recursive: true })
1903
+ rollbackOps.push(() => { try { fs.rmdirSync(newDir) } catch { /* ignore */ } })
1904
+
1905
+ // 2. Move the existing canvas file
1906
+ fs.renameSync(existingPath, movedCanvasPath)
1907
+ rollbackOps.push(() => { try { fs.renameSync(movedCanvasPath, existingPath) } catch { /* ignore */ } })
1908
+
1909
+ // 3. Write .meta.json with metadata from the existing canvas
1910
+ const metaObj = { title: existingData?.title || convertFrom }
1911
+ if (existingData?.description) metaObj.description = existingData.description
1912
+ if (existingData?.author) metaObj.author = existingData.author
1913
+ const metaPath = path.join(newDir, `${convertFrom}.meta.json`)
1914
+ fs.writeFileSync(metaPath, JSON.stringify(metaObj, null, 2) + '\n', 'utf-8')
1915
+ rollbackOps.push(() => { try { fs.unlinkSync(metaPath) } catch { /* ignore */ } })
1916
+
1917
+ // 4. Create the new page
1918
+ const creationEvent = {
1919
+ event: 'canvas_created',
1920
+ timestamp: new Date().toISOString(),
1921
+ title: title || kebab.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' '),
1922
+ grid,
1923
+ gridSize,
1924
+ colorMode,
1925
+ widgets: [],
1926
+ }
1927
+ writeNewCanvas(newPagePath, creationEvent)
1928
+
1929
+ const relPath = path.relative(root, newPagePath).replace(/\\/g, '/')
1930
+ const canonicalName = toCanvasId(relPath) || kebab
1931
+
1932
+ sendJson(res, 201, {
1933
+ success: true,
1934
+ converted: true,
1935
+ name: canonicalName,
1936
+ path: relPath,
1937
+ route: `/canvas/${canonicalName}`,
1938
+ })
1939
+ } catch (err) {
1940
+ // Rollback in reverse order
1941
+ for (let i = rollbackOps.length - 1; i >= 0; i--) {
1942
+ rollbackOps[i]()
1943
+ }
1944
+ sendJson(res, 500, { error: `Failed to convert canvas to folder: ${err.message}` })
1945
+ }
1946
+ return
1947
+ }
1948
+
1949
+ // ── Standard canvas creation ─────────────────────────────────────
1950
+ // Determine target directory
1951
+ const canvasDir = path.join(root, 'src', 'canvas')
1952
+ let targetDir = canvasDir
1953
+
1954
+ if (folder) {
1955
+ const dotFolderDir = path.join(canvasDir, `${folder}.folder`)
1956
+ const plainDir = path.join(canvasDir, folder)
1957
+
1958
+ if (fs.existsSync(dotFolderDir)) {
1959
+ // Existing .folder/ directory
1960
+ targetDir = dotFolderDir
1961
+ } else if (fs.existsSync(plainDir) && fs.statSync(plainDir).isDirectory()) {
1962
+ // Existing plain directory
1963
+ targetDir = plainDir
1964
+ } else {
1965
+ // Create new plain directory
1966
+ try {
1967
+ fs.mkdirSync(plainDir, { recursive: true })
1968
+ // Write .meta.json if meta was provided
1969
+ if (meta && typeof meta === 'object') {
1970
+ const metaPath = path.join(plainDir, `${folder}.meta.json`)
1971
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf-8')
1972
+ }
1973
+ } catch (err) {
1974
+ sendJson(res, 500, { error: `Failed to create directory: ${err.message}` })
1975
+ return
1976
+ }
1977
+ targetDir = plainDir
1978
+ }
1979
+ }
1980
+
1981
+ const canvasPath = path.join(targetDir, `${kebab}.canvas.jsonl`)
1982
+ if (fs.existsSync(canvasPath)) {
1983
+ sendJson(res, 409, { error: `Canvas "${kebab}" already exists` })
1984
+ return
1985
+ }
1986
+
1987
+ const creationEvent = {
1988
+ event: 'canvas_created',
1989
+ timestamp: new Date().toISOString(),
1990
+ title: title || kebab.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' '),
1991
+ grid,
1992
+ gridSize,
1993
+ colorMode,
1994
+ widgets: [],
1995
+ }
1996
+
1997
+ if (author) {
1998
+ creationEvent.author = author
1999
+ }
2000
+
2001
+ if (description) {
2002
+ creationEvent.description = description
2003
+ }
2004
+
2005
+ try {
2006
+ fs.mkdirSync(targetDir, { recursive: true })
2007
+ writeNewCanvas(canvasPath, creationEvent)
2008
+
2009
+ const relPath = path.relative(root, canvasPath).replace(/\\/g, '/')
2010
+ const canonicalName = toCanvasId(relPath) || kebab
2011
+
2012
+ const result = {
2013
+ success: true,
2014
+ name: canonicalName,
2015
+ path: relPath,
2016
+ route: `/canvas/${canonicalName}`,
2017
+ }
2018
+
2019
+ sendJson(res, 201, result)
2020
+ } catch (err) {
2021
+ sendJson(res, 500, { error: `Failed to create canvas: ${err.message}` })
2022
+ }
2023
+ return
2024
+ }
2025
+
2026
+ // ── Story routes ──────────────────────────────────────────────────
2027
+
2028
+ // GET /stories — list all .story.{jsx,tsx} files with their exports
2029
+ if (routePath === '/stories' && method === 'GET') {
2030
+ try {
2031
+ const storyFiles = findStoryFiles(root)
2032
+ sendJson(res, 200, { stories: storyFiles })
2033
+ } catch (err) {
2034
+ sendJson(res, 500, { error: `Failed to list stories: ${err.message}` })
2035
+ }
2036
+ return
2037
+ }
2038
+
2039
+ // POST /create-story — scaffold a new .story.jsx/.tsx file
2040
+ if (routePath === '/create-story' && method === 'POST') {
2041
+ const { name, location, format = 'jsx', canvasName: storyCanvasName } = body
2042
+
2043
+ if (!name || typeof name !== 'string') {
2044
+ sendJson(res, 400, { error: 'Component name is required' })
2045
+ return
2046
+ }
2047
+
2048
+ const kebab = name
2049
+ .replace(/[^a-zA-Z0-9\s_-]/g, '')
2050
+ .trim()
2051
+ .replace(/[\s_]+/g, '-')
2052
+ .toLowerCase()
2053
+ .replace(/-+/g, '-')
2054
+ .replace(/^-|-$/g, '')
2055
+
2056
+ if (!kebab) {
2057
+ sendJson(res, 400, { error: 'Name must contain at least one alphanumeric character' })
2058
+ return
2059
+ }
2060
+
2061
+ const ext = format === 'tsx' ? 'tsx' : 'jsx'
2062
+
2063
+ // Resolve target directory from location + canvas name
2064
+ let targetDir
2065
+ if (location === 'components') {
2066
+ targetDir = path.join(root, 'src', 'components')
2067
+ } else if (storyCanvasName) {
2068
+ const canvasPath = findCanvasPath(root, storyCanvasName)
2069
+ targetDir = canvasPath ? path.dirname(canvasPath) : path.join(root, 'src', 'canvas')
2070
+ } else {
2071
+ targetDir = path.join(root, 'src', 'canvas')
2072
+ }
2073
+
2074
+ const storyPath = path.join(targetDir, `${kebab}.story.${ext}`)
2075
+ if (fs.existsSync(storyPath)) {
2076
+ sendJson(res, 409, { error: `Story "${kebab}.story.${ext}" already exists at ${path.relative(root, targetDir)}` })
2077
+ return
2078
+ }
2079
+
2080
+ // Check for duplicate story name anywhere in the project (Vite data plugin
2081
+ // enforces global uniqueness and would fail the build on duplicates)
2082
+ const existing = findStoryFiles(root)
2083
+ if (existing.some(s => s.name === kebab)) {
2084
+ sendJson(res, 409, { error: `A story named "${kebab}" already exists in the project` })
2085
+ return
2086
+ }
2087
+
2088
+ const componentName = kebab.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('')
2089
+ const content = `/**
2090
+ * ${componentName} component stories.
2091
+ * Each named export becomes a draggable widget on the canvas.
2092
+ */
2093
+
2094
+ export function Default() {
2095
+ return (
2096
+ <div style={{ padding: '1.5rem', minWidth: 200 }}>
2097
+ <h3>${componentName}</h3>
2098
+ <p>Edit this file to build your component.</p>
2099
+ </div>
2100
+ )
2101
+ }
2102
+ `
2103
+
2104
+ try {
2105
+ fs.mkdirSync(targetDir, { recursive: true })
2106
+ fs.writeFileSync(storyPath, content, 'utf-8')
2107
+
2108
+ const relPath = path.relative(root, storyPath)
2109
+ sendJson(res, 201, {
2110
+ success: true,
2111
+ name: kebab,
2112
+ path: relPath,
2113
+ storyId: kebab,
2114
+ })
2115
+ } catch (err) {
2116
+ sendJson(res, 500, { error: `Failed to create story: ${err.message}` })
2117
+ }
2118
+ return
2119
+ }
2120
+
2121
+ // GET /github/available — check if gh CLI is installed locally
2122
+ if (routePath === '/github/available' && method === 'GET') {
2123
+ sendJson(res, 200, {
2124
+ available: isGhCliAvailable(),
2125
+ installUrl: GH_INSTALL_URL,
2126
+ })
2127
+ return
2128
+ }
2129
+
2130
+ // POST /github/embed — fetch metadata for GitHub issue/discussion/comment links
2131
+ if (routePath === '/github/embed' && method === 'POST') {
2132
+ const rawUrl = typeof body?.url === 'string' ? body.url.trim() : ''
2133
+
2134
+ if (!rawUrl) {
2135
+ sendJson(res, 400, { code: 'invalid_url', error: 'url is required' })
2136
+ return
2137
+ }
2138
+
2139
+ if (!isGitHubEmbedUrl(rawUrl)) {
2140
+ sendJson(res, 400, {
2141
+ code: 'unsupported_url',
2142
+ error: 'Only GitHub issue, discussion, and comment URLs are supported.',
2143
+ })
2144
+ return
2145
+ }
2146
+
2147
+ try {
2148
+ const snapshot = fetchGitHubEmbedSnapshot(rawUrl)
2149
+ sendJson(res, 200, { success: true, snapshot })
2150
+ } catch (error) {
2151
+ if (error instanceof GitHubEmbedError) {
2152
+ sendJson(res, error.status ?? 500, {
2153
+ code: error.code,
2154
+ error: error.message,
2155
+ installUrl: error.code === 'gh_unavailable' ? GH_INSTALL_URL : undefined,
2156
+ })
2157
+ return
2158
+ }
2159
+
2160
+ sendJson(res, 500, {
2161
+ code: 'gh_fetch_failed',
2162
+ error: error?.message || 'Failed to fetch GitHub metadata.',
2163
+ })
2164
+ }
2165
+ return
2166
+ }
2167
+
2168
+ // ── Image routes ──────────────────────────────────────────────────
2169
+
2170
+ const imagesDir = path.join(root, 'assets', 'canvas', 'images')
2171
+ const snapshotsDir = path.join(root, 'assets', 'canvas', 'snapshots')
2172
+
2173
+ const MIME_TO_EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' }
2174
+ const EXT_TO_MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }
2175
+ const MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5 MB
2176
+
2177
+ // Route snapshot uploads (snapshot-* prefix) to the snapshots directory
2178
+ function resolveWriteDir(canvasName) {
2179
+ return canvasName?.startsWith('snapshot-') ? snapshotsDir : imagesDir
2180
+ }
2181
+
2182
+ function resolveImagePath(filename) {
2183
+ // Check snapshots dir first, then images
2184
+ const snapshotPath = path.join(snapshotsDir, filename)
2185
+ if (fs.existsSync(snapshotPath)) return snapshotPath
2186
+ const imagePath = path.join(imagesDir, filename)
2187
+ if (fs.existsSync(imagePath)) return imagePath
2188
+ return null
2189
+ }
2190
+
2191
+ // POST /image — upload a pasted image (base64 data URL)
2192
+ if (routePath === '/image' && method === 'POST') {
2193
+ const { dataUrl, canvasName } = body
2194
+
2195
+ if (!dataUrl || typeof dataUrl !== 'string') {
2196
+ sendJson(res, 400, { error: 'dataUrl is required' })
2197
+ return
2198
+ }
2199
+
2200
+ const match = dataUrl.match(/^data:(image\/[a-z+]+);base64,(.+)$/i)
2201
+ if (!match) {
2202
+ sendJson(res, 400, { error: 'Invalid data URL format' })
2203
+ return
2204
+ }
2205
+
2206
+ const mime = match[1].toLowerCase()
2207
+ const ext = MIME_TO_EXT[mime]
2208
+ if (!ext) {
2209
+ sendJson(res, 400, { error: `Unsupported image type: ${mime}` })
2210
+ return
2211
+ }
2212
+
2213
+ const base64 = match[2]
2214
+ const buffer = Buffer.from(base64, 'base64')
2215
+
2216
+ if (buffer.length > MAX_IMAGE_SIZE) {
2217
+ sendJson(res, 413, { error: `Image exceeds ${MAX_IMAGE_SIZE / 1024 / 1024}MB limit` })
2218
+ return
2219
+ }
2220
+
2221
+ const now = new Date()
2222
+ const pad = (n) => String(n).padStart(2, '0')
2223
+ const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}--${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`
2224
+ const suffix = `-${Math.random().toString(36).slice(2, 6)}`
2225
+ const prefix = canvasName ? `${canvasName.replace(/[/:]/g, '--')}--` : ''
2226
+
2227
+ // Support explicit filename for snapshot uploads (stable naming)
2228
+ // and cropped image uploads (user-initiated crop)
2229
+ const explicitName = body.filename
2230
+ let filename
2231
+ if (explicitName && /^snapshot-[a-z0-9_-]+--(latest|light|dark)\.webp$/i.test(explicitName)) {
2232
+ filename = explicitName
2233
+ } else if (explicitName && /--cropped--\d{4}-\d{2}-\d{2}--\d{2}-\d{2}-\d{2}\.\w+$/.test(explicitName)) {
2234
+ // Cropped image: validate format, strip path traversal
2235
+ const safeName = explicitName.replace(/[/\\]/g, '')
2236
+ if (safeName === explicitName && !explicitName.includes('..')) {
2237
+ filename = explicitName
2238
+ } else {
2239
+ filename = `${prefix}${dateStr}${suffix}.${ext}`
2240
+ }
2241
+ } else {
2242
+ filename = `${prefix}${dateStr}${suffix}.${ext}`
2243
+ }
2244
+ const targetDir = resolveWriteDir(canvasName || '')
2245
+
2246
+ try {
2247
+ fs.mkdirSync(targetDir, { recursive: true })
2248
+ fs.writeFileSync(path.join(targetDir, filename), buffer)
2249
+ sendJson(res, 201, { success: true, filename })
2250
+ } catch (err) {
2251
+ sendJson(res, 500, { error: `Failed to save image: ${err.message}` })
2252
+ }
2253
+ return
2254
+ }
2255
+
2256
+ // GET /images/<filename> — serve an image file
2257
+ if (routePath.startsWith('/images/') && method === 'GET') {
2258
+ // Strip query string (e.g. ?v=123 cache busters) from filename
2259
+ let filename = routePath.slice('/images/'.length)
2260
+ const qIdx = filename.indexOf('?')
2261
+ if (qIdx !== -1) filename = filename.slice(0, qIdx)
2262
+
2263
+ // Block path traversal
2264
+ if (!filename || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
2265
+ sendJson(res, 400, { error: 'Invalid filename' })
2266
+ return
2267
+ }
2268
+
2269
+ const filePath = resolveImagePath(filename)
2270
+ if (!filePath) {
2271
+ sendJson(res, 404, { error: 'Image not found' })
2272
+ return
2273
+ }
2274
+
2275
+ const ext = path.extname(filename).slice(1).toLowerCase()
2276
+ const contentType = EXT_TO_MIME[ext] || 'application/octet-stream'
2277
+
2278
+ try {
2279
+ const data = fs.readFileSync(filePath)
2280
+ res.writeHead(200, {
2281
+ 'Content-Type': contentType,
2282
+ 'Content-Length': data.length,
2283
+ 'Cache-Control': 'no-cache',
2284
+ })
2285
+ res.end(data)
2286
+ } catch (err) {
2287
+ sendJson(res, 500, { error: `Failed to serve image: ${err.message}` })
2288
+ }
2289
+ return
2290
+ }
2291
+
2292
+ // POST /image/duplicate — copy an image file with a new timestamped name
2293
+ if (routePath === '/image/duplicate' && method === 'POST') {
2294
+ const { filename } = body
2295
+
2296
+ if (!filename || typeof filename !== 'string') {
2297
+ sendJson(res, 400, { error: 'filename is required' })
2298
+ return
2299
+ }
2300
+
2301
+ if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
2302
+ sendJson(res, 400, { error: 'Invalid filename' })
2303
+ return
2304
+ }
2305
+
2306
+ const sourcePath = resolveImagePath(filename)
2307
+ if (!sourcePath) {
2308
+ sendJson(res, 404, { error: 'Image not found' })
2309
+ return
2310
+ }
2311
+
2312
+ try {
2313
+ const ext = path.extname(filename)
2314
+ const now = new Date()
2315
+ const pad = (n) => String(n).padStart(2, '0')
2316
+ const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}--${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`
2317
+ // Preserve privacy prefix
2318
+ const prefix = filename.startsWith('~') ? '~' : ''
2319
+ const baseName = filename.replace(/^~/, '').replace(ext, '')
2320
+ // Extract canvas prefix (everything before the date pattern or the full base)
2321
+ const canvasMatch = baseName.match(/^(.+?--)\d{4}-/)
2322
+ const canvasPrefix = canvasMatch ? canvasMatch[1] : ''
2323
+ const newFilename = `${prefix}${canvasPrefix}${dateStr}${ext}`
2324
+ const targetDir = path.dirname(sourcePath)
2325
+ fs.copyFileSync(sourcePath, path.join(targetDir, newFilename))
2326
+ sendJson(res, 201, { success: true, filename: newFilename })
2327
+ } catch (err) {
2328
+ sendJson(res, 500, { error: `Failed to duplicate image: ${err.message}` })
2329
+ }
2330
+ return
2331
+ }
2332
+
2333
+ // POST /image/toggle-private — toggle tilde prefix on image filename
2334
+ if (routePath === '/image/toggle-private' && method === 'POST') {
2335
+ const { filename } = body
2336
+
2337
+ if (!filename || typeof filename !== 'string') {
2338
+ sendJson(res, 400, { error: 'filename is required' })
2339
+ return
2340
+ }
2341
+
2342
+ if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
2343
+ sendJson(res, 400, { error: 'Invalid filename' })
2344
+ return
2345
+ }
2346
+
2347
+ const isPrivate = filename.startsWith('~')
2348
+ const newFilename = isPrivate ? filename.slice(1) : `~${filename}`
2349
+ const oldPath = resolveImagePath(filename)
2350
+ if (!oldPath) {
2351
+ sendJson(res, 404, { error: 'Image not found' })
2352
+ return
2353
+ }
2354
+ const parentDir = path.dirname(oldPath)
2355
+ const newPath = path.join(parentDir, newFilename)
2356
+
2357
+ try {
2358
+ fs.renameSync(oldPath, newPath)
2359
+ sendJson(res, 200, { success: true, filename: newFilename, private: !isPrivate })
2360
+ } catch (err) {
2361
+ sendJson(res, 500, { error: `Failed to toggle private: ${err.message}` })
2362
+ }
2363
+ return
2364
+ }
2365
+
2366
+ // ── Agent Signal API ──────────────────────────────────────────────────
2367
+
2368
+ // POST /agent/signal — agent signals status (done/error/running)
2369
+ if (routePath === '/agent/signal' && method === 'POST') {
2370
+ const { widgetId, canvasId, branch, status, message, data: payload } = body
2371
+
2372
+ if (!widgetId || !status) {
2373
+ sendJson(res, 400, { error: 'widgetId and status are required' })
2374
+ return
2375
+ }
2376
+
2377
+ const validStatuses = ['done', 'error', 'running']
2378
+ if (!validStatuses.includes(status)) {
2379
+ sendJson(res, 400, { error: `status must be one of: ${validStatuses.join(', ')}` })
2380
+ return
2381
+ }
2382
+
2383
+ try {
2384
+ const { updateAgentStatus, initTerminalConfig } = await import('./terminal-config.js')
2385
+ initTerminalConfig(root)
2386
+ updateAgentStatus({
2387
+ branch: branch || 'unknown',
2388
+ canvasId: canvasId || 'unknown',
2389
+ widgetId,
2390
+ status,
2391
+ message: message || null,
2392
+ data: payload || null,
2393
+ })
2394
+
2395
+ // Push status to canvas clients via Vite WS custom event
2396
+ if (__viteWs) {
2397
+ __viteWs.send({
2398
+ type: 'custom',
2399
+ event: 'storyboard:agent-status',
2400
+ data: { widgetId, canvasId, status, message, timestamp: new Date().toISOString() },
2401
+ })
2402
+ }
2403
+
2404
+ sendJson(res, 200, { success: true, status })
2405
+ } catch (err) {
2406
+ sendJson(res, 500, { error: `Failed to update agent status: ${err.message}` })
2407
+ }
2408
+ return
2409
+ }
2410
+
2411
+ // GET /agent/status — poll agent status for a widget
2412
+ if (routePath === '/agent/status' && method === 'GET') {
2413
+ const url = new URL(req.url, 'http://localhost')
2414
+ const widgetId = url.searchParams.get('widgetId')
2415
+ const canvasId = url.searchParams.get('canvasId') || 'unknown'
2416
+ const branch = url.searchParams.get('branch') || 'unknown'
2417
+
2418
+ if (!widgetId) {
2419
+ sendJson(res, 400, { error: 'widgetId query parameter is required' })
2420
+ return
2421
+ }
2422
+
2423
+ try {
2424
+ const { readTerminalConfig, initTerminalConfig } = await import('./terminal-config.js')
2425
+ initTerminalConfig(root)
2426
+ const config = readTerminalConfig({ branch, canvasId, widgetId })
2427
+ sendJson(res, 200, { agentStatus: config?.agentStatus || null })
2428
+ } catch (err) {
2429
+ sendJson(res, 500, { error: `Failed to read agent status: ${err.message}` })
2430
+ }
2431
+ return
2432
+ }
2433
+
2434
+ // POST /agent/spawn — spawn a headless agent session
2435
+ if (routePath === '/agent/spawn' && method === 'POST') {
2436
+ const { canvasId, widgetId, prompt, autopilot = true, branch: reqBranch } = body
2437
+
2438
+ if (!canvasId || !widgetId || !prompt) {
2439
+ sendJson(res, 400, { error: 'canvasId, widgetId, and prompt are required' })
2440
+ return
2441
+ }
2442
+
2443
+ try {
2444
+ const { execSync } = await import('node:child_process')
2445
+ const { writeTerminalConfig, updateAgentStatus, initTerminalConfig } = await import('./terminal-config.js')
2446
+ const { generateTmuxName, registerSession } = await import('./terminal-registry.js')
2447
+ const fsModule = await import('node:fs')
2448
+
2449
+ initTerminalConfig(root)
2450
+
2451
+ let branch = reqBranch || 'unknown'
2452
+ try {
2453
+ branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
2454
+ } catch { /* empty */ }
2455
+
2456
+ const tmuxName = generateTmuxName(branch, canvasId, widgetId)
2457
+
2458
+ // Register in session registry
2459
+ registerSession({ branch, canvasId, widgetId, prettyName: null })
2460
+
2461
+ // Write terminal config with connected widget context
2462
+ writeTerminalConfig({ branch, canvasId, widgetId })
2463
+
2464
+ // Mark as running
2465
+ updateAgentStatus({ branch, canvasId, widgetId, status: 'running', message: 'Agent spawning...' })
2466
+
2467
+ // Push running status to clients
2468
+ if (__viteWs) {
2469
+ __viteWs.send({
2470
+ type: 'custom',
2471
+ event: 'storyboard:agent-status',
2472
+ data: { widgetId, canvasId, status: 'running', timestamp: new Date().toISOString() },
2473
+ })
2474
+ }
2475
+
2476
+ // Build server URL for agent env vars
2477
+ const serverUrl = `http://localhost:${req.socket?.localPort || 1234}`
2478
+
2479
+ // Create headless tmux session
2480
+ try {
2481
+ execSync(`tmux new-session -d -s "${tmuxName}" -c "${root}"`, { stdio: 'ignore' })
2482
+ execSync(`tmux set-option -t "${tmuxName}" status off`, { stdio: 'ignore' })
2483
+ execSync(`tmux set-option -t "${tmuxName}" mouse on`, { stdio: 'ignore' })
2484
+ execSync(`tmux set-option -t "${tmuxName}" set-clipboard off`, { stdio: 'ignore' })
2485
+ } catch (err) {
2486
+ // Session may already exist
2487
+ devLog().logEvent('warn', 'tmux session create failed', { tmuxName, error: err.message })
2488
+ }
2489
+
2490
+ // Set environment variables at tmux session level (inherited by new panes)
2491
+ const envMap = {
2492
+ STORYBOARD_WIDGET_ID: widgetId,
2493
+ STORYBOARD_CANVAS_ID: canvasId,
2494
+ STORYBOARD_BRANCH: branch,
2495
+ STORYBOARD_SERVER_URL: serverUrl,
2496
+ }
2497
+ for (const [key, val] of Object.entries(envMap)) {
2498
+ execSync(`tmux setenv -t "${tmuxName}" ${key} "${val}"`, { stdio: 'ignore' })
2499
+ }
2500
+
2501
+ // Write env file for this terminal session — sourced before copilot launch
2502
+ // This avoids race conditions with tmux send-keys export
2503
+ const envFile = path.join(root, '.storyboard', 'terminals', `${tmuxName}.env`)
2504
+ const envContent = Object.entries(envMap).map(([k, v]) => `export ${k}=${JSON.stringify(v)}`).join('\n') + '\n'
2505
+ fsModule.writeFileSync(envFile, envContent)
2506
+
2507
+ // Build command from widgets.config.json (prompt mode) or storyboard.config.json (interactive)
2508
+ let copilotCmd
2509
+ if (autopilot) {
2510
+ copilotCmd = buildPromptCmd({ prompt, envFile })
2511
+ if (!copilotCmd) {
2512
+ const execution = getPromptExecution()
2513
+ sendJson(res, 400, { error: `Default agent "${execution?.default || 'unknown'}" has no prompt command configured` })
2514
+ return
2515
+ }
2516
+ } else {
2517
+ // Interactive mode — read startupCommand from storyboard.config.json
2518
+ let startupCmd = 'copilot'
2519
+ try {
2520
+ const configPath = path.join(root, 'storyboard.config.json')
2521
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
2522
+ const agents = config?.canvas?.agents || {}
2523
+ const defaultAgent = Object.values(agents).find(a => a.default) || Object.values(agents)[0]
2524
+ if (defaultAgent?.startupCommand) startupCmd = defaultAgent.startupCommand
2525
+ } catch { /* empty */ }
2526
+ copilotCmd = `source ${envFile} && ${startupCmd}`
2527
+ }
2528
+
2529
+ setTimeout(() => {
2530
+ try {
2531
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(copilotCmd)}`, { stdio: 'ignore' })
2532
+ execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
2533
+ } catch (err) {
2534
+ devLog().logEvent('warn', 'Failed to launch copilot', { tmuxName, error: err.message })
2535
+ }
2536
+ // Poll for copilot readiness, then send /autopilot + Enter once
2537
+ let sent = false
2538
+ const poll = setInterval(() => {
2539
+ if (sent) { clearInterval(poll); return }
2540
+ try {
2541
+ const pane = execSync(`tmux capture-pane -t "${tmuxName}" -p`, { encoding: 'utf8', timeout: 1000 })
2542
+ if (pane.includes('Environment loaded:')) {
2543
+ sent = true
2544
+ clearInterval(poll)
2545
+ setTimeout(() => {
2546
+ try {
2547
+ execSync(`tmux send-keys -t "${tmuxName}" -l "/allow-all on"`, { stdio: 'ignore' })
2548
+ execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
2549
+ } catch { /* empty */ }
2550
+ }, 500)
2551
+ }
2552
+ } catch { /* empty */ }
2553
+ }, 1000)
2554
+ setTimeout(() => { if (!sent) { sent = true; clearInterval(poll) } }, 15000)
2555
+ }, 500)
2556
+
2557
+ // Set up idle timeout (5 minutes)
2558
+ const IDLE_TIMEOUT = 5 * 60 * 1000
2559
+ setTimeout(async () => {
2560
+ try {
2561
+ const { readTerminalConfig } = await import('./terminal-config.js')
2562
+ const config = readTerminalConfig({ branch, canvasId, widgetId })
2563
+ if (config?.agentStatus?.status === 'running') {
2564
+ updateAgentStatus({ branch, canvasId, widgetId, status: 'error', message: 'Agent timed out (5 min idle)' })
2565
+ if (__viteWs) {
2566
+ __viteWs.send({
2567
+ type: 'custom',
2568
+ event: 'storyboard:agent-status',
2569
+ data: { widgetId, canvasId, status: 'error', message: 'Agent timed out', timestamp: new Date().toISOString() },
2570
+ })
2571
+ }
2572
+ }
2573
+ } catch { /* empty */ }
2574
+ }, IDLE_TIMEOUT)
2575
+
2576
+ sendJson(res, 200, { success: true, tmuxName, status: 'running' })
2577
+ } catch (err) {
2578
+ sendJson(res, 500, { error: `Failed to spawn agent: ${err.message}` })
2579
+ }
2580
+ return
2581
+ }
2582
+
2583
+ // POST /agent/peek — reconnect a headless agent session to a visible terminal widget
2584
+ if (routePath === '/agent/peek' && method === 'POST') {
2585
+ const { widgetId, canvasId } = body
2586
+
2587
+ if (!widgetId) {
2588
+ sendJson(res, 400, { error: 'widgetId is required' })
2589
+ return
2590
+ }
2591
+
2592
+ try {
2593
+ const { execSync } = await import('node:child_process')
2594
+ const { generateTmuxName } = await import('./terminal-registry.js')
2595
+
2596
+ let branch = 'unknown'
2597
+ try {
2598
+ branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
2599
+ } catch { /* empty */ }
2600
+
2601
+ const tmuxName = generateTmuxName(branch, canvasId || 'unknown', widgetId)
2602
+
2603
+ // Check if the tmux session exists
2604
+ try {
2605
+ execSync(`tmux has-session -t "${tmuxName}"`, { stdio: 'ignore' })
2606
+ } catch {
2607
+ sendJson(res, 404, { error: `No tmux session found for widget ${widgetId}` })
2608
+ return
2609
+ }
2610
+
2611
+ // The session exists — return info so the client can create a terminal widget
2612
+ // that connects to it
2613
+ sendJson(res, 200, {
2614
+ success: true,
2615
+ tmuxName,
2616
+ widgetId,
2617
+ canvasId: canvasId || 'unknown',
2618
+ message: 'Session is alive. Create a terminal widget to connect.',
2619
+ })
2620
+ } catch (err) {
2621
+ sendJson(res, 500, { error: `Failed to peek agent session: ${err.message}` })
2622
+ }
2623
+ return
2624
+ }
2625
+
2626
+ // ── Terminal Messaging API ──────────────────────────────────────────
2627
+
2628
+ // POST /terminal/send — send a message to a terminal via tmux send-keys
2629
+ if (routePath === '/terminal/send' && method === 'POST') {
2630
+ const { widgetId: targetWidgetId, message, from: senderWidgetId } = body
2631
+
2632
+ if (!targetWidgetId || !message) {
2633
+ sendJson(res, 400, { error: 'widgetId and message are required' })
2634
+ return
2635
+ }
2636
+
2637
+ try {
2638
+ const { execSync } = await import('node:child_process')
2639
+ const { findTmuxNameForWidget } = await import('./terminal-registry.js')
2640
+ const { readTerminalConfigById, updatePendingMessages, initTerminalConfig } = await import('./terminal-config.js')
2641
+
2642
+ initTerminalConfig(root)
2643
+
2644
+ const tmuxName = findTmuxNameForWidget(targetWidgetId)
2645
+ if (!tmuxName) {
2646
+ sendJson(res, 404, { error: `No active session for widget ${targetWidgetId}` })
2647
+ return
2648
+ }
2649
+
2650
+ // Check session is live (widget open in browser)
2651
+ const { getSession } = await import('./terminal-registry.js')
2652
+ const session = getSession(tmuxName)
2653
+ const isLive = session?.status === 'live'
2654
+
2655
+ // Resolve sender display name
2656
+ let senderName = senderWidgetId || 'unknown'
2657
+ if (senderWidgetId) {
2658
+ try {
2659
+ const senderConfig = readTerminalConfigById(senderWidgetId)
2660
+ if (senderConfig?.displayName) senderName = senderConfig.displayName
2661
+ } catch { /* use widgetId as fallback */ }
2662
+ }
2663
+
2664
+ // Deterministic agent detection: get the pane's shell PID, then
2665
+ // check its child process. Known agent CLIs (copilot, claude) run
2666
+ // as direct children of the shell. We match against the process
2667
+ // name (comm) to identify which agent is running.
2668
+ let runningAgent = null // null = no agent, 'copilot' | 'claude' | 'codex' = which one
2669
+ try {
2670
+ const panePid = execSync(
2671
+ `tmux list-panes -t "${tmuxName}" -F '#{pane_pid}'`,
2672
+ { encoding: 'utf8', timeout: 2000 }
2673
+ ).trim()
2674
+ if (panePid && isLive) {
2675
+ const children = execSync(
2676
+ `ps -o comm= -p $(pgrep -P ${panePid} 2>/dev/null | tr '\\n' ',') 2>/dev/null || true`,
2677
+ { encoding: 'utf8', timeout: 2000 }
2678
+ ).trim().split('\n').map(s => s.trim()).filter(Boolean)
2679
+ for (const cmd of children) {
2680
+ if (cmd === 'copilot') { runningAgent = 'copilot'; break }
2681
+ if (cmd === 'claude') { runningAgent = 'claude'; break }
2682
+ if (cmd === 'codex') { runningAgent = 'codex'; break }
2683
+ }
2684
+ }
2685
+ } catch { /* tmux/ps not available */ }
2686
+
2687
+ const isAgentRunning = runningAgent !== null
2688
+
2689
+ if (isAgentRunning) {
2690
+ // Agent is running — send the full message directly (like a chat bubble)
2691
+ const formatted = `📩 ${senderName}: ${message}`
2692
+
2693
+ try {
2694
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(formatted)}`, { stdio: 'ignore' })
2695
+ execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
2696
+ } catch (err) {
2697
+ sendJson(res, 500, { error: `Failed to send via tmux: ${err.message}` })
2698
+ return
2699
+ }
2700
+
2701
+ sendJson(res, 200, { success: true, delivered: true })
2702
+ } else {
2703
+ // Shell prompt or unknown — queue the message
2704
+ updatePendingMessages(targetWidgetId, {
2705
+ from: senderWidgetId || null,
2706
+ fromName: senderName,
2707
+ message,
2708
+ createdAt: new Date().toISOString(),
2709
+ })
2710
+
2711
+ sendJson(res, 200, { success: true, queued: true })
2712
+ }
2713
+ } catch (err) {
2714
+ sendJson(res, 500, { error: `Failed to send message: ${err.message}` })
2715
+ }
2716
+ return
2717
+ }
2718
+
2719
+ // POST /terminal/output — save latest output to terminal config
2720
+ if (routePath === '/terminal/output' && method === 'POST') {
2721
+ const { widgetId: outputWidgetId, content, summary } = body
2722
+
2723
+ if (!outputWidgetId) {
2724
+ sendJson(res, 400, { error: 'widgetId is required' })
2725
+ return
2726
+ }
2727
+
2728
+ try {
2729
+ const { updateLatestOutput, initTerminalConfig } = await import('./terminal-config.js')
2730
+ initTerminalConfig(root)
2731
+
2732
+ updateLatestOutput(outputWidgetId, {
2733
+ content: content || '',
2734
+ summary: summary || '',
2735
+ updatedAt: new Date().toISOString(),
2736
+ })
2737
+
2738
+ sendJson(res, 200, { success: true })
2739
+ } catch (err) {
2740
+ sendJson(res, 500, { error: `Failed to save output: ${err.message}` })
2741
+ }
2742
+ return
2743
+ }
2744
+
2745
+ // POST /prompt/spawn — spawn a prompt agent session (acquires from hot pool)
2746
+ if (routePath === '/prompt/spawn' && method === 'POST') {
2747
+ const { canvasId, widgetId, prompt } = body
2748
+
2749
+ if (!canvasId || !widgetId || !prompt) {
2750
+ sendJson(res, 400, { error: 'canvasId, widgetId, and prompt are required' })
2751
+ return
2752
+ }
2753
+
2754
+ // Try to acquire a warm tmux session from the prompt pool
2755
+ const warmSession = hotPool?.acquire('prompt') || null
2756
+
2757
+ // Delegate to agent/spawn — the prompt widget is just a specialized agent
2758
+ // We reuse the same tmux-based infrastructure
2759
+ try {
2760
+ const { execSync } = await import('node:child_process')
2761
+ const { writeTerminalConfig, updateAgentStatus, updateTerminalConnections, initTerminalConfig } = await import('./terminal-config.js')
2762
+ const { generateTmuxName, registerSession } = await import('./terminal-registry.js')
2763
+ const fsModule = await import('node:fs')
2764
+
2765
+ initTerminalConfig(root)
2766
+
2767
+ let branch = 'unknown'
2768
+ try {
2769
+ branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
2770
+ } catch { /* empty */ }
2771
+
2772
+ const serverUrl = `http://localhost:${req.socket?.localPort || 1234}`
2773
+ const tmuxName = generateTmuxName(branch, canvasId, widgetId)
2774
+
2775
+ registerSession({ branch, canvasId, widgetId, prettyName: null })
2776
+ writeTerminalConfig({ branch, canvasId, widgetId, serverUrl, tmuxName })
2777
+ updateAgentStatus({ branch, canvasId, widgetId, status: 'running', message: 'Prompt agent spawning...' })
2778
+
2779
+ // Resolve connected widgets so the terminal-agent has context
2780
+ try {
2781
+ const canvasFilePath = findCanvasPath(root, canvasId)
2782
+ if (canvasFilePath) {
2783
+ const canvasData = readCanvas(canvasFilePath)
2784
+ const widgetMap = new Map((canvasData.widgets || []).map(w => [w.id, w]))
2785
+ const connectors = canvasData.connectors || []
2786
+ const connectedIds = new Set()
2787
+ for (const conn of connectors) {
2788
+ if (conn.start?.widgetId === widgetId) connectedIds.add(conn.end?.widgetId)
2789
+ if (conn.end?.widgetId === widgetId) connectedIds.add(conn.start?.widgetId)
2790
+ }
2791
+ connectedIds.delete(undefined)
2792
+ connectedIds.delete(null)
2793
+ const connectedWidgets = [...connectedIds]
2794
+ .map(id => widgetMap.get(id))
2795
+ .filter(Boolean)
2796
+ .map(w => ({ id: w.id, type: w.type, props: w.props, position: w.position }))
2797
+ if (connectedWidgets.length > 0) {
2798
+ updateTerminalConnections({ branch, canvasId, widgetId, connectedWidgets })
2799
+ }
2800
+ }
2801
+ } catch (err) {
2802
+ devLog().logEvent('warn', 'Failed to resolve prompt connections', { error: err.message })
2803
+ }
2804
+
2805
+ if (__viteWs) {
2806
+ __viteWs.send({
2807
+ type: 'custom',
2808
+ event: 'storyboard:agent-status',
2809
+ data: { widgetId, canvasId, status: 'running', timestamp: new Date().toISOString() },
2810
+ })
2811
+ }
2812
+
2813
+ // If we got a warm tmux session, rename it to the canonical name.
2814
+ // Otherwise, create a fresh tmux session from scratch.
2815
+ let usedWarm = false
2816
+ if (warmSession?.tmuxName) {
2817
+ try {
2818
+ // Kill any existing session with the canonical name first
2819
+ try { execSync(`tmux kill-session -t "${tmuxName}" 2>/dev/null`, { stdio: 'ignore' }) } catch { /* empty */ }
2820
+ // Rename the warm session to the canonical name
2821
+ execSync(`tmux rename-session -t "${warmSession.tmuxName}" "${tmuxName}"`, { stdio: 'ignore' })
2822
+ usedWarm = true
2823
+ hotPool.consume('prompt', warmSession.id)
2824
+ } catch {
2825
+ // Rename failed — fall back to creating fresh session
2826
+ hotPool.release('prompt', warmSession.id)
2827
+ }
2828
+ }
2829
+
2830
+ if (!usedWarm) {
2831
+ // Fresh tmux session (cold path)
2832
+ try {
2833
+ execSync(`tmux -f /dev/null new-session -d -s "${tmuxName}" -c "${root}"`, { stdio: 'ignore' })
2834
+ execSync(`tmux set-option -t "${tmuxName}" status off`, { stdio: 'ignore' })
2835
+ execSync(`tmux set-option -t "${tmuxName}" set-clipboard off 2>/dev/null`, { stdio: 'ignore' })
2836
+ } catch { /* session may already exist */ }
2837
+ }
2838
+
2839
+ // Set env vars — use send-keys to export into the running shell
2840
+ const envMap = {
2841
+ STORYBOARD_WIDGET_ID: widgetId,
2842
+ STORYBOARD_CANVAS_ID: canvasId,
2843
+ STORYBOARD_BRANCH: branch,
2844
+ STORYBOARD_SERVER_URL: serverUrl,
2845
+ }
2846
+
2847
+ // Write env file for the copilot command to source
2848
+ const { join } = await import('node:path')
2849
+ const envFile = join(root, '.storyboard', 'terminals', `${tmuxName}.env`)
2850
+ const envContent = Object.entries(envMap).map(([k, v]) => `export ${k}=${JSON.stringify(v)}`).join('\n') + '\n'
2851
+ fsModule.writeFileSync(envFile, envContent)
2852
+
2853
+ const copilotCmd = buildPromptCmd({ prompt, envFile })
2854
+ if (!copilotCmd) {
2855
+ const execution = getPromptExecution()
2856
+ sendJson(res, 400, { error: `Default agent "${execution?.default || 'unknown'}" has no prompt command configured` })
2857
+ return
2858
+ }
2859
+
2860
+ // Send the copilot command — warm sessions have a shell ready, no delay needed
2861
+ const delay = usedWarm ? 0 : 500
2862
+ const displayName = (() => {
2863
+ try {
2864
+ const canvasFilePath = findCanvasPath(root, canvasId)
2865
+ if (!canvasFilePath) return null
2866
+ const canvasData = readCanvas(canvasFilePath)
2867
+ const w = (canvasData.widgets || []).find(w => w.id === widgetId)
2868
+ return w?.props?.prettyName || null
2869
+ } catch { return null }
2870
+ })()
2871
+ const sendCmd = () => {
2872
+ try {
2873
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(copilotCmd)}`, { stdio: 'ignore' })
2874
+ execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
2875
+ } catch { /* empty */ }
2876
+ // Inject identity after the agent command starts
2877
+ setTimeout(() => {
2878
+ const configFile = `.storyboard/terminals/${widgetId}.json`
2879
+ const msg = `[System] Your terminal identity has been set. widgetId=${widgetId} displayName=${displayName || widgetId} canvasId=${canvasId} configFile=${configFile} serverUrl=${serverUrl} — this is a configuration step, no response needed.`
2880
+ try {
2881
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(msg)}`, { stdio: 'ignore' })
2882
+ execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
2883
+ } catch { /* empty */ }
2884
+ }, 3000)
2885
+ }
2886
+
2887
+ if (delay > 0) {
2888
+ setTimeout(sendCmd, delay)
2889
+ } else {
2890
+ sendCmd()
2891
+ }
2892
+
2893
+ // Idle timeout (5 min)
2894
+ setTimeout(async () => {
2895
+ try {
2896
+ const { readTerminalConfig } = await import('./terminal-config.js')
2897
+ const cfg = readTerminalConfig({ branch, canvasId, widgetId })
2898
+ if (cfg?.agentStatus?.status === 'running') {
2899
+ updateAgentStatus({ branch, canvasId, widgetId, status: 'error', message: 'Prompt timed out (5 min)' })
2900
+ if (__viteWs) {
2901
+ __viteWs.send({
2902
+ type: 'custom',
2903
+ event: 'storyboard:agent-status',
2904
+ data: { widgetId, canvasId, status: 'error', message: 'Prompt timed out', timestamp: new Date().toISOString() },
2905
+ })
2906
+ }
2907
+ }
2908
+ } catch { /* empty */ }
2909
+ }, 5 * 60 * 1000)
2910
+
2911
+ sendJson(res, 200, { success: true, tmuxName, status: 'running', warm: usedWarm })
2912
+ } catch (err) {
2913
+ sendJson(res, 500, { error: `Failed to spawn prompt agent: ${err.message}` })
2914
+ }
2915
+ return
2916
+ }
2917
+
2918
+ // POST /terminal/kill — kill a terminal/prompt tmux session
2919
+ if (routePath === '/terminal/kill' && method === 'POST') {
2920
+ const { widgetId: targetWidgetId } = body
2921
+
2922
+ if (!targetWidgetId) {
2923
+ sendJson(res, 400, { error: 'widgetId is required' })
2924
+ return
2925
+ }
2926
+
2927
+ try {
2928
+ const { findTmuxNameForWidget, killSession } = await import('./terminal-registry.js')
2929
+ const { updateAgentStatus, initTerminalConfig } = await import('./terminal-config.js')
2930
+
2931
+ initTerminalConfig(root)
2932
+
2933
+ const tmuxName = findTmuxNameForWidget(targetWidgetId)
2934
+ if (!tmuxName) {
2935
+ sendJson(res, 404, { error: `No active session for widget ${targetWidgetId}` })
2936
+ return
2937
+ }
2938
+
2939
+ // Close any WS connections for this session
2940
+ const { orphanTerminalSession } = await import('./terminal-server.js')
2941
+ orphanTerminalSession(targetWidgetId)
2942
+
2943
+ // Kill the tmux session and clean up registry
2944
+ killSession(tmuxName)
2945
+
2946
+ // Update agent status
2947
+ const pathParts = req.url.split('/')
2948
+ const _canvasIdx = pathParts.indexOf('canvas')
2949
+ void _canvasIdx
2950
+ let branch = 'unknown'
2951
+ try {
2952
+ const { execSync } = await import('node:child_process')
2953
+ branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
2954
+ } catch { /* empty */ }
2955
+
2956
+ try {
2957
+ updateAgentStatus({ branch, canvasId: 'unknown', widgetId: targetWidgetId, status: 'cancelled', message: 'Cancelled by user' })
2958
+ } catch { /* empty */ }
2959
+
2960
+ // Notify via HMR
2961
+ if (__viteWs) {
2962
+ __viteWs.send({
2963
+ type: 'custom',
2964
+ event: 'storyboard:agent-status',
2965
+ data: { widgetId: targetWidgetId, status: 'cancelled', message: 'Cancelled by user', timestamp: new Date().toISOString() },
2966
+ })
2967
+ }
2968
+
2969
+ sendJson(res, 200, { success: true, killed: tmuxName })
2970
+ } catch (err) {
2971
+ sendJson(res, 500, { error: `Failed to kill session: ${err.message}` })
2972
+ }
2973
+ return
2974
+ }
2975
+
2976
+ // GET /terminal-buffer/:widgetId — read terminal buffer JSON
2977
+ // Accepts optional ?length=N query param to truncate scrollback
2978
+ if (routePath.startsWith('/terminal-buffer/') && method === 'GET') {
2979
+ const widgetId = routePath.slice('/terminal-buffer/'.length).split('?')[0]
2980
+ if (!widgetId || widgetId.includes('..') || widgetId.includes('/')) {
2981
+ sendJson(res, 400, { error: 'Invalid widgetId' })
2982
+ return
2983
+ }
2984
+
2985
+ const urlObj = new URL(req.url, 'http://localhost')
2986
+ const lengthParam = urlObj.searchParams.get('length')
2987
+ const maxLength = lengthParam ? parseInt(lengthParam, 10) : undefined
2988
+
2989
+ try {
2990
+ const { readTerminalBuffer } = await import('./terminal-server.js')
2991
+ const buffer = readTerminalBuffer(widgetId, { maxLength: maxLength || undefined })
2992
+ if (buffer) {
2993
+ sendJson(res, 200, buffer)
2994
+ return
2995
+ }
2996
+ sendJson(res, 404, { error: 'Buffer not found' })
2997
+ } catch (err) {
2998
+ sendJson(res, 500, { error: `Failed to read buffer: ${err.message}` })
2999
+ }
3000
+ return
3001
+ }
3002
+
3003
+ // GET /terminal-snapshot/:widgetId — read terminal snapshot JSON (new + legacy fallback)
3004
+ if (routePath.startsWith('/terminal-snapshot/') && method === 'GET') {
3005
+ const widgetId = routePath.slice('/terminal-snapshot/'.length)
3006
+ if (!widgetId || widgetId.includes('..') || widgetId.includes('/')) {
3007
+ sendJson(res, 400, { error: 'Invalid widgetId' })
3008
+ return
3009
+ }
3010
+
3011
+ try {
3012
+ const { readTerminalSnapshot } = await import('./terminal-server.js')
3013
+
3014
+ // Try new path first
3015
+ const snapshot = readTerminalSnapshot(widgetId)
3016
+ if (snapshot) {
3017
+ sendJson(res, 200, snapshot)
3018
+ return
3019
+ }
3020
+
3021
+ // Legacy fallback: .storyboard/terminal-snapshots/<canvasDir>/<widgetId>.json
3022
+ const snapshotsRoot = path.join(root, '.storyboard', 'terminal-snapshots')
3023
+ if (fs.existsSync(snapshotsRoot)) {
3024
+ const dirs = fs.readdirSync(snapshotsRoot, { withFileTypes: true })
3025
+ for (const d of dirs) {
3026
+ if (!d.isDirectory()) continue
3027
+ const filePath = path.join(snapshotsRoot, d.name, `${widgetId}.json`)
3028
+ if (fs.existsSync(filePath)) {
3029
+ const data = fs.readFileSync(filePath, 'utf8')
3030
+ res.writeHead(200, { 'Content-Type': 'application/json' })
3031
+ res.end(data)
3032
+ return
3033
+ }
3034
+ }
3035
+ }
3036
+ sendJson(res, 404, { error: 'Snapshot not found' })
3037
+ } catch (err) {
3038
+ sendJson(res, 500, { error: `Failed to read snapshot: ${err.message}` })
3039
+ }
3040
+ return
3041
+ }
3042
+
3043
+ // DELETE /delete-canvas — delete a canvas and its directory
3044
+ if (routePath === '/delete-canvas' && method === 'DELETE') {
3045
+ const { name } = body
3046
+ if (!name || typeof name !== 'string') {
3047
+ sendJson(res, 400, { error: 'Canvas name is required' })
3048
+ return
3049
+ }
3050
+
3051
+ const filePath = findCanvasPath(root, name)
3052
+ if (!filePath) {
3053
+ sendJson(res, 404, { error: `Canvas "${name}" not found` })
3054
+ return
3055
+ }
3056
+
3057
+ try {
3058
+ const dir = path.dirname(filePath)
3059
+ const canvasDir = path.join(root, 'src', 'canvas')
3060
+
3061
+ // Delete the canvas file
3062
+ fs.unlinkSync(filePath)
3063
+
3064
+ // If the parent directory is inside src/canvas/ and now empty (or only has .meta.json), remove it
3065
+ if (dir !== canvasDir) {
3066
+ const remaining = fs.readdirSync(dir).filter(f => !f.endsWith('.meta.json'))
3067
+ if (remaining.length === 0) {
3068
+ for (const f of fs.readdirSync(dir)) {
3069
+ fs.unlinkSync(path.join(dir, f))
3070
+ }
3071
+ fs.rmdirSync(dir)
3072
+ }
3073
+ }
3074
+
3075
+ sendJson(res, 200, { success: true, deleted: name })
3076
+ } catch (err) {
3077
+ sendJson(res, 500, { error: `Failed to delete canvas: ${err.message}` })
3078
+ }
3079
+ return
3080
+ }
3081
+
3082
+ // PUT /update-meta — update canvas metadata
3083
+ if (routePath === '/update-meta' && method === 'PUT') {
3084
+ const { name, title, description, author } = body
3085
+ if (!name || typeof name !== 'string') {
3086
+ sendJson(res, 400, { error: 'Canvas name is required' })
3087
+ return
3088
+ }
3089
+
3090
+ const filePath = findCanvasPath(root, name)
3091
+ if (!filePath) {
3092
+ sendJson(res, 404, { error: `Canvas "${name}" not found` })
3093
+ return
3094
+ }
3095
+
3096
+ try {
3097
+ // Try to find and update .meta.json first
3098
+ const dir = path.dirname(filePath)
3099
+ const dirName = path.basename(dir).replace(/\.folder$/, '')
3100
+ const metaPath = path.join(dir, `${dirName}.meta.json`)
3101
+
3102
+ if (fs.existsSync(metaPath)) {
3103
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'))
3104
+ if (title !== undefined) meta.title = title
3105
+ if (description !== undefined) meta.description = description
3106
+ if (author !== undefined) meta.author = author
3107
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf-8')
3108
+ } else {
3109
+ // Update the canvas JSONL's canvas_created event metadata
3110
+ const text = fs.readFileSync(filePath, 'utf-8')
3111
+ const lines = text.split('\n').filter(Boolean)
3112
+ if (lines.length > 0) {
3113
+ const firstEvent = JSON.parse(lines[0])
3114
+ if (title !== undefined) firstEvent.title = title
3115
+ if (description !== undefined) firstEvent.description = description
3116
+ if (author !== undefined) firstEvent.author = author
3117
+ lines[0] = JSON.stringify(firstEvent)
3118
+ fs.writeFileSync(filePath, lines.join('\n') + '\n', 'utf-8')
3119
+ }
3120
+ }
3121
+
3122
+ // Notify via WebSocket
3123
+ pushCanvasUpdate(name, filePath, __viteWs)
3124
+
3125
+ sendJson(res, 200, { success: true, updated: name })
3126
+ } catch (err) {
3127
+ sendJson(res, 500, { error: `Failed to update canvas metadata: ${err.message}` })
3128
+ }
3129
+ return
3130
+ }
3131
+
3132
+ sendJson(res, 404, { error: `Unknown route: ${method} ${routePath}` })
3133
+ }
3134
+ }