@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
@@ -23,9 +23,10 @@
23
23
  */
24
24
 
25
25
  import { execSync } from 'node:child_process'
26
- import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'
27
- import { resolve, join } from 'node:path'
26
+ import { readFileSync, mkdirSync, writeFileSync, renameSync, existsSync, unlinkSync } from 'node:fs'
27
+ import { resolve, join, dirname } from 'node:path'
28
28
  import { tmpdir } from 'node:os'
29
+ import { devLog } from '../logger/devLogger.js'
29
30
 
30
31
  let WebSocketServer
31
32
  try {
@@ -38,11 +39,20 @@ import {
38
39
  registerSession,
39
40
  disconnectSession,
40
41
  orphanSession,
41
- getSession,
42
42
  generateTmuxName,
43
43
  findTmuxNameForWidget,
44
44
  killSession,
45
+ bulkCleanup,
46
+ getSessionStats,
45
47
  } from './terminal-registry.js'
48
+ import {
49
+ writeTerminalConfig as writeTermConfig,
50
+ initTerminalConfig,
51
+ readTerminalConfigById,
52
+ updatePendingMessages,
53
+ } from './terminal-config.js'
54
+ import { findByWorktree } from '../worktree/serverRegistry.js'
55
+ import { detectWorktreeName } from '../worktree/port.js'
46
56
 
47
57
  let pty
48
58
  try {
@@ -62,6 +72,50 @@ try {
62
72
 
63
73
  const TERMINAL_PATH_PREFIX = '/_storyboard/terminal/'
64
74
 
75
+ /**
76
+ * Env var prefixes/names from external terminal emulators and shell configs
77
+ * that must be stripped before spawning tmux or shell processes — they leak
78
+ * custom theming, prompts, and shell integrations into the storyboard terminal.
79
+ */
80
+ const SHELL_CONFIG_STRIP_RE = /^(ZDOTDIR|STARSHIP(_.*)?|GHOSTTY(_.*)?|POWERLEVEL.*|P9K_.*|P10K_.*|ZSH_THEME|BASH_ENV|ITERM(_.*)?|KITTY(_.*)?|ALACRITTY(_.*)?|WEZTERM(_.*)?|PROMPT_COMMAND|RPROMPT|RPS1)$/
81
+
82
+ function isShellConfigVar(key) {
83
+ return SHELL_CONFIG_STRIP_RE.test(key) || key === 'ENV'
84
+ }
85
+
86
+ /**
87
+ * Overrides injected into tmux global env to neutralize external shell themes.
88
+ * Applied after the tmux server is guaranteed to exist.
89
+ */
90
+ const TMUX_SHELL_OVERRIDES = {
91
+ STARSHIP_CONFIG: '/dev/null',
92
+ POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD: 'true',
93
+ ZSH_THEME: '',
94
+ TERM_PROGRAM: 'storyboard',
95
+ }
96
+
97
+ /** Apply shell-config overrides to the tmux server's global environment */
98
+ function applyTmuxShellOverrides() {
99
+ for (const [key, val] of Object.entries(TMUX_SHELL_OVERRIDES)) {
100
+ try { execSync(`tmux set-environment -g ${key} "${val}" 2>/dev/null`, { stdio: 'ignore' }) } catch {}
101
+ }
102
+ // Unset vars that should not exist at all inside storyboard terminals
103
+ for (const key of Object.keys(process.env)) {
104
+ if (isShellConfigVar(key) && !(key in TMUX_SHELL_OVERRIDES)) {
105
+ try { execSync(`tmux set-environment -g -u ${key} 2>/dev/null`, { stdio: 'ignore' }) } catch {}
106
+ }
107
+ }
108
+ }
109
+
110
+ /** Filter process.env, removing shell-config vars that would leak into PTY */
111
+ function cleanEnv() {
112
+ const filtered = {}
113
+ for (const [k, v] of Object.entries(process.env)) {
114
+ if (!isShellConfigVar(k)) filtered[k] = v
115
+ }
116
+ return filtered
117
+ }
118
+
65
119
  /** Read terminal config from storyboard.config.json */
66
120
  function readTerminalConfig() {
67
121
  try {
@@ -82,6 +136,299 @@ const wsConnections = new Map()
82
136
  /** Branch name for this worktree, set during setup */
83
137
  let currentBranch = 'unknown'
84
138
 
139
+ /** Actual server port, resolved from httpServer at setup time */
140
+ let actualServerPort = null
141
+
142
+ /** Hot pool manager reference (set by setupTerminalServer) */
143
+ let hotPoolRef = null
144
+
145
+ // ── PTY exhaustion detection & recovery ──
146
+
147
+ const PTY_ERROR_PATTERNS = [
148
+ /ENXIO/, /posix_openpt/, /Device not configured/,
149
+ /no available pty/i, /too many pty/i, /out of pty/i,
150
+ ]
151
+
152
+ function isPtyExhausted(err) {
153
+ const msg = err?.message || ''
154
+ return PTY_ERROR_PATTERNS.some(p => p.test(msg))
155
+ }
156
+
157
+ /**
158
+ * Spawn a PTY process with automatic cleanup on PTY exhaustion.
159
+ * On failure: kills archived sessions → retries → kills background → retries → throws.
160
+ * If all cleanup attempts fail, throws an error with `err.resourceLimited = true`
161
+ * and `err.stats` containing session counts.
162
+ */
163
+ function spawnWithCleanup(command, args, opts) {
164
+ try {
165
+ return pty.spawn(command, args, opts)
166
+ } catch (err) {
167
+ if (!isPtyExhausted(err)) throw err
168
+
169
+ devLog().logEvent('warn', 'PTY exhaustion detected, attempting cleanup', { error: err.message })
170
+
171
+ // Wave 1: clean archived sessions
172
+ const wave1 = bulkCleanup({ statuses: ['archived'] })
173
+ if (wave1.removed > 0) {
174
+ devLog().logEvent('info', `Cleaned ${wave1.removed} archived sessions, retrying spawn`)
175
+ try { return pty.spawn(command, args, opts) } catch (e) {
176
+ if (!isPtyExhausted(e)) throw e
177
+ }
178
+ }
179
+
180
+ // Wave 2: clean background sessions
181
+ const wave2 = bulkCleanup({ statuses: ['background'] })
182
+ if (wave2.removed > 0) {
183
+ devLog().logEvent('info', `Cleaned ${wave2.removed} background sessions, retrying spawn`)
184
+ try { return pty.spawn(command, args, opts) } catch (e) {
185
+ if (!isPtyExhausted(e)) throw e
186
+ }
187
+ }
188
+
189
+ // All cleanup exhausted — throw with resource-limited metadata
190
+ const resourceErr = new Error('No PTY devices available — all cleanup attempts exhausted')
191
+ resourceErr.resourceLimited = true
192
+ resourceErr.stats = getSessionStats()
193
+ throw resourceErr
194
+ }
195
+ }
196
+
197
+ /** Active snapshot intervals keyed by tmuxName */
198
+ const snapshotIntervals = new Map()
199
+
200
+ /**
201
+ * Time-windowed rolling buffer — accumulates raw PTY output with timestamps
202
+ * so we can trim by age (5 min for private buffer, 1 min for public snapshot).
203
+ * Each entry is { ts: number, data: string }.
204
+ */
205
+ const rollingBuffers = new Map()
206
+
207
+ /** Max buffer age in ms (5 minutes for private buffer) */
208
+ const BUFFER_MAX_AGE_MS = 5 * 60 * 1000
209
+
210
+ /** Max snapshot age in ms (1 minute for public snapshot) */
211
+ const SNAPSHOT_MAX_AGE_MS = 1 * 60 * 1000
212
+
213
+ /** Append PTY output to the rolling buffer for a session */
214
+ function appendToRollingBuffer(tmuxName, data) {
215
+ let entries = rollingBuffers.get(tmuxName)
216
+ if (!entries) {
217
+ entries = []
218
+ rollingBuffers.set(tmuxName, entries)
219
+ }
220
+ entries.push({ ts: Date.now(), data })
221
+ // Eagerly trim entries older than the max (buffer cap = 5 min)
222
+ const cutoff = Date.now() - BUFFER_MAX_AGE_MS
223
+ while (entries.length > 0 && entries[0].ts < cutoff) {
224
+ entries.shift()
225
+ }
226
+ }
227
+
228
+ /** Get concatenated buffer content within a time window */
229
+ function getRollingBufferContent(tmuxName, maxAgeMs = BUFFER_MAX_AGE_MS) {
230
+ const entries = rollingBuffers.get(tmuxName)
231
+ if (!entries || entries.length === 0) return ''
232
+ const cutoff = Date.now() - maxAgeMs
233
+ return entries
234
+ .filter((e) => e.ts >= cutoff)
235
+ .map((e) => e.data)
236
+ .join('')
237
+ }
238
+
239
+ /** Strip ANSI escape sequences from a string */
240
+ function stripAnsi(str) {
241
+ // eslint-disable-next-line no-control-regex
242
+ return str.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?(\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[>=<]|\x1b\[[\?]?[0-9;]*[hlsur]/g, '')
243
+ }
244
+
245
+ /**
246
+ * Inject a [System] identity message into a running agent's stdin via tmux send-keys.
247
+ * Called from BOTH hot and cold paths after the tmux session is bound and config is written.
248
+ * Uses the same pattern as messaging (📩) and skill injection (📡).
249
+ *
250
+ * Only injected for agent/prompt widgets — bare terminals skip this to avoid
251
+ * cluttering the shell with system messages a human would see.
252
+ */
253
+ function injectIdentityMessage(tmuxName, { widgetId, displayName, canvasId, branch, serverUrl }) {
254
+ if (!hasTmux) return
255
+ const configFile = `.storyboard/terminals/${widgetId}.json`
256
+ const msg = `[System] Your terminal identity has been set. widgetId=${widgetId} displayName=${displayName} canvasId=${canvasId} configFile=${configFile} serverUrl=${serverUrl} — this is a configuration step, no response needed.`
257
+ try {
258
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(msg)}`, { stdio: 'ignore' })
259
+ execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
260
+ } catch { /* best effort */ }
261
+ }
262
+
263
+ /** Safe directory name from canvasId (replace `/` with `--`) */
264
+ function safeCanvasDir(canvasId) {
265
+ return canvasId.replace(/\//g, '--')
266
+ }
267
+
268
+ /** Snapshot directory for a canvas (legacy — kept for fallback reads) */
269
+ function legacySnapshotDir(canvasId) {
270
+ return join(process.cwd(), '.storyboard', 'terminal-snapshots', safeCanvasDir(canvasId))
271
+ }
272
+
273
+ /** Private buffer directory — .storyboard/ (gitignored) */
274
+ function bufferDir() {
275
+ return join(process.cwd(), '.storyboard', 'terminal-buffers')
276
+ }
277
+
278
+ /** Public snapshot directory — assets/.storyboard-public/terminal-snapshots/ (committed) */
279
+ function publicSnapshotDir() {
280
+ return join(process.cwd(), 'assets', '.storyboard-public', 'terminal-snapshots')
281
+ }
282
+
283
+ /**
284
+ * Read the `private` prop for a widget from the terminal config.
285
+ * Returns true if the widget has props.private === true.
286
+ */
287
+ function isWidgetPrivate(widgetId, canvasId) {
288
+ try {
289
+ const config = readTerminalConfigById(widgetId)
290
+ if (config?.widgetProps?.private) return true
291
+ } catch {}
292
+ return false
293
+ }
294
+
295
+ /**
296
+ * Capture terminal content and write both buffer + snapshot files.
297
+ *
298
+ * Buffer (private): .storyboard/terminal-buffers/<widgetId>.buffer.json — 5-min scrollback, full metadata
299
+ * Snapshot (public): assets/.storyboard-public/terminal-snapshots/<widgetId>.snapshot.json — 1-min scrollback, stripped ANSI
300
+ *
301
+ * When widget is private, the public snapshot is skipped and any existing
302
+ * snapshot file is renamed to ~<filename> (tilde prefix = gitignored).
303
+ */
304
+ function captureSnapshot({ tmuxName, widgetId, canvasId, prettyName, cols, rows, createdAt }) {
305
+ let paneContent = ''
306
+ try {
307
+ paneContent = execSync(`tmux capture-pane -t "${tmuxName}" -p -e`, {
308
+ encoding: 'utf8',
309
+ timeout: 3000,
310
+ })
311
+ } catch {
312
+ // tmux capture failed — rolling buffer is the only source
313
+ }
314
+
315
+ const now = new Date().toISOString()
316
+ const rawTail = getRollingBufferContent(tmuxName, BUFFER_MAX_AGE_MS)
317
+
318
+ // ── Private buffer (.storyboard/terminal-buffers/<widgetId>.buffer.json) ──
319
+ const bDir = bufferDir()
320
+ const bufferPath = join(bDir, `${widgetId}.buffer.json`)
321
+ const bufferTmpPath = bufferPath + '.tmp'
322
+
323
+ const bufferData = {
324
+ widgetId,
325
+ canvasId,
326
+ tmuxName,
327
+ prettyName: prettyName || null,
328
+ createdAt: createdAt || now,
329
+ timestamp: now,
330
+ cols: cols || 80,
331
+ rows: rows || 24,
332
+ paneContent,
333
+ scrollback: rawTail,
334
+ }
335
+
336
+ try {
337
+ mkdirSync(bDir, { recursive: true })
338
+ writeFileSync(bufferTmpPath, JSON.stringify(bufferData, null, 2), 'utf8')
339
+ renameSync(bufferTmpPath, bufferPath)
340
+ } catch {
341
+ try { if (existsSync(bufferTmpPath)) unlinkSync(bufferTmpPath) } catch {}
342
+ }
343
+
344
+ // ── Plain-text buffer (.storyboard/terminal-buffers/<widgetId>.buffer.txt) ──
345
+ // Agent-readable raw text: screen first, then scrollback history.
346
+ const txtPath = join(bDir, `${widgetId}.buffer.txt`)
347
+ const txtTmpPath = txtPath + '.tmp'
348
+ try {
349
+ const screen = stripAnsi(paneContent).replace(/\r\n/g, '\n').replace(/\n+$/, '')
350
+ const history = stripAnsi(rawTail).replace(/\r\n/g, '\n').replace(/\n+$/, '')
351
+
352
+ let txt = `[${widgetId}${prettyName ? ' | ' + prettyName : ''} | ${now}]\n\n`
353
+ txt += '--- screen ---\n'
354
+ txt += (screen || '(empty)') + '\n'
355
+ if (history) {
356
+ txt += '\n--- scrollback ---\n'
357
+ txt += history + '\n'
358
+ }
359
+
360
+ writeFileSync(txtTmpPath, txt, 'utf8')
361
+ renameSync(txtTmpPath, txtPath)
362
+ } catch {
363
+ try { if (existsSync(txtTmpPath)) unlinkSync(txtTmpPath) } catch {}
364
+ }
365
+
366
+ // ── Public snapshot (assets/.storyboard-public/terminal-snapshots/<widgetId>.snapshot.json) ──
367
+ const isPrivate = isWidgetPrivate(widgetId, canvasId)
368
+ const sDir = publicSnapshotDir()
369
+ const snapshotPath = join(sDir, `${widgetId}.snapshot.json`)
370
+ const tildeSnapshotPath = join(sDir, `~${widgetId}.snapshot.json`)
371
+
372
+ if (isPrivate) {
373
+ // Rename existing public snapshot to tilde-prefixed (gitignored) version
374
+ if (existsSync(snapshotPath)) {
375
+ try { renameSync(snapshotPath, tildeSnapshotPath) } catch {}
376
+ }
377
+ return
378
+ }
379
+
380
+ // If un-privated, restore from tilde if the public file doesn't exist yet
381
+ if (existsSync(tildeSnapshotPath) && !existsSync(snapshotPath)) {
382
+ try { renameSync(tildeSnapshotPath, snapshotPath) } catch {}
383
+ }
384
+
385
+ const snapshotScrollback = getRollingBufferContent(tmuxName, SNAPSHOT_MAX_AGE_MS)
386
+ const snapshotData = {
387
+ widgetId,
388
+ canvasId,
389
+ prettyName: prettyName || null,
390
+ timestamp: now,
391
+ cols: cols || 80,
392
+ rows: rows || 24,
393
+ paneContent: stripAnsi(paneContent),
394
+ scrollback: stripAnsi(snapshotScrollback),
395
+ }
396
+
397
+ const snapshotTmpPath = snapshotPath + '.tmp'
398
+ try {
399
+ mkdirSync(sDir, { recursive: true })
400
+ writeFileSync(snapshotTmpPath, JSON.stringify(snapshotData, null, 2), 'utf8')
401
+ renameSync(snapshotTmpPath, snapshotPath)
402
+ } catch {
403
+ try { if (existsSync(snapshotTmpPath)) unlinkSync(snapshotTmpPath) } catch {}
404
+ }
405
+ }
406
+
407
+ /** Start periodic snapshot capture for a session */
408
+ function startSnapshotCapture(opts) {
409
+ const { tmuxName } = opts
410
+ if (snapshotIntervals.has(tmuxName)) return
411
+
412
+ const termCfg = readTerminalConfig()
413
+ const interval = termCfg.snapshotInterval ?? 5000
414
+
415
+ const id = setInterval(() => captureSnapshot(opts), interval)
416
+ snapshotIntervals.set(tmuxName, id)
417
+ }
418
+
419
+ /** Stop periodic snapshot capture and do a final capture */
420
+ function stopSnapshotCapture(tmuxName, finalOpts) {
421
+ const id = snapshotIntervals.get(tmuxName)
422
+ if (id) {
423
+ clearInterval(id)
424
+ snapshotIntervals.delete(tmuxName)
425
+ }
426
+ if (finalOpts) {
427
+ captureSnapshot(finalOpts)
428
+ }
429
+ rollingBuffers.delete(tmuxName)
430
+ }
431
+
85
432
  /** Check if a tmux session with the given name exists */
86
433
  function tmuxSessionExists(name) {
87
434
  try {
@@ -99,7 +446,7 @@ function tmuxSessionExists(name) {
99
446
  export function orphanTerminalSession(widgetId) {
100
447
  const tmuxName = findTmuxNameForWidget(widgetId)
101
448
  if (!tmuxName) {
102
- console.warn(`[storyboard] orphanTerminalSession: no registry entry for widget ${widgetId}`)
449
+ devLog().logEvent('warn', 'orphanTerminalSession: no registry entry for widget', { widgetId })
103
450
  legacyKillSession(widgetId)
104
451
  return
105
452
  }
@@ -138,19 +485,40 @@ function legacyKillSession(widgetId) {
138
485
  * @param {string} base — Vite base path
139
486
  * @param {string} branch — current git branch name
140
487
  */
141
- export function setupTerminalServer(httpServer, base = '/', branch = 'unknown') {
488
+ export function setupTerminalServer(httpServer, base = '/', branch = 'unknown', hotPoolManager = null) {
142
489
  if (!pty || !WebSocketServer) {
143
- if (!pty) console.warn('[storyboard] node-pty not available — terminal widgets disabled')
144
- if (!WebSocketServer) console.warn('[storyboard] ws not available — terminal widgets disabled')
490
+ if (!pty) devLog().logEvent('warn', 'node-pty not available — terminal widgets disabled')
491
+ if (!WebSocketServer) devLog().logEvent('warn', 'ws not available — terminal widgets disabled')
145
492
  return
146
493
  }
147
494
 
148
495
  currentBranch = branch
496
+ hotPoolRef = hotPoolManager
149
497
 
150
- // Initialize registry
498
+ // Capture the actual port from the running HTTP server
499
+ try {
500
+ const addr = httpServer.address()
501
+ if (addr && addr.port) actualServerPort = addr.port
502
+ } catch {}
503
+
504
+ // Ensure node-pty spawn-helper has execute permission (npm install can strip it)
505
+ try {
506
+ const nodePtyDir = resolve(process.cwd(), 'node_modules/node-pty/prebuilds')
507
+ execSync(`chmod +x "${nodePtyDir}"/darwin-*/spawn-helper 2>/dev/null || true`, { stdio: 'ignore' })
508
+ } catch {}
509
+
510
+ // Initialize registry and terminal config
151
511
  const root = process.cwd()
152
512
  const termCfg = readTerminalConfig()
153
513
  initRegistry(root, { gracePeriod: termCfg.orphanGracePeriod })
514
+ initTerminalConfig(root)
515
+
516
+ // Best-effort: apply shell-config overrides if a tmux server already exists
517
+ // from a previous dev server run. If no server exists, this fails silently —
518
+ // overrides are applied again in createTerminal() after the first new-session.
519
+ if (hasTmux) {
520
+ applyTmuxShellOverrides()
521
+ }
154
522
 
155
523
  const mode = hasTmux ? 'tmux (persistent sessions)' : 'node-pty (no persistence)'
156
524
  console.log(`[storyboard] terminal server ready (${mode}) [branch: ${branch}]`)
@@ -177,20 +545,130 @@ export function setupTerminalServer(httpServer, base = '/', branch = 'unknown')
177
545
  const params = new URLSearchParams(queryStr || '')
178
546
  const canvasId = params.get('canvas') || 'unknown'
179
547
  const prettyName = params.get('name') || null
548
+ const widgetStartupCommand = params.get('startupCommand') || null
549
+ const readOnly = params.get('readOnly') === '1'
180
550
 
181
551
  wss.handleUpgrade(req, socket, head, (ws) => {
182
- handleConnection(ws, sessionId, canvasId, prettyName)
552
+ if (readOnly) {
553
+ handleReadOnlyConnection(ws, sessionId, canvasId)
554
+ } else {
555
+ handleConnection(ws, sessionId, canvasId, prettyName, widgetStartupCommand)
556
+ }
557
+ })
558
+ })
559
+ }
560
+
561
+ /**
562
+ * Read-only WebSocket connection — attaches to an existing tmux session
563
+ * for output-only streaming. Does NOT close existing WS connections,
564
+ * does NOT kill existing pty processes, does NOT register in the session registry.
565
+ * Used by the PromptWidget's inline terminal viewer.
566
+ */
567
+ function handleReadOnlyConnection(ws, widgetId, canvasId) {
568
+ const branch = currentBranch
569
+ const tmuxName = generateTmuxName(branch, canvasId, widgetId)
570
+
571
+ if (!hasTmux || !tmuxSessionExists(tmuxName)) {
572
+ try {
573
+ ws.send(JSON.stringify({ type: 'error', message: 'No active session to observe' }))
574
+ ws.close()
575
+ } catch {}
576
+ return
577
+ }
578
+
579
+ // Track read-only connections separately so they don't interfere with the primary
580
+ const roKey = `${tmuxName}:ro`
581
+ const existingRo = wsConnections.get(roKey)
582
+ if (existingRo && existingRo !== ws && existingRo.readyState <= 1) {
583
+ try { existingRo.close() } catch {}
584
+ }
585
+ wsConnections.set(roKey, ws)
586
+
587
+ let ptyProcess
588
+ try {
589
+ ptyProcess = pty.spawn('tmux', ['-f', '/dev/null', 'attach-session', '-t', tmuxName, '-r'], {
590
+ name: 'xterm-256color',
591
+ cols: 80,
592
+ rows: 24,
593
+ cwd: process.cwd(),
594
+ env: { ...process.env, TERM: 'xterm-256color' },
183
595
  })
596
+ } catch (err) {
597
+ try {
598
+ ws.send(JSON.stringify({ type: 'error', message: `Failed to attach: ${err.message}` }))
599
+ ws.close()
600
+ } catch {}
601
+ return
602
+ }
603
+
604
+ // Forward pty output to WS (one-way only)
605
+ ptyProcess.onData((data) => {
606
+ if (ws.readyState === 1) {
607
+ try { ws.send(data) } catch {}
608
+ }
609
+ })
610
+
611
+ ptyProcess.onExit(() => {
612
+ wsConnections.delete(roKey)
613
+ if (ws.readyState <= 1) {
614
+ try { ws.close() } catch {}
615
+ }
616
+ })
617
+
618
+ // Handle resize from client (needed for correct rendering)
619
+ ws.on('message', (msg) => {
620
+ try {
621
+ const str = typeof msg === 'string' ? msg : msg.toString()
622
+ if (!str.startsWith('{')) return // ignore non-JSON (input data)
623
+ const parsed = JSON.parse(str)
624
+ if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
625
+ ptyProcess.resize(parsed.cols, parsed.rows)
626
+ }
627
+ } catch {}
628
+ // All other input is silently dropped (read-only)
184
629
  })
630
+
631
+ ws.on('close', () => {
632
+ wsConnections.delete(roKey)
633
+ try { ptyProcess.kill() } catch {}
634
+ })
635
+
636
+ ws.on('error', () => {
637
+ wsConnections.delete(roKey)
638
+ try { ptyProcess.kill() } catch {}
639
+ })
640
+
641
+ // Send session info
642
+ try {
643
+ ws.send(JSON.stringify({ type: 'session-info', tmuxName, readOnly: true }))
644
+ } catch {}
185
645
  }
186
646
 
187
- function handleConnection(ws, widgetId, canvasId, prettyName) {
647
+ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupCommand = null) {
188
648
  const branch = currentBranch
189
649
  const tmuxName = generateTmuxName(branch, canvasId, widgetId)
190
650
 
191
651
  // Register in registry, check for conflicts
192
652
  const { entry, conflict } = registerSession({ branch, canvasId, widgetId, prettyName })
193
653
 
654
+ // Resolve server URL deterministically:
655
+ // 1. Use the actual port from httpServer (set at setup time)
656
+ // 2. Fall back to server registry (tracks running dev servers)
657
+ // 3. Last resort: default port 1234
658
+ let serverPort = actualServerPort
659
+ if (!serverPort) {
660
+ try {
661
+ const name = detectWorktreeName()
662
+ const servers = findByWorktree(name)
663
+ if (servers.length > 0) serverPort = servers[0].port
664
+ } catch {}
665
+ }
666
+ if (!serverPort) serverPort = 1234
667
+ const serverUrl = `http://localhost:${serverPort}`
668
+
669
+ // Write terminal config for agent context
670
+ writeTermConfig({ branch, canvasId, widgetId, serverUrl, tmuxName, displayName: prettyName || null, widgetProps: prettyName ? { prettyName } : null })
671
+
194
672
  // Close any existing WS for this session (one viewer at a time)
195
673
  const existingWs = wsConnections.get(tmuxName)
196
674
  if (existingWs && existingWs !== ws && existingWs.readyState <= 1) {
@@ -210,7 +688,18 @@ function handleConnection(ws, widgetId, canvasId, prettyName) {
210
688
  const termCfg = readTerminalConfig()
211
689
  const prompt = termCfg.prompt || '$ '
212
690
 
213
- // Create a minimal ZDOTDIR with .zshrc to override the default prompt.
691
+ // Shared identity env vars for both tmux and direct paths
692
+ const identityEnv = {
693
+ STORYBOARD_WIDGET_ID: widgetId,
694
+ STORYBOARD_CANVAS_ID: canvasId,
695
+ STORYBOARD_BRANCH: branch,
696
+ STORYBOARD_SERVER_URL: serverUrl,
697
+ }
698
+
699
+ // Env for the tmux path — cleaned of external shell config + neutralizing overrides.
700
+ // These env vars are inherited by the shell spawned inside new-session (NOT by the
701
+ // tmux server global env). Verified: tmux new-session passes the spawning process's
702
+ // env to the session shell. This does NOT contaminate other tmux sessions.
214
703
  const zdotdir = join(tmpdir(), 'storyboard-terminal')
215
704
  try {
216
705
  mkdirSync(zdotdir, { recursive: true })
@@ -218,8 +707,22 @@ function handleConnection(ws, widgetId, canvasId, prettyName) {
218
707
  writeFileSync(join(zdotdir, '.zshrc'), `export PS1='${prompt.replace(/'/g, "'\\''")}'\nunset RPS1\n`)
219
708
  } catch { /* best effort */ }
220
709
 
221
- const env = {
222
- ...process.env,
710
+ const tmuxEnv = {
711
+ ...cleanEnv(),
712
+ TERM: 'xterm-256color',
713
+ TERM_PROGRAM: 'storyboard',
714
+ ZDOTDIR: zdotdir,
715
+ STARSHIP_CONFIG: '/dev/null',
716
+ POWERLEVEL9K_DISABLE_CONFIGURATION_WIZARD: 'true',
717
+ ZSH_THEME: '',
718
+ BASH_ENV: '',
719
+ ENV: '',
720
+ ...identityEnv,
721
+ }
722
+
723
+ // Full env for the direct-shell fallback (no tmux).
724
+ const directEnv = {
725
+ ...cleanEnv(),
223
726
  TERM: 'xterm-256color',
224
727
  TERM_PROGRAM: 'storyboard',
225
728
  ZDOTDIR: zdotdir,
@@ -229,20 +732,65 @@ function handleConnection(ws, widgetId, canvasId, prettyName) {
229
732
  BASH_ENV: '',
230
733
  ENV: '',
231
734
  PS1: prompt,
735
+ ...identityEnv,
232
736
  }
233
737
  let ptyProcess
234
738
  let isNewSession = false
739
+ let usedWarmAgent = false // true when session came from a pre-warmed agent pool
235
740
 
741
+ try {
236
742
  if (hasTmux) {
237
743
  const reattach = tmuxSessionExists(tmuxName)
238
744
 
239
745
  // Also check for legacy sb-{widgetId} sessions and migrate
240
746
  const legacyName = `sb-${widgetId}`
241
747
  const hasLegacy = !reattach && tmuxSessionExists(legacyName)
242
- const actualName = hasLegacy ? legacyName : tmuxName
748
+ let actualName = hasLegacy ? legacyName : tmuxName
749
+
750
+ // If no existing session, try to acquire from the hot pool
751
+ let poolSession = null
752
+ let poolId = null
753
+ if (!reattach && !hasLegacy && hotPoolRef) {
754
+ const startupCommand = widgetStartupCommand ?? readTerminalConfig().startupCommand ?? null
755
+
756
+ // Resolve startup command to agent ID for pool lookup
757
+ if (startupCommand && startupCommand !== 'shell') {
758
+ try {
759
+ const raw = readFileSync(resolve(process.cwd(), 'storyboard.config.json'), 'utf8')
760
+ const agentsConfig = JSON.parse(raw)?.canvas?.agents
761
+ if (agentsConfig && typeof agentsConfig === 'object') {
762
+ for (const [id, cfg] of Object.entries(agentsConfig)) {
763
+ if (cfg.startupCommand && startupCommand.startsWith(cfg.startupCommand.split(' ')[0])) {
764
+ poolId = id
765
+ break
766
+ }
767
+ }
768
+ }
769
+ } catch {}
770
+ }
771
+
772
+ // Try agent pool first, then fall back to terminal pool for bare shells
773
+ const targetPool = poolId || (startupCommand ? null : 'terminal')
774
+ if (targetPool && hotPoolRef.has(targetPool)) {
775
+ poolSession = hotPoolRef.acquire(targetPool)
776
+ }
777
+
778
+ // If we got a warm session, rename it to the canonical tmux name
779
+ if (poolSession?.tmuxName) {
780
+ try {
781
+ try { execSync(`tmux kill-session -t "${tmuxName}" 2>/dev/null`, { stdio: 'ignore' }) } catch {}
782
+ execSync(`tmux rename-session -t "${poolSession.tmuxName}" "${tmuxName}"`, { stdio: 'ignore' })
783
+ hotPoolRef.consume(targetPool, poolSession.id)
784
+ usedWarmAgent = !!poolId // only true for agent pools, not terminal pools
785
+ } catch {
786
+ hotPoolRef.release(targetPool, poolSession.id)
787
+ poolSession = null
788
+ }
789
+ }
790
+ }
243
791
 
244
792
  // -f /dev/null skips user tmux.conf; 'set status off' hides the status bar
245
- const args = (reattach || hasLegacy)
793
+ const args = (reattach || hasLegacy || poolSession)
246
794
  ? ['-f', '/dev/null', 'attach-session', '-t', actualName]
247
795
  : ['-f', '/dev/null', 'new-session', '-s', tmuxName, '-c', cwd]
248
796
 
@@ -253,33 +801,280 @@ function handleConnection(ws, widgetId, canvasId, prettyName) {
253
801
  } catch {}
254
802
  }
255
803
 
256
- ptyProcess = pty.spawn('tmux', args, {
804
+ ptyProcess = spawnWithCleanup('tmux', args, {
257
805
  name: 'xterm-256color',
258
806
  cols: 80,
259
807
  rows: 24,
260
808
  cwd,
261
- env,
809
+ env: tmuxEnv,
262
810
  })
263
811
 
264
- // Hide status bar
812
+ // Hide status bar + apply shell-config overrides
265
813
  const targetName = (reattach || hasLegacy) ? actualName : tmuxName
266
- isNewSession = !(reattach || hasLegacy)
814
+ isNewSession = !(reattach || hasLegacy) || !!poolSession
267
815
  const hideStatus = () => {
268
816
  try {
269
817
  execSync(`tmux set-option -t "${targetName}" status off 2>/dev/null`, { stdio: 'ignore' })
818
+ execSync(`tmux set-option -t "${targetName}" set-clipboard off 2>/dev/null`, { stdio: 'ignore' })
819
+ // Only enable mouse for reattach sessions. For new sessions, mouse on
820
+ // is deferred — tmux mouse events crash Clack prompts in the welcome script.
821
+ if (!isNewSession) {
822
+ execSync(`tmux set-option -t "${targetName}" mouse on 2>/dev/null`, { stdio: 'ignore' })
823
+ }
824
+
825
+ // Apply shell-config overrides to the tmux server's global env.
826
+ // This is the reliable call — the tmux server is guaranteed to exist
827
+ // after pty.spawn('tmux', ...) above.
828
+ applyTmuxShellOverrides()
829
+
830
+ // Update tmux session env vars so new shells (and agents reading $STORYBOARD_WIDGET_ID)
831
+ // always reflect the current widget identity — even after reassignment.
832
+ const tmuxEnvVars = {
833
+ STORYBOARD_WIDGET_ID: widgetId,
834
+ STORYBOARD_CANVAS_ID: canvasId,
835
+ STORYBOARD_BRANCH: branch,
836
+ STORYBOARD_SERVER_URL: serverUrl,
837
+ }
838
+ for (const [key, val] of Object.entries(tmuxEnvVars)) {
839
+ execSync(`tmux set-environment -t "${targetName}" ${key} "${val}" 2>/dev/null`, { stdio: 'ignore' })
840
+ }
841
+ // Write a sourceable env file keyed by tmux session name.
842
+ // Running shells can source this to get fresh identity without restarting.
843
+ const envDir = join(cwd, '.storyboard', 'terminals')
844
+ try {
845
+ const envContent = Object.entries(tmuxEnvVars)
846
+ .map(([k, v]) => `export ${k}="${v}"`)
847
+ .join('\n') + '\n'
848
+ writeFileSync(join(envDir, `${targetName}.env`), envContent)
849
+ } catch { /* best effort */ }
850
+
851
+ // Write shell aliases for `start` and agent shorthand commands.
852
+ // Written on every connection (not just new sessions) so the file
853
+ // is always available and up-to-date for manual sourcing.
854
+ const canvasArg = canvasId !== 'unknown' ? canvasId : ''
855
+ const nameArgVal = prettyName ? ` --name "${prettyName}"` : ''
856
+ const welcomeBase = `storyboard terminal-welcome --branch "${branch}" --canvas "${canvasArg}"${nameArgVal}`
857
+
858
+ // Write real executable scripts to .storyboard/terminals/bin/ and
859
+ // prepend that dir to PATH via tmux set-environment. This makes
860
+ // `start`, `copilot`, `claude`, `codex` available in ANY shell
861
+ // inside the tmux session — even bare shells after a crash.
862
+ const binDir = join(envDir, 'bin')
863
+ try { mkdirSync(binDir, { recursive: true }) } catch {}
864
+
865
+ // `start` — opens welcome screen (no args) or launches a command.
866
+ // Uses `exec` to REPLACE the current shell, preventing nested
867
+ // welcome→shell→welcome→shell stacking. The parent welcome (if any)
868
+ // sees its child close and loops back to its menu.
869
+ const startScript = [
870
+ '#!/usr/bin/env sh',
871
+ `if [ $# -eq 0 ]; then`,
872
+ ` exec ${welcomeBase}`,
873
+ `else`,
874
+ ` exec ${welcomeBase} --startup "$*"`,
875
+ `fi`,
876
+ ].join('\n') + '\n'
877
+ try {
878
+ writeFileSync(join(binDir, 'start'), startScript, { mode: 0o755 })
879
+ } catch {}
880
+
881
+ // Agent shorthand scripts (copilot, claude, codex, etc.)
882
+ try {
883
+ const raw = readFileSync(resolve(process.cwd(), 'storyboard.config.json'), 'utf8')
884
+ const agentsConfig = JSON.parse(raw)?.canvas?.agents
885
+ if (agentsConfig && typeof agentsConfig === 'object') {
886
+ for (const [id, cfg] of Object.entries(agentsConfig)) {
887
+ if (!cfg.startupCommand) continue
888
+ const agentScript = [
889
+ '#!/usr/bin/env sh',
890
+ `exec start ${cfg.startupCommand} "$@"`,
891
+ ].join('\n') + '\n'
892
+ try {
893
+ writeFileSync(join(binDir, id), agentScript, { mode: 0o755 })
894
+ } catch {}
895
+ }
896
+ }
897
+ } catch {}
898
+
899
+ // Prepend bin dir to PATH in the tmux session environment.
900
+ // Every new shell in this session will inherit the updated PATH.
901
+ try {
902
+ const currentPath = process.env.PATH || '/usr/bin:/bin'
903
+ if (!currentPath.includes(binDir)) {
904
+ execSync(`tmux set-environment -t "${targetName}" PATH "${binDir}:${currentPath}" 2>/dev/null`, { stdio: 'ignore' })
905
+ }
906
+ } catch {}
907
+
908
+ // Also keep the sourceable aliases file for backwards compatibility
909
+ const aliasLines = [
910
+ '# Storyboard terminal aliases — auto-generated, do not edit',
911
+ `start() { if [ $# -eq 0 ]; then ${welcomeBase}; else ${welcomeBase} --startup "$*"; fi; }`,
912
+ ]
913
+ try {
914
+ const raw = readFileSync(resolve(process.cwd(), 'storyboard.config.json'), 'utf8')
915
+ const agentsConfig = JSON.parse(raw)?.canvas?.agents
916
+ if (agentsConfig && typeof agentsConfig === 'object') {
917
+ for (const [id, cfg] of Object.entries(agentsConfig)) {
918
+ if (!cfg.startupCommand) continue
919
+ aliasLines.push(`${id}() { start ${cfg.startupCommand} "$@"; }`)
920
+ }
921
+ }
922
+ } catch {}
923
+ const aliasFile = join(envDir, `${widgetId}.aliases.sh`)
924
+ try { writeFileSync(aliasFile, aliasLines.join('\n') + '\n') } catch {}
270
925
  } catch {}
271
926
  }
272
927
  setTimeout(hideStatus, 200)
273
928
 
274
- // For new sessions, run the welcome prompt script inside tmux
929
+ // For new sessions, either run startupCommand (skip welcome) or show the welcome screen
275
930
  if (isNewSession) {
931
+ const startupCommand = widgetStartupCommand ?? termCfg.startupCommand ?? null
932
+
933
+ // Build the welcome command base — used by all paths below
276
934
  const canvasArg = canvasId !== 'unknown' ? canvasId : ''
277
- setTimeout(() => {
278
- // Send the welcome command to the shell inside tmux
279
- const nameArg = prettyName ? ` --name "${prettyName}"` : ''
280
- const cmd = `storyboard terminal-welcome --branch "${branch}" --canvas "${canvasArg}"${nameArg}\r`
281
- ptyProcess.write(cmd)
282
- }, 600)
935
+ const nameArg = prettyName ? ` --name "${prettyName}"` : ''
936
+ const welcomeBase = `storyboard terminal-welcome --branch "${branch}" --canvas "${canvasArg}"${nameArg}`
937
+
938
+ if (usedWarmAgent) {
939
+ // ── Hot pool path: agent is already running and ready ──
940
+ // Skip agent launch, readiness polling, and postStartup (all done by pool).
941
+ // Inject identity via [System] message so the agent knows who it is.
942
+ setTimeout(() => {
943
+ injectIdentityMessage(tmuxName, { widgetId, displayName: prettyName, canvasId, branch, serverUrl })
944
+ setTimeout(() => deliverPendingMessages(tmuxName, widgetId), 1000)
945
+ }, 500)
946
+ } else {
947
+ // ── Cold path: standard startup flow ──
948
+
949
+ // Export identity env vars + shell-config overrides into the shell via send-keys.
950
+ // pty.spawn sets env on the tmux client process, but the session's
951
+ // shell doesn't inherit those — it starts from the tmux server env.
952
+ // send-keys is the only reliable way to set vars in the running shell.
953
+ // Shell-config overrides (STARSHIP_CONFIG, etc.) must also be sent here
954
+ // because the shell's .zshrc has already run by the time tmux global env
955
+ // overrides are applied.
956
+ const envParts = [
957
+ `export STORYBOARD_WIDGET_ID="${widgetId}"`,
958
+ `export STORYBOARD_CANVAS_ID="${canvasId}"`,
959
+ `export STORYBOARD_BRANCH="${branch}"`,
960
+ `export STORYBOARD_SERVER_URL="${serverUrl}"`,
961
+ ...Object.entries(TMUX_SHELL_OVERRIDES).map(([k, v]) => `export ${k}="${v}"`),
962
+ ]
963
+
964
+ // Prepend the bin dir to PATH for the initial shell (tmux set-environment
965
+ // handles future shells, but the first shell is already running)
966
+ const binDir = join(cwd, '.storyboard', 'terminals', 'bin')
967
+ envParts.push(`export PATH="${binDir}:$PATH"`)
968
+
969
+ // Chain clear into env exports so it runs synchronously after exports
970
+ // complete, avoiding a timing race where clear leaks into the agent prompt
971
+ if (startupCommand) envParts.push('clear')
972
+ const envExports = envParts.join(' && ')
973
+
974
+ setTimeout(() => {
975
+ try {
976
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(envExports)}`, { stdio: 'ignore' })
977
+ execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
978
+ } catch {}
979
+ }, 300)
980
+
981
+ if (startupCommand) {
982
+
983
+ // Look up agent config for this startup command
984
+ const agentCfg = (() => {
985
+ try {
986
+ const raw = readFileSync(resolve(process.cwd(), 'storyboard.config.json'), 'utf8')
987
+ const agentsConfig = JSON.parse(raw)?.canvas?.agents
988
+ if (!agentsConfig || typeof agentsConfig !== 'object') return null
989
+ for (const cfg of Object.values(agentsConfig)) {
990
+ if (cfg.startupCommand && startupCommand.startsWith(cfg.startupCommand.split(' ')[0])) return cfg
991
+ }
992
+ } catch {}
993
+ return null
994
+ })()
995
+
996
+ if (startupCommand === 'shell') {
997
+ // Plain shell — route through welcome with --startup shell so it
998
+ // returns to the welcome screen on exit
999
+ setTimeout(() => {
1000
+ const cmd = `${welcomeBase} --startup shell`
1001
+ try {
1002
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(cmd)}`, { stdio: 'ignore' })
1003
+ execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
1004
+ } catch {}
1005
+ }, 800)
1006
+ } else if (agentCfg || startupCommand !== 'shell') {
1007
+ // Agent or custom command — route through welcome with --startup
1008
+ // so the welcome screen appears when the agent exits
1009
+ const cmd = agentCfg?.startupCommand || startupCommand
1010
+ const postStartup = agentCfg?.postStartup || null
1011
+ const readinessSignal = agentCfg?.readinessSignal || null
1012
+
1013
+ setTimeout(() => {
1014
+ const welcomeCmd = `${welcomeBase} --startup ${JSON.stringify(cmd)}`
1015
+ try {
1016
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(welcomeCmd)}`, { stdio: 'ignore' })
1017
+ execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
1018
+ } catch {}
1019
+
1020
+ if (readinessSignal) {
1021
+ // Poll for readiness, then send postStartup command and deliver messages
1022
+ let sent = false
1023
+ const pollInterval = setInterval(() => {
1024
+ if (sent) { clearInterval(pollInterval); return }
1025
+ try {
1026
+ const paneContent = execSync(
1027
+ `tmux capture-pane -t "${tmuxName}" -p`,
1028
+ { encoding: 'utf8', timeout: 1000 }
1029
+ )
1030
+ if (paneContent.includes(readinessSignal)) {
1031
+ sent = true
1032
+ clearInterval(pollInterval)
1033
+ setTimeout(() => {
1034
+ if (postStartup) {
1035
+ try {
1036
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(postStartup)}`, { stdio: 'ignore' })
1037
+ execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
1038
+ } catch {}
1039
+ }
1040
+ // Inject identity, then deliver pending messages
1041
+ injectIdentityMessage(tmuxName, { widgetId, displayName: prettyName, canvasId, branch, serverUrl })
1042
+ setTimeout(() => deliverPendingMessages(tmuxName, widgetId), 2000)
1043
+ }, 500)
1044
+ }
1045
+ } catch {}
1046
+ }, 2000)
1047
+ setTimeout(() => { if (!sent) { sent = true; clearInterval(pollInterval) } }, 30000)
1048
+ } else {
1049
+ // No readiness signal — inject identity and deliver messages after a delay
1050
+ setTimeout(() => {
1051
+ injectIdentityMessage(tmuxName, { widgetId, displayName: prettyName, canvasId, branch, serverUrl })
1052
+ setTimeout(() => deliverPendingMessages(tmuxName, widgetId), 2000)
1053
+ }, 5000)
1054
+ }
1055
+ }, 900)
1056
+ }
1057
+ } else {
1058
+ // No startupCommand — show the welcome screen as before.
1059
+ // Use tmux send-keys (not ptyProcess.write) so the command goes through
1060
+ // the same input path as the env exports, avoiding interleave races.
1061
+ // Prepend 'clear' so the exported env vars are cleared from the screen.
1062
+ setTimeout(() => {
1063
+ try {
1064
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(`clear && ${welcomeBase}`)}`, { stdio: 'ignore' })
1065
+ execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
1066
+ } catch {}
1067
+ }, 800)
1068
+ }
1069
+ }
1070
+
1071
+ // Execute startup sequence if configured (after welcome or startupCommand)
1072
+ const startupSeq = termCfg.defaultStartupSequence
1073
+ if (startupSeq?.steps?.length) {
1074
+ setTimeout(() => {
1075
+ executeStartupSequence(tmuxName, ws, startupSeq)
1076
+ }, startupCommand ? 1500 : 1500)
1077
+ }
283
1078
  }
284
1079
 
285
1080
  // Write conflict warning if session was live elsewhere
@@ -300,14 +1095,36 @@ function handleConnection(ws, widgetId, canvasId, prettyName) {
300
1095
  } else {
301
1096
  const noRcFlag = shell.endsWith('/zsh') ? '--no-rcs' : shell.endsWith('/bash') ? '--norc' : ''
302
1097
  const shellArgs = noRcFlag ? [noRcFlag] : []
303
- ptyProcess = pty.spawn(shell, shellArgs, {
1098
+ ptyProcess = spawnWithCleanup(shell, shellArgs, {
304
1099
  name: 'xterm-256color',
305
1100
  cols: 80,
306
1101
  rows: 24,
307
1102
  cwd,
308
- env,
1103
+ env: directEnv,
309
1104
  })
310
1105
  }
1106
+ } catch (spawnErr) {
1107
+ devLog().logEvent('error', 'Terminal spawn failed', { error: spawnErr.message })
1108
+
1109
+ // Roll back registry — mark as background (not live) since spawn failed
1110
+ disconnectSession(tmuxName, entry.generation)
1111
+
1112
+ if (ws.readyState === ws.OPEN) {
1113
+ if (spawnErr.resourceLimited) {
1114
+ // PTY exhaustion — send structured error so browser can show cleanup UI
1115
+ sendJson(ws, {
1116
+ type: 'resource-limited',
1117
+ message: 'No PTY devices available. Too many terminal sessions are open.',
1118
+ counts: spawnErr.stats || getSessionStats(),
1119
+ })
1120
+ } else {
1121
+ ws.send(`\r\n\x1b[31m✖ Terminal failed to start: ${spawnErr.message}\x1b[0m\r\n`)
1122
+ ws.send(`\x1b[2mTry: chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper\x1b[0m\r\n`)
1123
+ }
1124
+ ws.close()
1125
+ }
1126
+ return
1127
+ }
311
1128
 
312
1129
  const generation = entry.generation
313
1130
  ptyProcesses.set(tmuxName, ptyProcess)
@@ -316,8 +1133,16 @@ function handleConnection(ws, widgetId, canvasId, prettyName) {
316
1133
  if (ws.readyState === ws.OPEN) {
317
1134
  ws.send(data)
318
1135
  }
1136
+ // Maintain time-windowed rolling buffer
1137
+ appendToRollingBuffer(tmuxName, data)
319
1138
  })
320
1139
 
1140
+ // Start periodic snapshot capture for tmux sessions
1141
+ const snapshotOpts = { tmuxName, widgetId, canvasId, prettyName, cols: 80, rows: 24, createdAt: new Date().toISOString() }
1142
+ if (hasTmux) {
1143
+ startSnapshotCapture(snapshotOpts)
1144
+ }
1145
+
321
1146
  ptyProcess.onExit(() => {
322
1147
  ptyProcesses.delete(tmuxName)
323
1148
  if (ws.readyState === ws.OPEN) {
@@ -331,6 +1156,9 @@ function handleConnection(ws, widgetId, canvasId, prettyName) {
331
1156
  const parsed = JSON.parse(str)
332
1157
  if (parsed.type === 'resize' && parsed.cols && parsed.rows) {
333
1158
  ptyProcess.resize(parsed.cols, parsed.rows)
1159
+ // Update snapshot dimensions
1160
+ snapshotOpts.cols = parsed.cols
1161
+ snapshotOpts.rows = parsed.rows
334
1162
  return
335
1163
  }
336
1164
  } catch {
@@ -340,8 +1168,11 @@ function handleConnection(ws, widgetId, canvasId, prettyName) {
340
1168
  ptyProcess.write(str)
341
1169
  })
342
1170
 
343
- // On disconnect: kill the pty (detaches from tmux) but leave the tmux session alive
1171
+ // On disconnect: final snapshot, kill the pty (detaches from tmux) but leave the tmux session alive
344
1172
  ws.on('close', () => {
1173
+ if (hasTmux) {
1174
+ stopSnapshotCapture(tmuxName, snapshotOpts)
1175
+ }
345
1176
  if (wsConnections.get(tmuxName) === ws) {
346
1177
  wsConnections.delete(tmuxName)
347
1178
  }
@@ -354,6 +1185,9 @@ function handleConnection(ws, widgetId, canvasId, prettyName) {
354
1185
  })
355
1186
 
356
1187
  ws.on('error', () => {
1188
+ if (hasTmux) {
1189
+ stopSnapshotCapture(tmuxName, snapshotOpts)
1190
+ }
357
1191
  if (wsConnections.get(tmuxName) === ws) {
358
1192
  wsConnections.delete(tmuxName)
359
1193
  }
@@ -370,6 +1204,180 @@ function sendJson(ws, data) {
370
1204
  }
371
1205
  }
372
1206
 
1207
+ /**
1208
+ * Deliver any pending messages queued for this terminal.
1209
+ * Called after agent startup is complete.
1210
+ */
1211
+ function deliverPendingMessages(tmuxName, widgetId) {
1212
+ if (!hasTmux) return
1213
+ try {
1214
+ const config = readTerminalConfigById(widgetId)
1215
+ if (!config?.pendingMessages?.length) return
1216
+
1217
+ const messages = config.pendingMessages
1218
+ // Clear pending messages from config
1219
+ config.pendingMessages = []
1220
+ config.updatedAt = new Date().toISOString()
1221
+
1222
+ // Write back via symlink path
1223
+ const symPath = join(process.cwd(), '.storyboard', 'terminals', `${widgetId}.json`)
1224
+ try { writeFileSync(symPath, JSON.stringify(config, null, 2)) } catch {}
1225
+
1226
+ // Deliver each message with a small delay between them
1227
+ messages.forEach((msg, i) => {
1228
+ setTimeout(() => {
1229
+ try {
1230
+ const excerpt = msg.message.length > 200 ? msg.message.slice(0, 200) + '…' : msg.message
1231
+ const formatted = `📩 [${msg.fromName || msg.from || 'unknown'} → you]\n\`\`\`\n${excerpt}\n\`\`\`${msg.from ? `\nFull context: cat .storyboard/terminals/${msg.from}.json | jq '.latestOutput.content'` : ''}`
1232
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(formatted)}`, { stdio: 'ignore' })
1233
+ execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
1234
+ } catch {}
1235
+ }, i * 1500)
1236
+ })
1237
+ } catch {}
1238
+ }
1239
+
1240
+ /**
1241
+ * Execute a startup sequence for a new terminal session.
1242
+ * Runs server-side via tmux send-keys. Only called for new sessions.
1243
+ *
1244
+ * Step types:
1245
+ * command — send text + \n to the shell
1246
+ * keystroke — send raw keys (e.g. {enter}, {tab})
1247
+ * wait — pause for ms or until output matches a pattern
1248
+ * tmux — run a tmux command against the session
1249
+ * env — set env var (must be before shell starts, so this is a pre-step)
1250
+ *
1251
+ * @param {string} tmuxName — tmux session name
1252
+ * @param {object} ws — WebSocket connection
1253
+ * @param {object} sequence — { steps: [], renderAfterStep?: number }
1254
+ */
1255
+ async function executeStartupSequence(tmuxName, ws, sequence) {
1256
+ if (!sequence?.steps?.length) return
1257
+ if (!hasTmux) return
1258
+
1259
+ const { steps, renderAfterStep } = sequence
1260
+ const shouldGateRender = typeof renderAfterStep === 'number' && renderAfterStep >= 0
1261
+
1262
+ for (let i = 0; i < steps.length; i++) {
1263
+ const step = steps[i]
1264
+
1265
+ try {
1266
+ switch (step.type) {
1267
+ case 'command':
1268
+ // Use -l for literal text to avoid shell interpretation issues
1269
+ execSync(
1270
+ `tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(step.value)}`,
1271
+ { stdio: 'ignore' }
1272
+ )
1273
+ execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
1274
+ break
1275
+
1276
+ case 'keystroke': {
1277
+ const keyMap = { '{enter}': 'Enter', '{tab}': 'Tab', '{escape}': 'Escape', '{space}': 'Space' }
1278
+ const key = keyMap[step.value] || step.value
1279
+ execSync(`tmux send-keys -t "${tmuxName}" ${key}`, { stdio: 'ignore' })
1280
+ break
1281
+ }
1282
+
1283
+ case 'wait':
1284
+ if (step.until === 'ready' || step.until === 'output') {
1285
+ const timeout = step.timeout || 10000
1286
+ const start = Date.now()
1287
+ const match = step.match || null
1288
+ while (Date.now() - start < timeout) {
1289
+ await new Promise(r => setTimeout(r, 500))
1290
+ if (match) {
1291
+ try {
1292
+ const capture = execSync(
1293
+ `tmux capture-pane -t "${tmuxName}" -p`,
1294
+ { encoding: 'utf8', timeout: 2000 }
1295
+ )
1296
+ if (capture.includes(match)) break
1297
+ } catch { /* continue waiting */ }
1298
+ }
1299
+ }
1300
+ } else {
1301
+ await new Promise(r => setTimeout(r, step.ms || 1000))
1302
+ }
1303
+ break
1304
+
1305
+ case 'tmux':
1306
+ execSync(`tmux ${step.value}`, { stdio: 'ignore' })
1307
+ break
1308
+
1309
+ default:
1310
+ devLog().logEvent('warn', `Unknown startup step type: ${step.type}`, { stepType: step.type })
1311
+ }
1312
+ } catch (err) {
1313
+ devLog().logEvent('warn', `Startup sequence step ${i} (${step.type}) failed`, { step: i, stepType: step.type, error: err.message })
1314
+ // Non-fatal — continue to next step
1315
+ }
1316
+
1317
+ // Send render signal after the specified step
1318
+ if (shouldGateRender && i === renderAfterStep) {
1319
+ sendJson(ws, { type: 'render' })
1320
+ }
1321
+ }
1322
+
1323
+ // If renderAfterStep was beyond all steps, send it now
1324
+ if (shouldGateRender && renderAfterStep >= steps.length) {
1325
+ sendJson(ws, { type: 'render' })
1326
+ }
1327
+ }
1328
+
373
1329
  // Re-export for backwards compat (canvas server uses this name)
374
1330
  export { killSession as killTerminalSession }
375
1331
 
1332
+ // Export for REST endpoint in canvas server
1333
+ export { legacySnapshotDir as terminalSnapshotDir }
1334
+
1335
+ /**
1336
+ * Read a terminal buffer file by widget ID.
1337
+ * Returns the parsed JSON or null if not found.
1338
+ * Optionally truncates scrollback to `maxLength` chars.
1339
+ */
1340
+ export function readTerminalBuffer(widgetId, { maxLength } = {}) {
1341
+ const filePath = join(bufferDir(), `${widgetId}.buffer.json`)
1342
+ try {
1343
+ if (!existsSync(filePath)) return null
1344
+ const data = JSON.parse(readFileSync(filePath, 'utf8'))
1345
+ if (maxLength && typeof maxLength === 'number') {
1346
+ if (data.scrollback && data.scrollback.length > maxLength) {
1347
+ data.scrollback = data.scrollback.slice(-maxLength)
1348
+ }
1349
+ if (data.paneContent && data.paneContent.length > maxLength) {
1350
+ data.paneContent = data.paneContent.slice(-maxLength)
1351
+ }
1352
+ }
1353
+ return data
1354
+ } catch {
1355
+ return null
1356
+ }
1357
+ }
1358
+
1359
+ /**
1360
+ * Read a terminal public snapshot by widget ID.
1361
+ * Checks new path first, falls back to legacy path.
1362
+ */
1363
+ export function readTerminalSnapshot(widgetId, canvasId) {
1364
+ // New path: assets/.storyboard-public/terminal-snapshots/<widgetId>.snapshot.json
1365
+ const newPath = join(publicSnapshotDir(), `${widgetId}.snapshot.json`)
1366
+ try {
1367
+ if (existsSync(newPath)) {
1368
+ return JSON.parse(readFileSync(newPath, 'utf8'))
1369
+ }
1370
+ } catch {}
1371
+
1372
+ // Legacy fallback: .storyboard/terminal-snapshots/<canvasDir>/<widgetId>.json
1373
+ if (canvasId) {
1374
+ const legacyPath = join(legacySnapshotDir(canvasId), `${widgetId}.json`)
1375
+ try {
1376
+ if (existsSync(legacyPath)) {
1377
+ return JSON.parse(readFileSync(legacyPath, 'utf8'))
1378
+ }
1379
+ } catch {}
1380
+ }
1381
+
1382
+ return null
1383
+ }