@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.
- package/commandpalette.config.json +109 -24
- package/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +16711 -27239
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +5 -2
- package/scaffold/agents/prompt-agent.agent.md +181 -0
- package/scaffold/agents/terminal-agent.agent.md +351 -0
- package/scaffold/codex/config.toml +246 -0
- package/scaffold/manifest.json +5 -10
- package/scaffold/skills/canvas/SKILL.md +5 -4
- package/scaffold/skills/ship/SKILL.md +1 -1
- package/scaffold/storyboard.config.json +11 -1
- package/src/ActionMenuButton.jsx +103 -0
- package/src/AutosyncMenuButton.css +67 -0
- package/src/AutosyncMenuButton.jsx +242 -0
- package/src/BranchSelect.jsx +29 -0
- package/src/BranchSelect.module.css +30 -0
- package/src/CanvasAgentsMenu.jsx +89 -0
- package/src/CanvasCreateMenu.jsx +611 -0
- package/src/CanvasSnap.css +27 -0
- package/src/CanvasSnap.jsx +51 -0
- package/src/CanvasUndoRedo.css +36 -0
- package/src/CanvasUndoRedo.jsx +62 -0
- package/src/CanvasZoomControl.css +53 -0
- package/src/CanvasZoomControl.jsx +49 -0
- package/src/CanvasZoomToFit.css +18 -0
- package/src/CanvasZoomToFit.jsx +26 -0
- package/src/CommandMenu.css +8 -0
- package/src/CommandMenu.jsx +287 -0
- package/src/CommandPalette.jsx +35 -0
- package/src/CommandPaletteTrigger.jsx +25 -0
- package/src/CommentsMenuButton.jsx +40 -0
- package/src/CoreUIBar.css +47 -0
- package/src/CoreUIBar.jsx +858 -0
- package/src/CreateMenuButton.jsx +117 -0
- package/src/HideChromeTrigger.jsx +40 -0
- package/src/InspectorPanel.css +109 -0
- package/src/InspectorPanel.jsx +632 -0
- package/src/PwaInstallBanner.css +42 -0
- package/src/PwaInstallBanner.jsx +124 -0
- package/src/SidePanel.jsx +261 -0
- package/src/ThemeMenuButton.jsx +139 -0
- package/src/autosync/server.js +202 -5
- package/src/autosync/server.test.js +111 -0
- package/src/canvas/__tests__/agent-integration.test.js +596 -0
- package/src/canvas/__tests__/helpers/browser.js +95 -0
- package/src/canvas/__tests__/helpers/canvas-api.js +129 -0
- package/src/canvas/__tests__/helpers/perf.js +118 -0
- package/src/canvas/__tests__/helpers/setup.js +176 -0
- package/src/canvas/__tests__/helpers/tmux.js +130 -0
- package/src/canvas/__tests__/helpers/transcript.js +132 -0
- package/src/canvas/__tests__/terminal-integration.test.js +177 -0
- package/src/canvas/hot-pool.js +756 -0
- package/src/canvas/materializer.js +31 -0
- package/src/canvas/materializer.test.js +56 -0
- package/src/canvas/selectedWidgets.js +65 -7
- package/src/canvas/server.js +1802 -22
- package/src/canvas/server.test.js +239 -0
- package/src/canvas/terminal-config.js +330 -0
- package/src/canvas/terminal-registry.js +41 -3
- package/src/canvas/terminal-server.js +1098 -37
- package/src/canvas/writeGuard.js +51 -3
- package/src/canvasConfig.js +67 -1
- package/src/canvasConfig.test.js +79 -1
- package/src/cli/agent.js +85 -0
- package/src/cli/branch.js +232 -0
- package/src/cli/canvasAdd.js +61 -14
- package/src/cli/canvasBatch.js +98 -0
- package/src/cli/canvasBounds.js +1 -1
- package/src/cli/canvasRead.js +1 -1
- package/src/cli/canvasUpdate.js +179 -0
- package/src/cli/compact.js +0 -2
- package/src/cli/create.js +40 -16
- package/src/cli/dev.js +158 -84
- package/src/cli/exit.js +23 -24
- package/src/cli/index.js +55 -2
- package/src/cli/proxy.js +96 -37
- package/src/cli/schemas.js +22 -4
- package/src/cli/server.js +149 -26
- package/src/cli/serverUrl.js +8 -3
- package/src/cli/sessions.js +133 -7
- package/src/cli/setup.js +138 -12
- package/src/cli/terminal-commands.js +20 -9
- package/src/cli/terminal-messaging.js +231 -0
- package/src/cli/terminal-welcome.js +449 -34
- package/src/cli/updateVersion.js +1 -1
- package/src/commandActions.js +1 -0
- package/src/commandPaletteConfig.js +9 -0
- package/src/comments/auth.js +2 -1
- package/src/comments/ui/AuthModal.jsx +114 -0
- package/src/comments/ui/CommentWindow.jsx +329 -0
- package/src/comments/ui/CommentsDrawer.jsx +102 -0
- package/src/comments/ui/Composer.jsx +64 -0
- package/src/comments/ui/authModal.test.js +1 -1
- package/src/comments/ui/commentWindow.js +16 -17
- package/src/comments/ui/commentsDrawer.js +25 -26
- package/src/comments/ui/composer.js +23 -24
- package/src/comments/ui/index.js +2 -3
- package/src/configSchema.js +59 -1
- package/src/configStore.js +161 -0
- package/src/core-ui-colors.css +12 -0
- package/src/devtools.js +17 -19
- package/src/devtools.test.js +18 -9
- package/src/featureFlags.js +12 -5
- package/src/fuzzySearch.test.js +10 -0
- package/src/index.js +15 -3
- package/src/lib/components/ui/alert/alert-action.jsx +11 -0
- package/src/lib/components/ui/alert/alert-description.jsx +11 -0
- package/src/lib/components/ui/alert/alert-title.jsx +11 -0
- package/src/lib/components/ui/alert/alert.jsx +25 -0
- package/src/lib/components/ui/alert/index.js +15 -15
- package/src/lib/components/ui/avatar/avatar-badge.jsx +22 -0
- package/src/lib/components/ui/avatar/avatar-fallback.jsx +18 -0
- package/src/lib/components/ui/avatar/avatar-group-count.jsx +19 -0
- package/src/lib/components/ui/avatar/avatar-group.jsx +19 -0
- package/src/lib/components/ui/avatar/avatar-image.jsx +15 -0
- package/src/lib/components/ui/avatar/avatar.jsx +19 -0
- package/src/lib/components/ui/avatar/index.js +20 -20
- package/src/lib/components/ui/badge/badge.jsx +31 -0
- package/src/lib/components/ui/badge/index.js +2 -2
- package/src/lib/components/ui/button/button.jsx +100 -0
- package/src/lib/components/ui/button/index.js +9 -9
- package/src/lib/components/ui/card/card-action.jsx +11 -0
- package/src/lib/components/ui/card/card-content.jsx +11 -0
- package/src/lib/components/ui/card/card-description.jsx +11 -0
- package/src/lib/components/ui/card/card-footer.jsx +11 -0
- package/src/lib/components/ui/card/card-header.jsx +19 -0
- package/src/lib/components/ui/card/card-title.jsx +11 -0
- package/src/lib/components/ui/card/card.jsx +17 -0
- package/src/lib/components/ui/card/index.js +23 -23
- package/src/lib/components/ui/checkbox/checkbox.jsx +29 -0
- package/src/lib/components/ui/checkbox/index.js +5 -5
- package/src/lib/components/ui/collapsible/collapsible-content.jsx +7 -0
- package/src/lib/components/ui/collapsible/collapsible-trigger.jsx +7 -0
- package/src/lib/components/ui/collapsible/collapsible.jsx +7 -0
- package/src/lib/components/ui/collapsible/index.js +11 -11
- package/src/lib/components/ui/dialog/dialog-close.jsx +7 -0
- package/src/lib/components/ui/dialog/dialog-content.jsx +34 -0
- package/src/lib/components/ui/dialog/dialog-description.jsx +15 -0
- package/src/lib/components/ui/dialog/dialog-footer.jsx +23 -0
- package/src/lib/components/ui/dialog/dialog-header.jsx +11 -0
- package/src/lib/components/ui/dialog/dialog-overlay.jsx +15 -0
- package/src/lib/components/ui/dialog/dialog-portal.jsx +4 -0
- package/src/lib/components/ui/dialog/dialog-title.jsx +15 -0
- package/src/lib/components/ui/dialog/dialog-trigger.jsx +7 -0
- package/src/lib/components/ui/dialog/dialog.jsx +4 -0
- package/src/lib/components/ui/dialog/index.js +32 -32
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.jsx +8 -0
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.jsx +30 -0
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-content.jsx +22 -0
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.jsx +16 -0
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-group.jsx +7 -0
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-item.jsx +20 -0
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-label.jsx +17 -0
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.jsx +4 -0
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.jsx +7 -0
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.jsx +29 -0
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.jsx +15 -0
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.jsx +16 -0
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.jsx +15 -0
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.jsx +23 -0
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.jsx +4 -0
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.jsx +7 -0
- package/src/lib/components/ui/dropdown-menu/dropdown-menu.jsx +4 -0
- package/src/lib/components/ui/dropdown-menu/index.js +52 -52
- package/src/lib/components/ui/input/index.js +5 -5
- package/src/lib/components/ui/input/input.jsx +19 -0
- package/src/lib/components/ui/label/index.js +5 -5
- package/src/lib/components/ui/label/label.jsx +19 -0
- package/src/lib/components/ui/panel/index.js +21 -21
- package/src/lib/components/ui/panel/panel-body.jsx +11 -0
- package/src/lib/components/ui/panel/panel-close.jsx +16 -0
- package/src/lib/components/ui/panel/panel-content.jsx +29 -0
- package/src/lib/components/ui/panel/panel-footer.jsx +11 -0
- package/src/lib/components/ui/panel/panel-header.jsx +11 -0
- package/src/lib/components/ui/panel/panel-title.jsx +12 -0
- package/src/lib/components/ui/panel/panel.jsx +4 -0
- package/src/lib/components/ui/popover/index.js +26 -26
- package/src/lib/components/ui/popover/popover-close.jsx +7 -0
- package/src/lib/components/ui/popover/popover-content.jsx +22 -0
- package/src/lib/components/ui/popover/popover-description.jsx +11 -0
- package/src/lib/components/ui/popover/popover-header.jsx +11 -0
- package/src/lib/components/ui/popover/popover-portal.jsx +4 -0
- package/src/lib/components/ui/popover/popover-title.jsx +11 -0
- package/src/lib/components/ui/popover/popover-trigger.jsx +8 -0
- package/src/lib/components/ui/popover/popover.jsx +4 -0
- package/src/lib/components/ui/searchable-list.jsx +160 -0
- package/src/lib/components/ui/select/index.js +35 -35
- package/src/lib/components/ui/select/select-content.jsx +30 -0
- package/src/lib/components/ui/select/select-group-heading.jsx +17 -0
- package/src/lib/components/ui/select/select-group.jsx +15 -0
- package/src/lib/components/ui/select/select-item.jsx +26 -0
- package/src/lib/components/ui/select/select-label.jsx +11 -0
- package/src/lib/components/ui/select/select-portal.jsx +4 -0
- package/src/lib/components/ui/select/select-scroll-down-button.jsx +18 -0
- package/src/lib/components/ui/select/select-scroll-up-button.jsx +18 -0
- package/src/lib/components/ui/select/select-separator.jsx +15 -0
- package/src/lib/components/ui/select/select-trigger.jsx +25 -0
- package/src/lib/components/ui/select/select.jsx +4 -0
- package/src/lib/components/ui/separator/index.js +5 -5
- package/src/lib/components/ui/separator/separator.jsx +22 -0
- package/src/lib/components/ui/sheet/index.js +32 -32
- package/src/lib/components/ui/sheet/sheet-close.jsx +7 -0
- package/src/lib/components/ui/sheet/sheet-content.jsx +35 -0
- package/src/lib/components/ui/sheet/sheet-description.jsx +15 -0
- package/src/lib/components/ui/sheet/sheet-footer.jsx +11 -0
- package/src/lib/components/ui/sheet/sheet-header.jsx +11 -0
- package/src/lib/components/ui/sheet/sheet-overlay.jsx +15 -0
- package/src/lib/components/ui/sheet/sheet-portal.jsx +4 -0
- package/src/lib/components/ui/sheet/sheet-title.jsx +15 -0
- package/src/lib/components/ui/sheet/sheet-trigger.jsx +7 -0
- package/src/lib/components/ui/sheet/sheet.jsx +4 -0
- package/src/lib/components/ui/textarea/index.js +5 -5
- package/src/lib/components/ui/textarea/textarea.jsx +18 -0
- package/src/lib/components/ui/toggle/index.js +6 -9
- package/src/lib/components/ui/toggle/toggle.jsx +36 -0
- package/src/lib/components/ui/toggle-group/index.js +8 -8
- package/src/lib/components/ui/toggle-group/toggle-group-item.jsx +29 -0
- package/src/lib/components/ui/toggle-group/toggle-group.jsx +43 -0
- package/src/lib/components/ui/tooltip/index.js +3 -3
- package/src/lib/components/ui/tooltip/tooltip-content.jsx +21 -0
- package/src/lib/components/ui/tooltip/tooltip-trigger.jsx +23 -0
- package/src/lib/components/ui/tooltip/tooltip.jsx +11 -0
- package/src/lib/components/ui/trigger-button/index.js +3 -3
- package/src/lib/components/ui/trigger-button/trigger-button.css +38 -0
- package/src/lib/components/ui/trigger-button/trigger-button.jsx +63 -0
- package/src/logger/devLogger.js +238 -0
- package/src/logger/devLogger.test.js +193 -0
- package/src/modes.test.js +4 -4
- package/src/mountStoryboardCore.js +133 -35
- package/src/paletteProviders.js +3 -0
- package/src/paletteProviders.test.js +2 -2
- package/src/server/index.js +104 -40
- package/src/sidepanel.css +214 -0
- package/src/smoothCorners.js +1 -1
- package/src/styles/tailwind.css +1 -1
- package/src/svelte-plugin-ui/__tests__/ModeSwitch.test.ts +8 -8
- package/src/svelte-plugin-ui/__tests__/ToolbarShell.test.ts +11 -10
- package/src/svelte-plugin-ui/components/Icon.css +11 -0
- package/src/svelte-plugin-ui/components/Icon.jsx +281 -0
- package/src/svelte-plugin-ui/components/ModeSwitch.css +90 -0
- package/src/svelte-plugin-ui/components/ModeSwitch.jsx +47 -0
- package/src/svelte-plugin-ui/components/ToolbarShell.css +80 -0
- package/src/svelte-plugin-ui/components/ToolbarShell.jsx +84 -0
- package/src/svelte-plugin-ui/components/Viewfinder.css +412 -0
- package/src/svelte-plugin-ui/components/Viewfinder.jsx +513 -0
- package/src/svelte-plugin-ui/mount.ts +12 -16
- package/src/toolRegistry.js +4 -4
- package/src/toolbarConfigStore.js +30 -0
- package/src/tools/handlers/autosync.js +1 -1
- package/src/tools/handlers/canvasAddWidget.js +1 -1
- package/src/tools/handlers/canvasAgents.js +20 -0
- package/src/tools/handlers/canvasToolbar.js +8 -8
- package/src/tools/handlers/commandPalette.js +9 -0
- package/src/tools/handlers/comments.js +2 -2
- package/src/tools/handlers/create.js +1 -1
- package/src/tools/handlers/devtools.js +19 -3
- package/src/tools/handlers/devtools.test.js +38 -0
- package/src/tools/handlers/featureFlags.js +1 -1
- package/src/tools/handlers/flows.js +3 -3
- package/src/tools/handlers/hideChrome.js +9 -0
- package/src/tools/handlers/paletteTheme.js +35 -0
- package/src/tools/handlers/theme.js +1 -1
- package/src/tools/registry.js +4 -1
- package/src/tools/surfaces/commandList.js +3 -3
- package/src/tools/surfaces/mainToolbar.js +3 -3
- package/src/tools/surfaces/registry.js +4 -4
- package/src/ui/design-modes.ts +2 -2
- package/src/ui/viewfinder.ts +1 -1
- package/src/vite/server-plugin.js +243 -61
- package/src/workshop/features/createCanvas/CreateCanvasForm.jsx +260 -0
- package/src/workshop/features/createCanvas/index.js +1 -1
- package/src/workshop/features/createFlow/CreateFlowForm.jsx +334 -0
- package/src/workshop/features/createFlow/index.js +1 -1
- package/src/workshop/features/createPage/CreatePageForm.jsx +304 -0
- package/src/workshop/features/createPage/index.js +1 -1
- package/src/workshop/features/createPrototype/CreatePrototypeForm.jsx +289 -0
- package/src/workshop/features/createPrototype/index.js +1 -1
- package/src/workshop/features/createPrototype/server.js +98 -0
- package/src/workshop/features/createStory/CreateStoryForm.jsx +208 -0
- package/src/workshop/features/createStory/index.js +1 -1
- package/src/workshop/ui/WorkshopPanel.jsx +98 -0
- package/src/workshop/ui/mount.ts +1 -1
- package/src/worktree/port.js +48 -0
- package/src/worktree/serverRegistry.js +120 -0
- package/toolbar.config.json +93 -42
- package/widgets.config.json +580 -12
- package/scaffold/commandpalette.config.json +0 -4
- package/scaffold/toolbar.config.json +0 -4
- package/src/ActionMenuButton.svelte +0 -119
- package/src/AutosyncMenuButton.svelte +0 -397
- package/src/CanvasCreateMenu.svelte +0 -295
- package/src/CanvasSnap.svelte +0 -87
- package/src/CanvasUndoRedo.svelte +0 -108
- package/src/CanvasZoomControl.svelte +0 -111
- package/src/CanvasZoomToFit.svelte +0 -52
- package/src/CommandMenu.svelte +0 -249
- package/src/CommandPalette.svelte +0 -33
- package/src/CommentsMenuButton.svelte +0 -53
- package/src/CoreUIBar.svelte +0 -847
- package/src/CreateMenuButton.svelte +0 -133
- package/src/DocPanel.svelte +0 -299
- package/src/InspectorPanel.svelte +0 -745
- package/src/PwaInstallBanner.svelte +0 -124
- package/src/SidePanel.svelte +0 -480
- package/src/ThemeMenuButton.svelte +0 -132
- package/src/comments/ui/AuthModal.svelte +0 -108
- package/src/comments/ui/CommentWindow.svelte +0 -333
- package/src/comments/ui/CommentsDrawer.svelte +0 -96
- package/src/comments/ui/Composer.svelte +0 -65
- package/src/lib/components/ui/alert/alert-action.svelte +0 -19
- package/src/lib/components/ui/alert/alert-description.svelte +0 -22
- package/src/lib/components/ui/alert/alert-title.svelte +0 -22
- package/src/lib/components/ui/alert/alert.svelte +0 -38
- package/src/lib/components/ui/avatar/avatar-badge.svelte +0 -25
- package/src/lib/components/ui/avatar/avatar-fallback.svelte +0 -20
- package/src/lib/components/ui/avatar/avatar-group-count.svelte +0 -22
- package/src/lib/components/ui/avatar/avatar-group.svelte +0 -22
- package/src/lib/components/ui/avatar/avatar-image.svelte +0 -17
- package/src/lib/components/ui/avatar/avatar.svelte +0 -24
- package/src/lib/components/ui/badge/badge.svelte +0 -44
- package/src/lib/components/ui/button/button.svelte +0 -108
- package/src/lib/components/ui/card/card-action.svelte +0 -21
- package/src/lib/components/ui/card/card-content.svelte +0 -19
- package/src/lib/components/ui/card/card-description.svelte +0 -19
- package/src/lib/components/ui/card/card-footer.svelte +0 -18
- package/src/lib/components/ui/card/card-header.svelte +0 -21
- package/src/lib/components/ui/card/card-title.svelte +0 -14
- package/src/lib/components/ui/card/card.svelte +0 -21
- package/src/lib/components/ui/checkbox/checkbox.svelte +0 -39
- package/src/lib/components/ui/collapsible/collapsible-content.svelte +0 -7
- package/src/lib/components/ui/collapsible/collapsible-trigger.svelte +0 -7
- package/src/lib/components/ui/collapsible/collapsible.svelte +0 -11
- package/src/lib/components/ui/dialog/dialog-close.svelte +0 -11
- package/src/lib/components/ui/dialog/dialog-content.svelte +0 -42
- package/src/lib/components/ui/dialog/dialog-description.svelte +0 -17
- package/src/lib/components/ui/dialog/dialog-footer.svelte +0 -29
- package/src/lib/components/ui/dialog/dialog-header.svelte +0 -19
- package/src/lib/components/ui/dialog/dialog-overlay.svelte +0 -17
- package/src/lib/components/ui/dialog/dialog-portal.svelte +0 -7
- package/src/lib/components/ui/dialog/dialog-title.svelte +0 -17
- package/src/lib/components/ui/dialog/dialog-trigger.svelte +0 -11
- package/src/lib/components/ui/dialog/dialog.svelte +0 -7
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte +0 -16
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +0 -40
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte +0 -27
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +0 -18
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte +0 -7
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte +0 -24
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte +0 -20
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte +0 -7
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +0 -16
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +0 -34
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte +0 -17
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +0 -19
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +0 -17
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +0 -27
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte +0 -7
- package/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +0 -7
- package/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte +0 -7
- package/src/lib/components/ui/input/input.svelte +0 -40
- package/src/lib/components/ui/label/label.svelte +0 -20
- package/src/lib/components/ui/panel/panel-body.svelte +0 -13
- package/src/lib/components/ui/panel/panel-close.svelte +0 -16
- package/src/lib/components/ui/panel/panel-content.svelte +0 -33
- package/src/lib/components/ui/panel/panel-footer.svelte +0 -13
- package/src/lib/components/ui/panel/panel-header.svelte +0 -16
- package/src/lib/components/ui/panel/panel-title.svelte +0 -14
- package/src/lib/components/ui/panel/panel.svelte +0 -15
- package/src/lib/components/ui/popover/popover-close.svelte +0 -7
- package/src/lib/components/ui/popover/popover-content.svelte +0 -27
- package/src/lib/components/ui/popover/popover-description.svelte +0 -19
- package/src/lib/components/ui/popover/popover-header.svelte +0 -19
- package/src/lib/components/ui/popover/popover-portal.svelte +0 -7
- package/src/lib/components/ui/popover/popover-title.svelte +0 -19
- package/src/lib/components/ui/popover/popover-trigger.svelte +0 -17
- package/src/lib/components/ui/popover/popover.svelte +0 -7
- package/src/lib/components/ui/select/select-content.svelte +0 -40
- package/src/lib/components/ui/select/select-group-heading.svelte +0 -19
- package/src/lib/components/ui/select/select-group.svelte +0 -17
- package/src/lib/components/ui/select/select-item.svelte +0 -38
- package/src/lib/components/ui/select/select-label.svelte +0 -18
- package/src/lib/components/ui/select/select-portal.svelte +0 -7
- package/src/lib/components/ui/select/select-scroll-down-button.svelte +0 -20
- package/src/lib/components/ui/select/select-scroll-up-button.svelte +0 -20
- package/src/lib/components/ui/select/select-separator.svelte +0 -17
- package/src/lib/components/ui/select/select-trigger.svelte +0 -27
- package/src/lib/components/ui/select/select.svelte +0 -11
- package/src/lib/components/ui/separator/separator.svelte +0 -23
- package/src/lib/components/ui/sheet/sheet-close.svelte +0 -7
- package/src/lib/components/ui/sheet/sheet-content.svelte +0 -43
- package/src/lib/components/ui/sheet/sheet-description.svelte +0 -17
- package/src/lib/components/ui/sheet/sheet-footer.svelte +0 -18
- package/src/lib/components/ui/sheet/sheet-header.svelte +0 -19
- package/src/lib/components/ui/sheet/sheet-overlay.svelte +0 -17
- package/src/lib/components/ui/sheet/sheet-portal.svelte +0 -7
- package/src/lib/components/ui/sheet/sheet-title.svelte +0 -17
- package/src/lib/components/ui/sheet/sheet-trigger.svelte +0 -7
- package/src/lib/components/ui/sheet/sheet.svelte +0 -7
- package/src/lib/components/ui/textarea/textarea.svelte +0 -21
- package/src/lib/components/ui/toggle/toggle.svelte +0 -45
- package/src/lib/components/ui/toggle-group/toggle-group-item.svelte +0 -35
- package/src/lib/components/ui/toggle-group/toggle-group.svelte +0 -63
- package/src/lib/components/ui/tooltip/tooltip-content.svelte +0 -24
- package/src/lib/components/ui/tooltip/tooltip-trigger.svelte +0 -27
- package/src/lib/components/ui/tooltip/tooltip.svelte +0 -9
- package/src/lib/components/ui/trigger-button/trigger-button.svelte +0 -106
- package/src/svelte-plugin-ui/components/Icon.svelte +0 -181
- package/src/svelte-plugin-ui/components/ModeSwitch.svelte +0 -121
- package/src/svelte-plugin-ui/components/ToolbarShell.svelte +0 -150
- package/src/svelte-plugin-ui/components/Viewfinder.svelte +0 -1001
- package/src/tools/handlers/docs.js +0 -11
- package/src/workshop/features/createCanvas/CreateCanvasForm.svelte +0 -139
- package/src/workshop/features/createFlow/CreateFlowForm.svelte +0 -314
- package/src/workshop/features/createPage/CreatePageForm.svelte +0 -249
- package/src/workshop/features/createPrototype/CreatePrototypeForm.svelte +0 -287
- package/src/workshop/features/createStory/CreateStoryForm.svelte +0 -161
- 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
|
-
|
|
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)
|
|
144
|
-
if (!WebSocketServer)
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
222
|
-
...
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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 =
|
|
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
|
+
}
|