@dfosco/storyboard-core 4.2.0-beta.2 → 4.2.0-beta.21

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 (414) hide show
  1. package/commandpalette.config.json +109 -24
  2. package/dist/storyboard-ui.css +1 -1
  3. package/dist/storyboard-ui.js +17379 -28568
  4. package/dist/storyboard-ui.js.map +1 -1
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +5 -2
  7. package/scaffold/agents/prompt-agent.agent.md +181 -0
  8. package/scaffold/agents/terminal-agent.agent.md +351 -0
  9. package/scaffold/codex/config.toml +246 -0
  10. package/scaffold/manifest.json +5 -0
  11. package/scaffold/skills/canvas/SKILL.md +5 -4
  12. package/scaffold/skills/ship/SKILL.md +1 -1
  13. package/scaffold/storyboard.config.json +14 -1
  14. package/scaffold/toolbar.config.json +1 -1
  15. package/src/ActionMenuButton.jsx +100 -0
  16. package/src/AutosyncMenuButton.css +67 -0
  17. package/src/AutosyncMenuButton.jsx +241 -0
  18. package/src/BranchSelect.jsx +29 -0
  19. package/src/BranchSelect.module.css +30 -0
  20. package/src/CanvasAgentsMenu.jsx +87 -0
  21. package/src/CanvasCreateMenu.jsx +609 -0
  22. package/src/CanvasSnap.css +27 -0
  23. package/src/CanvasSnap.jsx +51 -0
  24. package/src/CanvasUndoRedo.css +36 -0
  25. package/src/CanvasUndoRedo.jsx +62 -0
  26. package/src/CanvasZoomControl.css +53 -0
  27. package/src/CanvasZoomControl.jsx +49 -0
  28. package/src/CanvasZoomToFit.css +18 -0
  29. package/src/CanvasZoomToFit.jsx +26 -0
  30. package/src/CommandMenu.css +8 -0
  31. package/src/CommandMenu.jsx +286 -0
  32. package/src/CommandPalette.jsx +35 -0
  33. package/src/CommandPaletteTrigger.jsx +25 -0
  34. package/src/CommentsMenuButton.jsx +38 -0
  35. package/src/CoreUIBar.css +47 -0
  36. package/src/CoreUIBar.jsx +855 -0
  37. package/src/CreateMenuButton.jsx +116 -0
  38. package/src/HideChromeTrigger.jsx +40 -0
  39. package/src/InspectorPanel.css +109 -0
  40. package/src/InspectorPanel.jsx +629 -0
  41. package/src/PwaInstallBanner.css +42 -0
  42. package/src/PwaInstallBanner.jsx +124 -0
  43. package/src/SidePanel.jsx +260 -0
  44. package/src/ThemeMenuButton.jsx +136 -0
  45. package/src/autosync/server.js +202 -5
  46. package/src/autosync/server.test.js +112 -0
  47. package/src/canvas/__tests__/agent-integration.test.js +593 -0
  48. package/src/canvas/__tests__/helpers/browser.js +95 -0
  49. package/src/canvas/__tests__/helpers/canvas-api.js +129 -0
  50. package/src/canvas/__tests__/helpers/perf.js +118 -0
  51. package/src/canvas/__tests__/helpers/setup.js +176 -0
  52. package/src/canvas/__tests__/helpers/tmux.js +130 -0
  53. package/src/canvas/__tests__/helpers/transcript.js +129 -0
  54. package/src/canvas/__tests__/terminal-integration.test.js +175 -0
  55. package/src/canvas/hot-pool.js +757 -0
  56. package/src/canvas/materializer.js +31 -0
  57. package/src/canvas/materializer.test.js +56 -0
  58. package/src/canvas/selectedWidgets.js +65 -7
  59. package/src/canvas/server.js +1801 -22
  60. package/src/canvas/server.test.js +239 -0
  61. package/src/canvas/terminal-config.js +331 -0
  62. package/src/canvas/terminal-registry.js +38 -0
  63. package/src/canvas/terminal-server.js +1037 -29
  64. package/src/canvas/writeGuard.js +51 -3
  65. package/src/canvasConfig.js +67 -1
  66. package/src/canvasConfig.test.js +79 -1
  67. package/src/cli/agent.js +85 -0
  68. package/src/cli/branch.js +232 -0
  69. package/src/cli/canvasAdd.js +59 -12
  70. package/src/cli/canvasBatch.js +98 -0
  71. package/src/cli/canvasBounds.js +1 -1
  72. package/src/cli/canvasRead.js +1 -1
  73. package/src/cli/canvasUpdate.js +179 -0
  74. package/src/cli/create.js +38 -14
  75. package/src/cli/dev.js +157 -83
  76. package/src/cli/exit.js +23 -24
  77. package/src/cli/index.js +55 -2
  78. package/src/cli/proxy.js +96 -37
  79. package/src/cli/schemas.js +22 -4
  80. package/src/cli/server.js +148 -25
  81. package/src/cli/serverUrl.js +8 -3
  82. package/src/cli/sessions.js +131 -5
  83. package/src/cli/setup.js +109 -11
  84. package/src/cli/terminal-commands.js +16 -8
  85. package/src/cli/terminal-messaging.js +231 -0
  86. package/src/cli/terminal-welcome.js +365 -33
  87. package/src/commandActions.js +1 -0
  88. package/src/commandPaletteConfig.js +9 -0
  89. package/src/comments/auth.js +2 -1
  90. package/src/comments/ui/AuthModal.jsx +114 -0
  91. package/src/comments/ui/CommentWindow.jsx +329 -0
  92. package/src/comments/ui/CommentsDrawer.jsx +102 -0
  93. package/src/comments/ui/Composer.jsx +64 -0
  94. package/src/comments/ui/authModal.test.js +1 -1
  95. package/src/comments/ui/commentWindow.js +16 -17
  96. package/src/comments/ui/commentsDrawer.js +25 -26
  97. package/src/comments/ui/composer.js +23 -24
  98. package/src/comments/ui/index.js +2 -3
  99. package/src/configSchema.js +59 -1
  100. package/src/configStore.js +161 -0
  101. package/src/core-ui-colors.css +12 -0
  102. package/src/devtools.js +17 -19
  103. package/src/devtools.test.js +18 -9
  104. package/src/featureFlags.js +12 -5
  105. package/src/fuzzySearch.test.js +10 -0
  106. package/src/index.js +14 -2
  107. package/src/lib/components/ui/alert/alert-action.jsx +11 -0
  108. package/src/lib/components/ui/alert/alert-description.jsx +11 -0
  109. package/src/lib/components/ui/alert/alert-title.jsx +11 -0
  110. package/src/lib/components/ui/alert/alert.jsx +25 -0
  111. package/src/lib/components/ui/alert/index.js +15 -15
  112. package/src/lib/components/ui/avatar/avatar-badge.jsx +22 -0
  113. package/src/lib/components/ui/avatar/avatar-fallback.jsx +18 -0
  114. package/src/lib/components/ui/avatar/avatar-group-count.jsx +19 -0
  115. package/src/lib/components/ui/avatar/avatar-group.jsx +19 -0
  116. package/src/lib/components/ui/avatar/avatar-image.jsx +15 -0
  117. package/src/lib/components/ui/avatar/avatar.jsx +19 -0
  118. package/src/lib/components/ui/avatar/index.js +20 -20
  119. package/src/lib/components/ui/badge/badge.jsx +31 -0
  120. package/src/lib/components/ui/badge/index.js +2 -2
  121. package/src/lib/components/ui/button/button.jsx +100 -0
  122. package/src/lib/components/ui/button/index.js +9 -9
  123. package/src/lib/components/ui/card/card-action.jsx +11 -0
  124. package/src/lib/components/ui/card/card-content.jsx +11 -0
  125. package/src/lib/components/ui/card/card-description.jsx +11 -0
  126. package/src/lib/components/ui/card/card-footer.jsx +11 -0
  127. package/src/lib/components/ui/card/card-header.jsx +19 -0
  128. package/src/lib/components/ui/card/card-title.jsx +11 -0
  129. package/src/lib/components/ui/card/card.jsx +17 -0
  130. package/src/lib/components/ui/card/index.js +23 -23
  131. package/src/lib/components/ui/checkbox/checkbox.jsx +29 -0
  132. package/src/lib/components/ui/checkbox/index.js +5 -5
  133. package/src/lib/components/ui/collapsible/collapsible-content.jsx +7 -0
  134. package/src/lib/components/ui/collapsible/collapsible-trigger.jsx +7 -0
  135. package/src/lib/components/ui/collapsible/collapsible.jsx +7 -0
  136. package/src/lib/components/ui/collapsible/index.js +11 -11
  137. package/src/lib/components/ui/dialog/dialog-close.jsx +7 -0
  138. package/src/lib/components/ui/dialog/dialog-content.jsx +34 -0
  139. package/src/lib/components/ui/dialog/dialog-description.jsx +15 -0
  140. package/src/lib/components/ui/dialog/dialog-footer.jsx +23 -0
  141. package/src/lib/components/ui/dialog/dialog-header.jsx +11 -0
  142. package/src/lib/components/ui/dialog/dialog-overlay.jsx +15 -0
  143. package/src/lib/components/ui/dialog/dialog-portal.jsx +4 -0
  144. package/src/lib/components/ui/dialog/dialog-title.jsx +15 -0
  145. package/src/lib/components/ui/dialog/dialog-trigger.jsx +7 -0
  146. package/src/lib/components/ui/dialog/dialog.jsx +4 -0
  147. package/src/lib/components/ui/dialog/index.js +32 -32
  148. package/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.jsx +8 -0
  149. package/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.jsx +30 -0
  150. package/src/lib/components/ui/dropdown-menu/dropdown-menu-content.jsx +22 -0
  151. package/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.jsx +16 -0
  152. package/src/lib/components/ui/dropdown-menu/dropdown-menu-group.jsx +7 -0
  153. package/src/lib/components/ui/dropdown-menu/dropdown-menu-item.jsx +20 -0
  154. package/src/lib/components/ui/dropdown-menu/dropdown-menu-label.jsx +17 -0
  155. package/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.jsx +4 -0
  156. package/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.jsx +7 -0
  157. package/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.jsx +29 -0
  158. package/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.jsx +15 -0
  159. package/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.jsx +16 -0
  160. package/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.jsx +15 -0
  161. package/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.jsx +23 -0
  162. package/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.jsx +4 -0
  163. package/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +7 -0
  164. package/src/lib/components/ui/dropdown-menu/dropdown-menu.jsx +4 -0
  165. package/src/lib/components/ui/dropdown-menu/index.js +52 -52
  166. package/src/lib/components/ui/input/index.js +5 -5
  167. package/src/lib/components/ui/input/input.jsx +19 -0
  168. package/src/lib/components/ui/label/index.js +5 -5
  169. package/src/lib/components/ui/label/label.jsx +19 -0
  170. package/src/lib/components/ui/panel/index.js +21 -21
  171. package/src/lib/components/ui/panel/panel-body.jsx +11 -0
  172. package/src/lib/components/ui/panel/panel-close.jsx +16 -0
  173. package/src/lib/components/ui/panel/panel-content.jsx +29 -0
  174. package/src/lib/components/ui/panel/panel-footer.jsx +11 -0
  175. package/src/lib/components/ui/panel/panel-header.jsx +11 -0
  176. package/src/lib/components/ui/panel/panel-title.jsx +12 -0
  177. package/src/lib/components/ui/panel/panel.jsx +4 -0
  178. package/src/lib/components/ui/popover/index.js +26 -26
  179. package/src/lib/components/ui/popover/popover-close.jsx +7 -0
  180. package/src/lib/components/ui/popover/popover-content.jsx +22 -0
  181. package/src/lib/components/ui/popover/popover-description.jsx +11 -0
  182. package/src/lib/components/ui/popover/popover-header.jsx +11 -0
  183. package/src/lib/components/ui/popover/popover-portal.jsx +4 -0
  184. package/src/lib/components/ui/popover/popover-title.jsx +11 -0
  185. package/src/lib/components/ui/popover/popover-trigger.jsx +8 -0
  186. package/src/lib/components/ui/popover/popover.jsx +4 -0
  187. package/src/lib/components/ui/searchable-list.jsx +159 -0
  188. package/src/lib/components/ui/select/index.js +35 -35
  189. package/src/lib/components/ui/select/select-content.jsx +30 -0
  190. package/src/lib/components/ui/select/select-group-heading.jsx +17 -0
  191. package/src/lib/components/ui/select/select-group.jsx +15 -0
  192. package/src/lib/components/ui/select/select-item.jsx +26 -0
  193. package/src/lib/components/ui/select/select-label.jsx +11 -0
  194. package/src/lib/components/ui/select/select-portal.jsx +4 -0
  195. package/src/lib/components/ui/select/select-scroll-down-button.jsx +18 -0
  196. package/src/lib/components/ui/select/select-scroll-up-button.jsx +18 -0
  197. package/src/lib/components/ui/select/select-separator.jsx +15 -0
  198. package/src/lib/components/ui/select/select-trigger.jsx +25 -0
  199. package/src/lib/components/ui/select/select.jsx +4 -0
  200. package/src/lib/components/ui/separator/index.js +5 -5
  201. package/src/lib/components/ui/separator/separator.jsx +22 -0
  202. package/src/lib/components/ui/sheet/index.js +32 -32
  203. package/src/lib/components/ui/sheet/sheet-close.jsx +7 -0
  204. package/src/lib/components/ui/sheet/sheet-content.jsx +35 -0
  205. package/src/lib/components/ui/sheet/sheet-description.jsx +15 -0
  206. package/src/lib/components/ui/sheet/sheet-footer.jsx +11 -0
  207. package/src/lib/components/ui/sheet/sheet-header.jsx +11 -0
  208. package/src/lib/components/ui/sheet/sheet-overlay.jsx +15 -0
  209. package/src/lib/components/ui/sheet/sheet-portal.jsx +4 -0
  210. package/src/lib/components/ui/sheet/sheet-title.jsx +15 -0
  211. package/src/lib/components/ui/sheet/sheet-trigger.jsx +7 -0
  212. package/src/lib/components/ui/sheet/sheet.jsx +4 -0
  213. package/src/lib/components/ui/textarea/index.js +5 -5
  214. package/src/lib/components/ui/textarea/textarea.jsx +18 -0
  215. package/src/lib/components/ui/toggle/index.js +6 -9
  216. package/src/lib/components/ui/toggle/toggle.jsx +36 -0
  217. package/src/lib/components/ui/toggle-group/index.js +8 -8
  218. package/src/lib/components/ui/toggle-group/toggle-group-item.jsx +29 -0
  219. package/src/lib/components/ui/toggle-group/toggle-group.jsx +43 -0
  220. package/src/lib/components/ui/tooltip/index.js +3 -3
  221. package/src/lib/components/ui/tooltip/tooltip-content.jsx +21 -0
  222. package/src/lib/components/ui/tooltip/tooltip-trigger.jsx +23 -0
  223. package/src/lib/components/ui/tooltip/tooltip.jsx +11 -0
  224. package/src/lib/components/ui/trigger-button/index.js +3 -3
  225. package/src/lib/components/ui/trigger-button/trigger-button.css +38 -0
  226. package/src/lib/components/ui/trigger-button/trigger-button.jsx +63 -0
  227. package/src/logger/devLogger.js +238 -0
  228. package/src/logger/devLogger.test.js +193 -0
  229. package/src/modes.test.js +4 -4
  230. package/src/mountStoryboardCore.js +123 -27
  231. package/src/paletteProviders.js +3 -0
  232. package/src/paletteProviders.test.js +2 -2
  233. package/src/server/index.js +98 -36
  234. package/src/sidepanel.css +214 -0
  235. package/src/styles/tailwind.css +1 -1
  236. package/src/svelte-plugin-ui/__tests__/ModeSwitch.test.ts +8 -8
  237. package/src/svelte-plugin-ui/__tests__/ToolbarShell.test.ts +11 -10
  238. package/src/svelte-plugin-ui/components/Icon.css +11 -0
  239. package/src/svelte-plugin-ui/components/Icon.jsx +281 -0
  240. package/src/svelte-plugin-ui/components/ModeSwitch.css +90 -0
  241. package/src/svelte-plugin-ui/components/ModeSwitch.jsx +47 -0
  242. package/src/svelte-plugin-ui/components/ToolbarShell.css +80 -0
  243. package/src/svelte-plugin-ui/components/ToolbarShell.jsx +84 -0
  244. package/src/svelte-plugin-ui/components/Viewfinder.css +412 -0
  245. package/src/svelte-plugin-ui/components/Viewfinder.jsx +512 -0
  246. package/src/svelte-plugin-ui/mount.ts +12 -16
  247. package/src/toolRegistry.js +4 -4
  248. package/src/toolbarConfigStore.js +30 -0
  249. package/src/tools/handlers/autosync.js +1 -1
  250. package/src/tools/handlers/canvasAddWidget.js +1 -1
  251. package/src/tools/handlers/canvasAgents.js +19 -0
  252. package/src/tools/handlers/canvasToolbar.js +8 -8
  253. package/src/tools/handlers/commandPalette.js +9 -0
  254. package/src/tools/handlers/comments.js +1 -1
  255. package/src/tools/handlers/create.js +1 -1
  256. package/src/tools/handlers/devtools.js +16 -0
  257. package/src/tools/handlers/devtools.test.js +38 -0
  258. package/src/tools/handlers/flows.js +1 -1
  259. package/src/tools/handlers/hideChrome.js +9 -0
  260. package/src/tools/handlers/paletteTheme.js +35 -0
  261. package/src/tools/handlers/theme.js +1 -1
  262. package/src/tools/registry.js +4 -1
  263. package/src/tools/surfaces/commandList.js +3 -3
  264. package/src/tools/surfaces/mainToolbar.js +3 -3
  265. package/src/tools/surfaces/registry.js +4 -4
  266. package/src/ui/design-modes.ts +2 -2
  267. package/src/ui/viewfinder.ts +1 -1
  268. package/src/vite/server-plugin.js +242 -60
  269. package/src/workshop/features/createCanvas/CreateCanvasForm.jsx +260 -0
  270. package/src/workshop/features/createCanvas/index.js +1 -1
  271. package/src/workshop/features/createFlow/CreateFlowForm.jsx +334 -0
  272. package/src/workshop/features/createFlow/index.js +1 -1
  273. package/src/workshop/features/createPage/CreatePageForm.jsx +304 -0
  274. package/src/workshop/features/createPage/index.js +1 -1
  275. package/src/workshop/features/createPrototype/CreatePrototypeForm.jsx +289 -0
  276. package/src/workshop/features/createPrototype/index.js +1 -1
  277. package/src/workshop/features/createPrototype/server.js +98 -0
  278. package/src/workshop/features/createStory/CreateStoryForm.jsx +208 -0
  279. package/src/workshop/features/createStory/index.js +1 -1
  280. package/src/workshop/ui/WorkshopPanel.jsx +98 -0
  281. package/src/workshop/ui/mount.ts +1 -1
  282. package/src/worktree/port.js +48 -0
  283. package/src/worktree/serverRegistry.js +120 -0
  284. package/toolbar.config.json +93 -42
  285. package/widgets.config.json +580 -12
  286. package/src/ActionMenuButton.svelte +0 -119
  287. package/src/AutosyncMenuButton.svelte +0 -397
  288. package/src/CanvasCreateMenu.svelte +0 -295
  289. package/src/CanvasSnap.svelte +0 -87
  290. package/src/CanvasUndoRedo.svelte +0 -108
  291. package/src/CanvasZoomControl.svelte +0 -111
  292. package/src/CanvasZoomToFit.svelte +0 -52
  293. package/src/CommandMenu.svelte +0 -249
  294. package/src/CommandPalette.svelte +0 -33
  295. package/src/CommentsMenuButton.svelte +0 -53
  296. package/src/CoreUIBar.svelte +0 -847
  297. package/src/CreateMenuButton.svelte +0 -133
  298. package/src/DocPanel.svelte +0 -299
  299. package/src/InspectorPanel.svelte +0 -745
  300. package/src/PwaInstallBanner.svelte +0 -124
  301. package/src/SidePanel.svelte +0 -480
  302. package/src/ThemeMenuButton.svelte +0 -132
  303. package/src/comments/ui/AuthModal.svelte +0 -108
  304. package/src/comments/ui/CommentWindow.svelte +0 -333
  305. package/src/comments/ui/CommentsDrawer.svelte +0 -96
  306. package/src/comments/ui/Composer.svelte +0 -65
  307. package/src/lib/components/ui/alert/alert-action.svelte +0 -19
  308. package/src/lib/components/ui/alert/alert-description.svelte +0 -22
  309. package/src/lib/components/ui/alert/alert-title.svelte +0 -22
  310. package/src/lib/components/ui/alert/alert.svelte +0 -38
  311. package/src/lib/components/ui/avatar/avatar-badge.svelte +0 -25
  312. package/src/lib/components/ui/avatar/avatar-fallback.svelte +0 -20
  313. package/src/lib/components/ui/avatar/avatar-group-count.svelte +0 -22
  314. package/src/lib/components/ui/avatar/avatar-group.svelte +0 -22
  315. package/src/lib/components/ui/avatar/avatar-image.svelte +0 -17
  316. package/src/lib/components/ui/avatar/avatar.svelte +0 -24
  317. package/src/lib/components/ui/badge/badge.svelte +0 -44
  318. package/src/lib/components/ui/button/button.svelte +0 -108
  319. package/src/lib/components/ui/card/card-action.svelte +0 -21
  320. package/src/lib/components/ui/card/card-content.svelte +0 -19
  321. package/src/lib/components/ui/card/card-description.svelte +0 -19
  322. package/src/lib/components/ui/card/card-footer.svelte +0 -18
  323. package/src/lib/components/ui/card/card-header.svelte +0 -21
  324. package/src/lib/components/ui/card/card-title.svelte +0 -14
  325. package/src/lib/components/ui/card/card.svelte +0 -21
  326. package/src/lib/components/ui/checkbox/checkbox.svelte +0 -39
  327. package/src/lib/components/ui/collapsible/collapsible-content.svelte +0 -7
  328. package/src/lib/components/ui/collapsible/collapsible-trigger.svelte +0 -7
  329. package/src/lib/components/ui/collapsible/collapsible.svelte +0 -11
  330. package/src/lib/components/ui/dialog/dialog-close.svelte +0 -11
  331. package/src/lib/components/ui/dialog/dialog-content.svelte +0 -42
  332. package/src/lib/components/ui/dialog/dialog-description.svelte +0 -17
  333. package/src/lib/components/ui/dialog/dialog-footer.svelte +0 -29
  334. package/src/lib/components/ui/dialog/dialog-header.svelte +0 -19
  335. package/src/lib/components/ui/dialog/dialog-overlay.svelte +0 -17
  336. package/src/lib/components/ui/dialog/dialog-portal.svelte +0 -7
  337. package/src/lib/components/ui/dialog/dialog-title.svelte +0 -17
  338. package/src/lib/components/ui/dialog/dialog-trigger.svelte +0 -11
  339. package/src/lib/components/ui/dialog/dialog.svelte +0 -7
  340. package/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte +0 -16
  341. package/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +0 -40
  342. package/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte +0 -27
  343. package/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +0 -18
  344. package/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte +0 -7
  345. package/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte +0 -24
  346. package/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte +0 -20
  347. package/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte +0 -7
  348. package/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +0 -16
  349. package/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +0 -34
  350. package/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte +0 -17
  351. package/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +0 -19
  352. package/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +0 -17
  353. package/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +0 -27
  354. package/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte +0 -7
  355. package/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +0 -7
  356. package/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte +0 -7
  357. package/src/lib/components/ui/input/input.svelte +0 -40
  358. package/src/lib/components/ui/label/label.svelte +0 -20
  359. package/src/lib/components/ui/panel/panel-body.svelte +0 -13
  360. package/src/lib/components/ui/panel/panel-close.svelte +0 -16
  361. package/src/lib/components/ui/panel/panel-content.svelte +0 -33
  362. package/src/lib/components/ui/panel/panel-footer.svelte +0 -13
  363. package/src/lib/components/ui/panel/panel-header.svelte +0 -16
  364. package/src/lib/components/ui/panel/panel-title.svelte +0 -14
  365. package/src/lib/components/ui/panel/panel.svelte +0 -15
  366. package/src/lib/components/ui/popover/popover-close.svelte +0 -7
  367. package/src/lib/components/ui/popover/popover-content.svelte +0 -27
  368. package/src/lib/components/ui/popover/popover-description.svelte +0 -19
  369. package/src/lib/components/ui/popover/popover-header.svelte +0 -19
  370. package/src/lib/components/ui/popover/popover-portal.svelte +0 -7
  371. package/src/lib/components/ui/popover/popover-title.svelte +0 -19
  372. package/src/lib/components/ui/popover/popover-trigger.svelte +0 -17
  373. package/src/lib/components/ui/popover/popover.svelte +0 -7
  374. package/src/lib/components/ui/select/select-content.svelte +0 -40
  375. package/src/lib/components/ui/select/select-group-heading.svelte +0 -19
  376. package/src/lib/components/ui/select/select-group.svelte +0 -17
  377. package/src/lib/components/ui/select/select-item.svelte +0 -38
  378. package/src/lib/components/ui/select/select-label.svelte +0 -18
  379. package/src/lib/components/ui/select/select-portal.svelte +0 -7
  380. package/src/lib/components/ui/select/select-scroll-down-button.svelte +0 -20
  381. package/src/lib/components/ui/select/select-scroll-up-button.svelte +0 -20
  382. package/src/lib/components/ui/select/select-separator.svelte +0 -17
  383. package/src/lib/components/ui/select/select-trigger.svelte +0 -27
  384. package/src/lib/components/ui/select/select.svelte +0 -11
  385. package/src/lib/components/ui/separator/separator.svelte +0 -23
  386. package/src/lib/components/ui/sheet/sheet-close.svelte +0 -7
  387. package/src/lib/components/ui/sheet/sheet-content.svelte +0 -43
  388. package/src/lib/components/ui/sheet/sheet-description.svelte +0 -17
  389. package/src/lib/components/ui/sheet/sheet-footer.svelte +0 -18
  390. package/src/lib/components/ui/sheet/sheet-header.svelte +0 -19
  391. package/src/lib/components/ui/sheet/sheet-overlay.svelte +0 -17
  392. package/src/lib/components/ui/sheet/sheet-portal.svelte +0 -7
  393. package/src/lib/components/ui/sheet/sheet-title.svelte +0 -17
  394. package/src/lib/components/ui/sheet/sheet-trigger.svelte +0 -7
  395. package/src/lib/components/ui/sheet/sheet.svelte +0 -7
  396. package/src/lib/components/ui/textarea/textarea.svelte +0 -21
  397. package/src/lib/components/ui/toggle/toggle.svelte +0 -45
  398. package/src/lib/components/ui/toggle-group/toggle-group-item.svelte +0 -35
  399. package/src/lib/components/ui/toggle-group/toggle-group.svelte +0 -63
  400. package/src/lib/components/ui/tooltip/tooltip-content.svelte +0 -24
  401. package/src/lib/components/ui/tooltip/tooltip-trigger.svelte +0 -27
  402. package/src/lib/components/ui/tooltip/tooltip.svelte +0 -9
  403. package/src/lib/components/ui/trigger-button/trigger-button.svelte +0 -106
  404. package/src/svelte-plugin-ui/components/Icon.svelte +0 -181
  405. package/src/svelte-plugin-ui/components/ModeSwitch.svelte +0 -121
  406. package/src/svelte-plugin-ui/components/ToolbarShell.svelte +0 -150
  407. package/src/svelte-plugin-ui/components/Viewfinder.svelte +0 -1001
  408. package/src/tools/handlers/docs.js +0 -11
  409. package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +0 -139
  410. package/src/workshop/features/createFlow/CreateFlowForm.svelte +0 -314
  411. package/src/workshop/features/createPage/CreatePageForm.svelte +0 -249
  412. package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +0 -287
  413. package/src/workshop/features/createStory/CreateStoryForm.svelte +0 -161
  414. package/src/workshop/ui/WorkshopPanel.svelte +0 -97
