@dfosco/storyboard-core 4.2.0-beta.3 → 4.2.0

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