@@ -17,9 +17,11 @@
17
17
  * GET /page-order — read page order for a folder
18
18
  * PUT /update-folder-meta — update folder .meta.json title
19
19
  * POST /widget — append a widget_added event
20
+ * PATCH /widget — update a single widget's props/position
20
21
  * DELETE /widget — append a widget_removed event
21
22
  * POST /connector — append a connector_added event
22
23
  * DELETE /connector — append a connector_removed event
24
+ * POST /batch — execute multiple operations in one request (refs, single HMR push)
23
25
  * POST /create — create a new .canvas.jsonl file
24
26
  * GET /stories — list all .story.{jsx,tsx} files with exports
25
27
  * POST /create-story — scaffold a new .story.{jsx,tsx} file
@@ -27,7 +29,9 @@
27
29
  * POST /github/embed — fetch GitHub issue/discussion/PR/comment metadata via gh
28
30
  * POST /image — upload a pasted image to src/canvas/images/
29
31
  * GET /images/* — serve an image file from src/canvas/images/
30
- * POST /image/toggle-private — toggle _prefix on image filename
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
31
35
  */
32
36
 
33
37
  import fs from 'node:fs'
@@ -42,7 +46,43 @@ import {
42
46
  isGhCliAvailable,
43
47
  isGitHubEmbedUrl,
44
48
  } from './githubEmbeds.js'
45
- import { stampBounds, stampBoundsAll } from './collision.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
+ }
46
86
 
47
87
  /**
48
88
  * Scan src/canvas/ for directories containing .meta.json files.
@@ -209,14 +249,261 @@ function generateWidgetId(type) {
209
249
  * Create the canvas API route handler.
210
250
  */
211
251
  export function createCanvasHandler(ctx) {
212
- const { root, sendJson } = 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 {}
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
+ }
213
414
 
214
415
  // Append an event to an existing canvas file.
215
- // The data plugin already skips .canvas.jsonl `change` events to avoid
216
- // a save reload lost-editing-state feedback loop, so we just write
217
- // directly without touching the watcher.
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).
218
419
  function appendEvent(filePath, event) {
420
+ markCanvasWrite(filePath)
219
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 {}
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 */ }
220
507
  }
221
508
 
222
509
  // Write a new JSONL file with a single creation event.
@@ -226,7 +513,7 @@ export function createCanvasHandler(ctx) {
226
513
  fs.writeFileSync(filePath, serializeEvent(event) + '\n', 'utf-8')
227
514
  }
228
515
 
229
- return async (req, res, { body, path: routePath, method }) => {
516
+ return async (req, res, { body, path: routePath, method, __viteWs }) => {
230
517
  // GET /folders — list available canvas folders
231
518
  if (routePath === '/folders' && method === 'GET') {
232
519
  const canvasDir = path.join(root, 'src', 'canvas')
@@ -336,7 +623,7 @@ export function createCanvasHandler(ctx) {
336
623
 
337
624
  // PUT /update — append update events to the canvas stream
338
625
  if (routePath === '/update' && method === 'PUT') {
339
- const { name, widgets, sources, settings } = body
626
+ const { name, widgets, sources, settings, connectors } = body
340
627
 
341
628
  if (!name) {
342
629
  sendJson(res, 400, { error: 'Canvas name is required' })
@@ -353,6 +640,21 @@ export function createCanvasHandler(ctx) {
353
640
  const ts = new Date().toISOString()
354
641
 
355
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
+ }
356
658
  const stamped = stampBoundsAll(widgets)
357
659
  appendEvent(filePath, { event: 'widgets_replaced', timestamp: ts, widgets: stamped })
358
660
  }
@@ -361,6 +663,10 @@ export function createCanvasHandler(ctx) {
361
663
  appendEvent(filePath, { event: 'source_updated', timestamp: ts, sources })
362
664
  }
363
665
 
666
+ if (connectors) {
667
+ appendEvent(filePath, { event: 'connectors_replaced', timestamp: ts, connectors })
668
+ }
669
+
364
670
  if (settings) {
365
671
  const filtered = {}
366
672
  for (const [key, value] of Object.entries(settings)) {
@@ -374,6 +680,7 @@ export function createCanvasHandler(ctx) {
374
680
  }
375
681
 
376
682
  sendJson(res, 200, { success: true, name })
683
+ pushCanvasUpdate(name, filePath, __viteWs)
377
684
  } catch (err) {
378
685
  sendJson(res, 500, { error: `Failed to update canvas: ${err.message}` })
379
686
  }
@@ -382,7 +689,14 @@ export function createCanvasHandler(ctx) {
382
689
 
383
690
  // POST /widget — append a widget_added event
384
691
  if (routePath === '/widget' && method === 'POST') {
385
- const { name, type, props = {}, position = { x: 0, y: 0 } } = body
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
386
700
 
387
701
  if (!name) {
388
702
  sendJson(res, 400, { error: 'Canvas name is required' })
@@ -400,14 +714,51 @@ export function createCanvasHandler(ctx) {
400
714
  }
401
715
 
402
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
+
403
749
  const widgetId = generateWidgetId(type)
404
750
 
405
- // Auto-assign a pretty name for terminal widgets
406
- if (type === 'terminal' && !props.prettyName) {
407
- try {
408
- const { generateFriendlyName } = await import('./terminal-registry.js')
409
- props.prettyName = generateFriendlyName()
410
- } catch { /* registry not initialized yet — will get a name on session connect */ }
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
+ }
411
762
  }
412
763
 
413
764
  const widget = stampBounds({ id: widgetId, type, position, props })
@@ -418,7 +769,10 @@ export function createCanvasHandler(ctx) {
418
769
  widget,
419
770
  })
420
771
 
421
- sendJson(res, 201, { success: true, widget })
772
+ const response = { success: true, widget }
773
+ if (hotSession) response.hotSession = { id: hotSession.id, tmuxName: hotSession.tmuxName || null }
774
+ sendJson(res, 201, response)
775
+ pushCanvasUpdate(name, filePath, __viteWs)
422
776
  } catch (err) {
423
777
  sendJson(res, 500, { error: `Failed to add widget: ${err.message}` })
424
778
  }
@@ -456,22 +810,86 @@ export function createCanvasHandler(ctx) {
456
810
  })
457
811
 
458
812
  // Orphan terminal session when a terminal widget is deleted (not killed)
459
- if (widget.type === 'terminal') {
813
+ if (widget.type === 'terminal' || widget.type === 'agent') {
460
814
  try {
461
815
  const { orphanTerminalSession } = await import('./terminal-server.js')
462
816
  orphanTerminalSession(widgetId)
463
817
  } catch (err) {
464
- console.warn(`[storyboard] Failed to orphan terminal session for ${widgetId}:`, err.message)
818
+ devLog().logEvent('warn', `Failed to orphan terminal session for ${widgetId}`, { widgetId, error: err.message })
465
819
  }
466
820
  }
467
821
 
468
822
  sendJson(res, 200, { success: true, removed: 1 })
823
+ pushCanvasUpdate(name, filePath, __viteWs)
469
824
  } catch (err) {
470
825
  sendJson(res, 500, { error: `Failed to remove widget: ${err.message}` })
471
826
  }
472
827
  return
473
828
  }
474
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
+
475
893
  // POST /connector — append a connector_added event
476
894
  if (routePath === '/connector' && method === 'POST') {
477
895
  const { name, startWidgetId, startAnchor, endWidgetId, endAnchor, connectorType = 'default' } = body
@@ -529,12 +947,115 @@ export function createCanvasHandler(ctx) {
529
947
  })
530
948
 
531
949
  sendJson(res, 201, { success: true, connector })
950
+ pushCanvasUpdate(name, filePath, __viteWs)
532
951
  } catch (err) {
533
952
  sendJson(res, 500, { error: `Failed to add connector: ${err.message}` })
534
953
  }
535
954
  return
536
955
  }
537
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
+
538
1059
  // DELETE /connector — append a connector_removed event
539
1060
  if (routePath === '/connector' && method === 'DELETE') {
540
1061
  const { name, connectorId } = body
@@ -565,12 +1086,454 @@ export function createCanvasHandler(ctx) {
565
1086
  })
566
1087
 
567
1088
  sendJson(res, 200, { success: true, removed: 1 })
1089
+ pushCanvasUpdate(name, filePath, __viteWs)
568
1090
  } catch (err) {
569
1091
  sendJson(res, 500, { error: `Failed to remove connector: ${err.message}` })
570
1092
  }
571
1093
  return
572
1094
  }
573
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 }
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
+
574
1537
  // PUT /rename-page — rename a canvas page file
575
1538
  if (routePath === '/rename-page' && method === 'PUT') {
576
1539
  const { name, newTitle } = body
@@ -1258,15 +2221,25 @@ export function Default() {
1258
2221
  const now = new Date()
1259
2222
  const pad = (n) => String(n).padStart(2, '0')
1260
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)}`
1261
2225
  const prefix = canvasName ? `${canvasName.replace(/[/:]/g, '--')}--` : ''
1262
2226
 
1263
2227
  // Support explicit filename for snapshot uploads (stable naming)
2228
+ // and cropped image uploads (user-initiated crop)
1264
2229
  const explicitName = body.filename
1265
2230
  let filename
1266
2231
  if (explicitName && /^snapshot-[a-z0-9_-]+--(latest|light|dark)\.webp$/i.test(explicitName)) {
1267
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
+ }
1268
2241
  } else {
1269
- filename = `${prefix}${dateStr}.${ext}`
2242
+ filename = `${prefix}${dateStr}${suffix}.${ext}`
1270
2243
  }
1271
2244
  const targetDir = resolveWriteDir(canvasName || '')
1272
2245
 
@@ -1316,7 +2289,48 @@ export function Default() {
1316
2289
  return
1317
2290
  }
1318
2291
 
1319
- // POST /image/toggle-privatetoggle underscore prefix on image filename
2292
+ // POST /image/duplicatecopy 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
1320
2334
  if (routePath === '/image/toggle-private' && method === 'POST') {
1321
2335
  const { filename } = body
1322
2336
 
@@ -1330,8 +2344,8 @@ export function Default() {
1330
2344
  return
1331
2345
  }
1332
2346
 
1333
- const isPrivate = filename.startsWith('_')
1334
- const newFilename = isPrivate ? filename.slice(1) : `_${filename}`
2347
+ const isPrivate = filename.startsWith('~')
2348
+ const newFilename = isPrivate ? filename.slice(1) : `~${filename}`
1335
2349
  const oldPath = resolveImagePath(filename)
1336
2350
  if (!oldPath) {
1337
2351
  sendJson(res, 404, { error: 'Image not found' })
@@ -1349,6 +2363,771 @@ export function Default() {
1349
2363
  return
1350
2364
  }
1351
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 {}
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 = 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 {}
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 {}
2550
+ }, 500)
2551
+ }
2552
+ } catch {}
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 {}
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 {}
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 {}
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 {}
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 {}
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 {}
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 {}
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
+ let branch = 'unknown'
2950
+ try {
2951
+ const { execSync } = await import('node:child_process')
2952
+ branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
2953
+ } catch {}
2954
+
2955
+ try {
2956
+ updateAgentStatus({ branch, canvasId: 'unknown', widgetId: targetWidgetId, status: 'cancelled', message: 'Cancelled by user' })
2957
+ } catch {}
2958
+
2959
+ // Notify via HMR
2960
+ if (__viteWs) {
2961
+ __viteWs.send({
2962
+ type: 'custom',
2963
+ event: 'storyboard:agent-status',
2964
+ data: { widgetId: targetWidgetId, status: 'cancelled', message: 'Cancelled by user', timestamp: new Date().toISOString() },
2965
+ })
2966
+ }
2967
+
2968
+ sendJson(res, 200, { success: true, killed: tmuxName })
2969
+ } catch (err) {
2970
+ sendJson(res, 500, { error: `Failed to kill session: ${err.message}` })
2971
+ }
2972
+ return
2973
+ }
2974
+
2975
+ // GET /terminal-buffer/:widgetId — read terminal buffer JSON
2976
+ // Accepts optional ?length=N query param to truncate scrollback
2977
+ if (routePath.startsWith('/terminal-buffer/') && method === 'GET') {
2978
+ const widgetId = routePath.slice('/terminal-buffer/'.length).split('?')[0]
2979
+ if (!widgetId || widgetId.includes('..') || widgetId.includes('/')) {
2980
+ sendJson(res, 400, { error: 'Invalid widgetId' })
2981
+ return
2982
+ }
2983
+
2984
+ const urlObj = new URL(req.url, 'http://localhost')
2985
+ const lengthParam = urlObj.searchParams.get('length')
2986
+ const maxLength = lengthParam ? parseInt(lengthParam, 10) : undefined
2987
+
2988
+ try {
2989
+ const { readTerminalBuffer } = await import('./terminal-server.js')
2990
+ const buffer = readTerminalBuffer(widgetId, { maxLength: maxLength || undefined })
2991
+ if (buffer) {
2992
+ sendJson(res, 200, buffer)
2993
+ return
2994
+ }
2995
+ sendJson(res, 404, { error: 'Buffer not found' })
2996
+ } catch (err) {
2997
+ sendJson(res, 500, { error: `Failed to read buffer: ${err.message}` })
2998
+ }
2999
+ return
3000
+ }
3001
+
3002
+ // GET /terminal-snapshot/:widgetId — read terminal snapshot JSON (new + legacy fallback)
3003
+ if (routePath.startsWith('/terminal-snapshot/') && method === 'GET') {
3004
+ const widgetId = routePath.slice('/terminal-snapshot/'.length)
3005
+ if (!widgetId || widgetId.includes('..') || widgetId.includes('/')) {
3006
+ sendJson(res, 400, { error: 'Invalid widgetId' })
3007
+ return
3008
+ }
3009
+
3010
+ try {
3011
+ const { readTerminalSnapshot } = await import('./terminal-server.js')
3012
+
3013
+ // Try new path first
3014
+ const snapshot = readTerminalSnapshot(widgetId)
3015
+ if (snapshot) {
3016
+ sendJson(res, 200, snapshot)
3017
+ return
3018
+ }
3019
+
3020
+ // Legacy fallback: .storyboard/terminal-snapshots/<canvasDir>/<widgetId>.json
3021
+ const snapshotsRoot = path.join(root, '.storyboard', 'terminal-snapshots')
3022
+ if (fs.existsSync(snapshotsRoot)) {
3023
+ const dirs = fs.readdirSync(snapshotsRoot, { withFileTypes: true })
3024
+ for (const d of dirs) {
3025
+ if (!d.isDirectory()) continue
3026
+ const filePath = path.join(snapshotsRoot, d.name, `${widgetId}.json`)
3027
+ if (fs.existsSync(filePath)) {
3028
+ const data = fs.readFileSync(filePath, 'utf8')
3029
+ res.writeHead(200, { 'Content-Type': 'application/json' })
3030
+ res.end(data)
3031
+ return
3032
+ }
3033
+ }
3034
+ }
3035
+ sendJson(res, 404, { error: 'Snapshot not found' })
3036
+ } catch (err) {
3037
+ sendJson(res, 500, { error: `Failed to read snapshot: ${err.message}` })
3038
+ }
3039
+ return
3040
+ }
3041
+
3042
+ // DELETE /delete-canvas — delete a canvas and its directory
3043
+ if (routePath === '/delete-canvas' && method === 'DELETE') {
3044
+ const { name } = body
3045
+ if (!name || typeof name !== 'string') {
3046
+ sendJson(res, 400, { error: 'Canvas name is required' })
3047
+ return
3048
+ }
3049
+
3050
+ const filePath = findCanvasPath(root, name)
3051
+ if (!filePath) {
3052
+ sendJson(res, 404, { error: `Canvas "${name}" not found` })
3053
+ return
3054
+ }
3055
+
3056
+ try {
3057
+ const dir = path.dirname(filePath)
3058
+ const canvasDir = path.join(root, 'src', 'canvas')
3059
+
3060
+ // Delete the canvas file
3061
+ fs.unlinkSync(filePath)
3062
+
3063
+ // If the parent directory is inside src/canvas/ and now empty (or only has .meta.json), remove it
3064
+ if (dir !== canvasDir) {
3065
+ const remaining = fs.readdirSync(dir).filter(f => !f.endsWith('.meta.json'))
3066
+ if (remaining.length === 0) {
3067
+ for (const f of fs.readdirSync(dir)) {
3068
+ fs.unlinkSync(path.join(dir, f))
3069
+ }
3070
+ fs.rmdirSync(dir)
3071
+ }
3072
+ }
3073
+
3074
+ sendJson(res, 200, { success: true, deleted: name })
3075
+ } catch (err) {
3076
+ sendJson(res, 500, { error: `Failed to delete canvas: ${err.message}` })
3077
+ }
3078
+ return
3079
+ }
3080
+
3081
+ // PUT /update-meta — update canvas metadata
3082
+ if (routePath === '/update-meta' && method === 'PUT') {
3083
+ const { name, title, description, author } = body
3084
+ if (!name || typeof name !== 'string') {
3085
+ sendJson(res, 400, { error: 'Canvas name is required' })
3086
+ return
3087
+ }
3088
+
3089
+ const filePath = findCanvasPath(root, name)
3090
+ if (!filePath) {
3091
+ sendJson(res, 404, { error: `Canvas "${name}" not found` })
3092
+ return
3093
+ }
3094
+
3095
+ try {
3096
+ // Try to find and update .meta.json first
3097
+ const dir = path.dirname(filePath)
3098
+ const dirName = path.basename(dir).replace(/\.folder$/, '')
3099
+ const metaPath = path.join(dir, `${dirName}.meta.json`)
3100
+
3101
+ if (fs.existsSync(metaPath)) {
3102
+ const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'))
3103
+ if (title !== undefined) meta.title = title
3104
+ if (description !== undefined) meta.description = description
3105
+ if (author !== undefined) meta.author = author
3106
+ fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf-8')
3107
+ } else {
3108
+ // Update the canvas JSONL's canvas_created event metadata
3109
+ const text = fs.readFileSync(filePath, 'utf-8')
3110
+ const lines = text.split('\n').filter(Boolean)
3111
+ if (lines.length > 0) {
3112
+ const firstEvent = JSON.parse(lines[0])
3113
+ if (title !== undefined) firstEvent.title = title
3114
+ if (description !== undefined) firstEvent.description = description
3115
+ if (author !== undefined) firstEvent.author = author
3116
+ lines[0] = JSON.stringify(firstEvent)
3117
+ fs.writeFileSync(filePath, lines.join('\n') + '\n', 'utf-8')
3118
+ }
3119
+ }
3120
+
3121
+ // Notify via WebSocket
3122
+ pushCanvasUpdate(name, filePath, __viteWs)
3123
+
3124
+ sendJson(res, 200, { success: true, updated: name })
3125
+ } catch (err) {
3126
+ sendJson(res, 500, { error: `Failed to update canvas metadata: ${err.message}` })
3127
+ }
3128
+ return
3129
+ }
3130
+
1352
3131
  sendJson(res, 404, { error: `Unknown route: ${method} ${routePath}` })
1353
3132
  }
1354
3133
  }