@dfosco/storyboard-core 4.2.0-beta.2 → 4.2.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commandpalette.config.json +109 -24
- package/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +17379 -28568
- 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 -0
- package/scaffold/skills/canvas/SKILL.md +5 -4
- package/scaffold/skills/ship/SKILL.md +1 -1
- package/scaffold/storyboard.config.json +14 -1
- package/scaffold/toolbar.config.json +1 -1
- package/src/ActionMenuButton.jsx +100 -0
- package/src/AutosyncMenuButton.css +67 -0
- package/src/AutosyncMenuButton.jsx +241 -0
- package/src/BranchSelect.jsx +29 -0
- package/src/BranchSelect.module.css +30 -0
- package/src/CanvasAgentsMenu.jsx +87 -0
- package/src/CanvasCreateMenu.jsx +609 -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 +286 -0
- package/src/CommandPalette.jsx +35 -0
- package/src/CommandPaletteTrigger.jsx +25 -0
- package/src/CommentsMenuButton.jsx +38 -0
- package/src/CoreUIBar.css +47 -0
- package/src/CoreUIBar.jsx +855 -0
- package/src/CreateMenuButton.jsx +116 -0
- package/src/HideChromeTrigger.jsx +40 -0
- package/src/InspectorPanel.css +109 -0
- package/src/InspectorPanel.jsx +629 -0
- package/src/PwaInstallBanner.css +42 -0
- package/src/PwaInstallBanner.jsx +124 -0
- package/src/SidePanel.jsx +260 -0
- package/src/ThemeMenuButton.jsx +136 -0
- package/src/autosync/server.js +202 -5
- package/src/autosync/server.test.js +112 -0
- package/src/canvas/__tests__/agent-integration.test.js +593 -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 +129 -0
- package/src/canvas/__tests__/terminal-integration.test.js +175 -0
- package/src/canvas/hot-pool.js +757 -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 +1801 -22
- package/src/canvas/server.test.js +239 -0
- package/src/canvas/terminal-config.js +331 -0
- package/src/canvas/terminal-registry.js +38 -0
- package/src/canvas/terminal-server.js +1037 -29
- 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 +59 -12
- 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/create.js +38 -14
- package/src/cli/dev.js +157 -83
- 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 +148 -25
- package/src/cli/serverUrl.js +8 -3
- package/src/cli/sessions.js +131 -5
- package/src/cli/setup.js +109 -11
- package/src/cli/terminal-commands.js +16 -8
- package/src/cli/terminal-messaging.js +231 -0
- package/src/cli/terminal-welcome.js +365 -33
- 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 +14 -2
- 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 +159 -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 +123 -27
- package/src/paletteProviders.js +3 -0
- package/src/paletteProviders.test.js +2 -2
- package/src/server/index.js +98 -36
- package/src/sidepanel.css +214 -0
- 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 +512 -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 +19 -0
- package/src/tools/handlers/canvasToolbar.js +8 -8
- package/src/tools/handlers/commandPalette.js +9 -0
- package/src/tools/handlers/comments.js +1 -1
- package/src/tools/handlers/create.js +1 -1
- package/src/tools/handlers/devtools.js +16 -0
- package/src/tools/handlers/devtools.test.js +38 -0
- package/src/tools/handlers/flows.js +1 -1
- 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 +242 -60
- 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/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
package/src/canvas/server.js
CHANGED
|
@@ -17,9 +17,11 @@
|
|
|
17
17
|
* GET /page-order — read page order for a folder
|
|
18
18
|
* PUT /update-folder-meta — update folder .meta.json title
|
|
19
19
|
* POST /widget — append a widget_added event
|
|
20
|
+
* PATCH /widget — update a single widget's props/position
|
|
20
21
|
* DELETE /widget — append a widget_removed event
|
|
21
22
|
* POST /connector — append a connector_added event
|
|
22
23
|
* DELETE /connector — append a connector_removed event
|
|
24
|
+
* POST /batch — execute multiple operations in one request (refs, single HMR push)
|
|
23
25
|
* POST /create — create a new .canvas.jsonl file
|
|
24
26
|
* GET /stories — list all .story.{jsx,tsx} files with exports
|
|
25
27
|
* POST /create-story — scaffold a new .story.{jsx,tsx} file
|
|
@@ -27,7 +29,9 @@
|
|
|
27
29
|
* POST /github/embed — fetch GitHub issue/discussion/PR/comment metadata via gh
|
|
28
30
|
* POST /image — upload a pasted image to src/canvas/images/
|
|
29
31
|
* GET /images/* — serve an image file from src/canvas/images/
|
|
30
|
-
* POST /image/toggle-private — toggle
|
|
32
|
+
* POST /image/toggle-private — toggle ~prefix on image filename
|
|
33
|
+
* GET /terminal-buffer/:id — read private terminal buffer (with ?length=N)
|
|
34
|
+
* GET /terminal-snapshot/:id — read public terminal snapshot
|
|
31
35
|
*/
|
|
32
36
|
|
|
33
37
|
import fs from 'node:fs'
|
|
@@ -42,7 +46,43 @@ import {
|
|
|
42
46
|
isGhCliAvailable,
|
|
43
47
|
isGitHubEmbedUrl,
|
|
44
48
|
} from './githubEmbeds.js'
|
|
45
|
-
import { stampBounds, stampBoundsAll } from './collision.js'
|
|
49
|
+
import { stampBounds, stampBoundsAll, resolvePosition, getWidgetBounds } from './collision.js'
|
|
50
|
+
import { markCanvasWrite, unmarkCanvasWrite } from './writeGuard.js'
|
|
51
|
+
import { devLog } from '../logger/devLogger.js'
|
|
52
|
+
import widgetsConfig from '../../widgets.config.json' with { type: 'json' }
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read the prompt widget's execution config from widgets.config.json.
|
|
56
|
+
* Returns { default, agents } where each agent has a command template.
|
|
57
|
+
*/
|
|
58
|
+
function getPromptExecution() {
|
|
59
|
+
return widgetsConfig?.widgets?.prompt?.execution || null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build the CLI command for a prompt spawn.
|
|
64
|
+
* Reads the prompt widget's execution.agents config and interpolates ${prompt}.
|
|
65
|
+
*/
|
|
66
|
+
function buildPromptCmd({ prompt, envFile, agentId }) {
|
|
67
|
+
const execution = getPromptExecution()
|
|
68
|
+
|
|
69
|
+
if (!execution) {
|
|
70
|
+
// Bare fallback — no execution config found
|
|
71
|
+
const escaped = prompt.replace(/"/g, '\\"')
|
|
72
|
+
return `source ${envFile} && copilot -p "${escaped}" --allow-all`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const id = agentId || execution.default
|
|
76
|
+
const agent = execution.agents?.[id]
|
|
77
|
+
|
|
78
|
+
if (!agent?.command) {
|
|
79
|
+
return null // This agent doesn't have a prompt command
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const escaped = prompt.replace(/"/g, '\\"')
|
|
83
|
+
const cmd = agent.command.replace('${prompt}', escaped)
|
|
84
|
+
return `source ${envFile} && ${cmd}`
|
|
85
|
+
}
|
|
46
86
|
|
|
47
87
|
/**
|
|
48
88
|
* Scan src/canvas/ for directories containing .meta.json files.
|
|
@@ -209,14 +249,261 @@ function generateWidgetId(type) {
|
|
|
209
249
|
* Create the canvas API route handler.
|
|
210
250
|
*/
|
|
211
251
|
export function createCanvasHandler(ctx) {
|
|
212
|
-
const { root, sendJson } = ctx
|
|
252
|
+
const { root, sendJson, hotPool } = ctx
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Compute a target position relative to a reference widget.
|
|
256
|
+
* @param {object} refWidget — widget to position near (must have position + type/props)
|
|
257
|
+
* @param {string} direction — 'right' | 'left' | 'above' | 'below'
|
|
258
|
+
* @param {string} newType — type of the widget being created (for size defaults)
|
|
259
|
+
* @param {object} newProps — props of the widget being created
|
|
260
|
+
* @param {number} gap — spacing between widgets (default 40)
|
|
261
|
+
* @returns {{ x: number, y: number }}
|
|
262
|
+
*/
|
|
263
|
+
function computeNearPosition(refWidget, direction = 'right', newType = 'sticky-note', newProps = {}, gap = 40) {
|
|
264
|
+
const refBounds = getWidgetBounds(refWidget)
|
|
265
|
+
const newDefaults = getWidgetBounds({ type: newType, props: newProps, position: { x: 0, y: 0 } })
|
|
266
|
+
switch (direction) {
|
|
267
|
+
case 'left':
|
|
268
|
+
return { x: refBounds.x - newDefaults.width - gap, y: refBounds.y }
|
|
269
|
+
case 'above':
|
|
270
|
+
return { x: refBounds.x, y: refBounds.y - newDefaults.height - gap }
|
|
271
|
+
case 'below':
|
|
272
|
+
return { x: refBounds.x, y: refBounds.y + refBounds.height + gap }
|
|
273
|
+
case 'right':
|
|
274
|
+
default:
|
|
275
|
+
return { x: refBounds.x + refBounds.width + gap, y: refBounds.y }
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Compute a smart default position when no --near or explicit x,y is given.
|
|
281
|
+
* Priority chain:
|
|
282
|
+
* 1. Active agent/terminal (source widget ID from request)
|
|
283
|
+
* 2. User-selected widget (from .selectedwidgets.json, same canvas)
|
|
284
|
+
* 3. Viewport center (from .selectedwidgets.json)
|
|
285
|
+
* 4. Last widget on canvas
|
|
286
|
+
* 5. Origin (0, 0) — empty canvas, no viewport
|
|
287
|
+
*
|
|
288
|
+
* @param {object[]} canvasWidgets — current widgets on the canvas
|
|
289
|
+
* @param {string} type — widget type being created
|
|
290
|
+
* @param {object} props — widget props
|
|
291
|
+
* @param {string} projectRoot — project root directory
|
|
292
|
+
* @param {string|null} canvasName — canvas ID for matching selectedwidgets context
|
|
293
|
+
* @param {string|null} sourceWidgetId — caller's widget ID (agent/terminal creating this widget)
|
|
294
|
+
*/
|
|
295
|
+
async function computeAutoPosition(canvasWidgets, type, props, projectRoot, canvasName, sourceWidgetId) {
|
|
296
|
+
const widgetMap = new Map((canvasWidgets || []).map(w => [w.id, w]))
|
|
297
|
+
|
|
298
|
+
// 1. Place near the source agent/terminal widget
|
|
299
|
+
if (sourceWidgetId && widgetMap.has(sourceWidgetId)) {
|
|
300
|
+
return computeNearPosition(widgetMap.get(sourceWidgetId), 'right', type, props)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 2–3. Read .selectedwidgets.json for selection + viewport context
|
|
304
|
+
try {
|
|
305
|
+
const { readSelectedWidgets } = await import('./selectedWidgets.js')
|
|
306
|
+
const sw = readSelectedWidgets(projectRoot)
|
|
307
|
+
if (sw && sw.canvasId === canvasName) {
|
|
308
|
+
// 2. Place near the selected widget
|
|
309
|
+
if (sw.selectedWidgetIds?.length > 0) {
|
|
310
|
+
const selectedId = sw.selectedWidgetIds[0]
|
|
311
|
+
if (widgetMap.has(selectedId)) {
|
|
312
|
+
return computeNearPosition(widgetMap.get(selectedId), 'right', type, props)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 3. Place at viewport center
|
|
317
|
+
const vp = sw.viewport
|
|
318
|
+
if (vp && vp.centerX != null && vp.centerY != null) {
|
|
319
|
+
return { x: Math.round(vp.centerX / 24) * 24, y: Math.round(vp.centerY / 24) * 24 }
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
} catch { /* selectedWidgets bridge may not be initialized */ }
|
|
323
|
+
|
|
324
|
+
// 4. Place near the last widget on the canvas
|
|
325
|
+
if (canvasWidgets && canvasWidgets.length > 0) {
|
|
326
|
+
const lastWidget = canvasWidgets[canvasWidgets.length - 1]
|
|
327
|
+
return computeNearPosition(lastWidget, 'right', type, props)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 5. Truly empty canvas, no viewport
|
|
331
|
+
return { x: 0, y: 0 }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Update terminal configs when connectors change.
|
|
336
|
+
* Finds all terminal widgets in the canvas, computes their connected widget IDs
|
|
337
|
+
* from the current connector list, and updates their config files.
|
|
338
|
+
*/
|
|
339
|
+
async function updateTerminalConnectionsForCanvas(root, canvasName, canvasData, connectors) {
|
|
340
|
+
try {
|
|
341
|
+
const { updateTerminalConnections, initTerminalConfig } = await import('./terminal-config.js')
|
|
342
|
+
const { execSync } = await import('node:child_process')
|
|
343
|
+
initTerminalConfig(root)
|
|
344
|
+
|
|
345
|
+
let branch = 'unknown'
|
|
346
|
+
try {
|
|
347
|
+
branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
|
|
348
|
+
} catch {}
|
|
349
|
+
|
|
350
|
+
const widgets = canvasData.widgets || []
|
|
351
|
+
const widgetMap = new Map(widgets.map(w => [w.id, w]))
|
|
352
|
+
const terminalWidgets = widgets.filter((w) => w.type === 'terminal' || w.type === 'agent' || w.type === 'prompt')
|
|
353
|
+
|
|
354
|
+
for (const tw of terminalWidgets) {
|
|
355
|
+
const connectedIds = new Set()
|
|
356
|
+
const messagingPeers = []
|
|
357
|
+
for (const conn of connectors) {
|
|
358
|
+
let peerId = null
|
|
359
|
+
let direction = null
|
|
360
|
+
if (conn.start?.widgetId === tw.id) {
|
|
361
|
+
peerId = conn.end?.widgetId
|
|
362
|
+
direction = 'outgoing' // tw → peer
|
|
363
|
+
}
|
|
364
|
+
if (conn.end?.widgetId === tw.id) {
|
|
365
|
+
peerId = conn.start?.widgetId
|
|
366
|
+
direction = 'incoming' // peer → tw
|
|
367
|
+
}
|
|
368
|
+
if (peerId) {
|
|
369
|
+
connectedIds.add(peerId)
|
|
370
|
+
const mode = conn.meta?.messagingMode || 'none'
|
|
371
|
+
if (mode !== 'none') {
|
|
372
|
+
const peerWidget = widgetMap.get(peerId)
|
|
373
|
+
if (peerWidget && (peerWidget.type === 'terminal' || peerWidget.type === 'agent')) {
|
|
374
|
+
const canSend = mode === 'two-way' || (mode === 'one-way' && direction === 'outgoing')
|
|
375
|
+
const canReceive = mode === 'two-way' || (mode === 'one-way' && direction === 'incoming')
|
|
376
|
+
messagingPeers.push({
|
|
377
|
+
widgetId: peerId,
|
|
378
|
+
displayName: peerWidget.props?.prettyName || peerId,
|
|
379
|
+
configPath: `.storyboard/terminals/${peerId}.json`,
|
|
380
|
+
type: peerWidget.type,
|
|
381
|
+
canSend,
|
|
382
|
+
canReceive,
|
|
383
|
+
mode,
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
connectedIds.delete(undefined)
|
|
390
|
+
connectedIds.delete(null)
|
|
391
|
+
|
|
392
|
+
// Resolve full widget objects for connected widgets
|
|
393
|
+
const connectedWidgets = [...connectedIds]
|
|
394
|
+
.map(id => widgetMap.get(id))
|
|
395
|
+
.filter(Boolean)
|
|
396
|
+
.map(w => ({ id: w.id, type: w.type, props: w.props, position: w.position }))
|
|
397
|
+
|
|
398
|
+
// Build messaging section if there are messaging-enabled peers
|
|
399
|
+
const messaging = messagingPeers.length > 0 ? { peers: messagingPeers } : null
|
|
400
|
+
|
|
401
|
+
updateTerminalConnections({
|
|
402
|
+
branch,
|
|
403
|
+
canvasId: canvasName,
|
|
404
|
+
widgetId: tw.id,
|
|
405
|
+
connectedWidgets,
|
|
406
|
+
widgetProps: tw.props || null,
|
|
407
|
+
messaging,
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
} catch (err) {
|
|
411
|
+
devLog().logEvent('warn', 'Failed to update terminal connections', { error: err.message })
|
|
412
|
+
}
|
|
413
|
+
}
|
|
213
414
|
|
|
214
415
|
// Append an event to an existing canvas file.
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
416
|
+
// Marks the file in the write guard so the data plugin's watcher handler
|
|
417
|
+
// skips sending a duplicate HMR event (the server pushes its own via
|
|
418
|
+
// pushCanvasUpdate after the write).
|
|
218
419
|
function appendEvent(filePath, event) {
|
|
420
|
+
markCanvasWrite(filePath)
|
|
219
421
|
appendEventRaw(filePath, event)
|
|
422
|
+
// Unmark after enough time for the watcher to fire and be suppressed.
|
|
423
|
+
// macOS FSEvents latency is typically 100-500ms; 1s covers edge cases.
|
|
424
|
+
setTimeout(() => unmarkCanvasWrite(filePath), 1000)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Prepare a terminal/agent widget: auto-assign displayName and pre-reserve identity.
|
|
429
|
+
* Shared by POST /widget and batch create-widget.
|
|
430
|
+
* @param {{ type: string, props: Object }} opts
|
|
431
|
+
* @param {string} widgetId
|
|
432
|
+
* @param {string} canvasName
|
|
433
|
+
* @param {import('node:http').IncomingMessage} [req]
|
|
434
|
+
*/
|
|
435
|
+
async function prepareTerminalWidget({ type, props, widgetId, canvasName, req }) {
|
|
436
|
+
if (type !== 'terminal' && type !== 'agent') return
|
|
437
|
+
|
|
438
|
+
if (!props.prettyName) {
|
|
439
|
+
try {
|
|
440
|
+
const { generateFriendlyName } = await import('./terminal-registry.js')
|
|
441
|
+
props.prettyName = generateFriendlyName()
|
|
442
|
+
} catch { /* registry not initialized yet */ }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
try {
|
|
446
|
+
const { preReserveTerminalIdentity, initTerminalConfig } = await import('./terminal-config.js')
|
|
447
|
+
initTerminalConfig(root)
|
|
448
|
+
let branch = 'unknown'
|
|
449
|
+
try {
|
|
450
|
+
const { execSync } = await import('node:child_process')
|
|
451
|
+
branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
|
|
452
|
+
} catch {}
|
|
453
|
+
const serverUrl = `http://localhost:${req?.socket?.localPort || 1234}`
|
|
454
|
+
preReserveTerminalIdentity({
|
|
455
|
+
widgetId,
|
|
456
|
+
preDisplayName: props.prettyName || null,
|
|
457
|
+
canvasId: canvasName,
|
|
458
|
+
branch,
|
|
459
|
+
serverUrl,
|
|
460
|
+
})
|
|
461
|
+
} catch { /* best effort */ }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Resolve which hot pool to use for a widget type + props.
|
|
466
|
+
* Agent widgets use their agentId as pool ID; terminals use 'terminal'.
|
|
467
|
+
*/
|
|
468
|
+
function resolvePoolId(type, props) {
|
|
469
|
+
if (type === 'agent' && props?.agentId) return props.agentId
|
|
470
|
+
return 'terminal'
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Try to acquire a warm session from the hot pool.
|
|
475
|
+
* @param {Object|null} hotPool — HotPoolManager instance
|
|
476
|
+
* @param {string} poolId — pool to acquire from
|
|
477
|
+
* @param {string} [mode] — 'auto' (default), 'hot', or 'cold'
|
|
478
|
+
* @returns {Object|null} acquired session or null
|
|
479
|
+
*/
|
|
480
|
+
function acquireFromPool(hotPool, poolId, mode) {
|
|
481
|
+
if (!hotPool || mode === 'cold') return null
|
|
482
|
+
const effectiveMode = mode || 'auto'
|
|
483
|
+
if (effectiveMode === 'cold') return null
|
|
484
|
+
if (!hotPool.has(poolId)) return null
|
|
485
|
+
return hotPool.acquire(poolId) || null
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Push live canvas update to connected clients via Vite HMR.
|
|
490
|
+
* Reads the full materialized state from disk and sends it as a custom
|
|
491
|
+
* event so useCanvas can update in-place without a page refresh.
|
|
492
|
+
*/
|
|
493
|
+
function pushCanvasUpdate(canvasName, filePath, viteWs) {
|
|
494
|
+
if (!viteWs) return
|
|
495
|
+
try {
|
|
496
|
+
const data = readCanvas(filePath)
|
|
497
|
+
viteWs.send({
|
|
498
|
+
type: 'custom',
|
|
499
|
+
event: 'storyboard:canvas-file-changed',
|
|
500
|
+
data: { canvasId: canvasName, name: canvasName, metadata: data },
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
// Refresh terminal config files on every canvas change so agents
|
|
504
|
+
// always see up-to-date connectedWidgets and widget props.
|
|
505
|
+
updateTerminalConnectionsForCanvas(root, canvasName, data, data.connectors || [])
|
|
506
|
+
} catch { /* best effort — watcher will catch it eventually */ }
|
|
220
507
|
}
|
|
221
508
|
|
|
222
509
|
// Write a new JSONL file with a single creation event.
|
|
@@ -226,7 +513,7 @@ export function createCanvasHandler(ctx) {
|
|
|
226
513
|
fs.writeFileSync(filePath, serializeEvent(event) + '\n', 'utf-8')
|
|
227
514
|
}
|
|
228
515
|
|
|
229
|
-
return async (req, res, { body, path: routePath, method }) => {
|
|
516
|
+
return async (req, res, { body, path: routePath, method, __viteWs }) => {
|
|
230
517
|
// GET /folders — list available canvas folders
|
|
231
518
|
if (routePath === '/folders' && method === 'GET') {
|
|
232
519
|
const canvasDir = path.join(root, 'src', 'canvas')
|
|
@@ -336,7 +623,7 @@ export function createCanvasHandler(ctx) {
|
|
|
336
623
|
|
|
337
624
|
// PUT /update — append update events to the canvas stream
|
|
338
625
|
if (routePath === '/update' && method === 'PUT') {
|
|
339
|
-
const { name, widgets, sources, settings } = body
|
|
626
|
+
const { name, widgets, sources, settings, connectors } = body
|
|
340
627
|
|
|
341
628
|
if (!name) {
|
|
342
629
|
sendJson(res, 400, { error: 'Canvas name is required' })
|
|
@@ -353,6 +640,21 @@ export function createCanvasHandler(ctx) {
|
|
|
353
640
|
const ts = new Date().toISOString()
|
|
354
641
|
|
|
355
642
|
if (widgets) {
|
|
643
|
+
// Guard against accidental canvas wipes: if the incoming widget count
|
|
644
|
+
// is much smaller than the current canvas, reject unless explicitly confirmed.
|
|
645
|
+
// This protects against agents/scripts that accidentally send a partial widget
|
|
646
|
+
// array to the widgets_replaced endpoint (which replaces ALL widgets).
|
|
647
|
+
const current = readCanvas(filePath)
|
|
648
|
+
const currentCount = (current.widgets || []).length
|
|
649
|
+
if (currentCount > 1 && widgets.length < currentCount * 0.5 && body.replaceAll !== true) {
|
|
650
|
+
sendJson(res, 400, {
|
|
651
|
+
error: `Refusing to replace ${currentCount} widgets with ${widgets.length}. `
|
|
652
|
+
+ `This would delete ${currentCount - widgets.length} widgets. `
|
|
653
|
+
+ `Use PATCH /_storyboard/canvas/widget to update individual widgets, `
|
|
654
|
+
+ `or pass "replaceAll": true to confirm full replacement.`,
|
|
655
|
+
})
|
|
656
|
+
return
|
|
657
|
+
}
|
|
356
658
|
const stamped = stampBoundsAll(widgets)
|
|
357
659
|
appendEvent(filePath, { event: 'widgets_replaced', timestamp: ts, widgets: stamped })
|
|
358
660
|
}
|
|
@@ -361,6 +663,10 @@ export function createCanvasHandler(ctx) {
|
|
|
361
663
|
appendEvent(filePath, { event: 'source_updated', timestamp: ts, sources })
|
|
362
664
|
}
|
|
363
665
|
|
|
666
|
+
if (connectors) {
|
|
667
|
+
appendEvent(filePath, { event: 'connectors_replaced', timestamp: ts, connectors })
|
|
668
|
+
}
|
|
669
|
+
|
|
364
670
|
if (settings) {
|
|
365
671
|
const filtered = {}
|
|
366
672
|
for (const [key, value] of Object.entries(settings)) {
|
|
@@ -374,6 +680,7 @@ export function createCanvasHandler(ctx) {
|
|
|
374
680
|
}
|
|
375
681
|
|
|
376
682
|
sendJson(res, 200, { success: true, name })
|
|
683
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
377
684
|
} catch (err) {
|
|
378
685
|
sendJson(res, 500, { error: `Failed to update canvas: ${err.message}` })
|
|
379
686
|
}
|
|
@@ -382,7 +689,14 @@ export function createCanvasHandler(ctx) {
|
|
|
382
689
|
|
|
383
690
|
// POST /widget — append a widget_added event
|
|
384
691
|
if (routePath === '/widget' && method === 'POST') {
|
|
385
|
-
const { name, type, props = {},
|
|
692
|
+
const { name, type, props = {}, pool, near, direction, resolve, source } = body
|
|
693
|
+
let position = body.position || { x: 0, y: 0 }
|
|
694
|
+
|
|
695
|
+
// Detect whether the caller provided an explicit position.
|
|
696
|
+
// `near === false` is the explicit opt-out ("put it exactly here").
|
|
697
|
+
const hasExplicitPosition = body.position && (body.position.x !== 0 || body.position.y !== 0)
|
|
698
|
+
const hasNearOptOut = near === false
|
|
699
|
+
const needsAutoPosition = !near && !hasExplicitPosition && !hasNearOptOut
|
|
386
700
|
|
|
387
701
|
if (!name) {
|
|
388
702
|
sendJson(res, 400, { error: 'Canvas name is required' })
|
|
@@ -400,14 +714,51 @@ export function createCanvasHandler(ctx) {
|
|
|
400
714
|
}
|
|
401
715
|
|
|
402
716
|
try {
|
|
717
|
+
// Always read canvas when we need near, resolve, or auto-positioning
|
|
718
|
+
const needsCanvasRead = near || resolve || needsAutoPosition
|
|
719
|
+
let canvasWidgets = null
|
|
720
|
+
let canvasData = null
|
|
721
|
+
if (needsCanvasRead) {
|
|
722
|
+
canvasData = readCanvas(filePath)
|
|
723
|
+
canvasWidgets = canvasData.widgets || []
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (near) {
|
|
727
|
+
const refWidget = canvasWidgets.find((w) => w.id === near)
|
|
728
|
+
if (!refWidget) {
|
|
729
|
+
sendJson(res, 400, { error: `Widget "${near}" not found (--near)` })
|
|
730
|
+
return
|
|
731
|
+
}
|
|
732
|
+
position = computeNearPosition(refWidget, direction || 'right', type, props)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// Auto-position: no --near, no explicit x,y → smart default
|
|
736
|
+
if (needsAutoPosition && !near) {
|
|
737
|
+
position = await computeAutoPosition(canvasWidgets, type, props, root, name, source || null)
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (near || resolve || needsAutoPosition) {
|
|
741
|
+
const resolved = resolvePosition({
|
|
742
|
+
x: position.x, y: position.y, type, props,
|
|
743
|
+
widgets: canvasWidgets,
|
|
744
|
+
gridSize: (canvasData && canvasData.gridSize) || 24,
|
|
745
|
+
})
|
|
746
|
+
position = { x: resolved.x, y: resolved.y }
|
|
747
|
+
}
|
|
748
|
+
|
|
403
749
|
const widgetId = generateWidgetId(type)
|
|
404
750
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
751
|
+
await prepareTerminalWidget({ type, props, widgetId, canvasName: name, req })
|
|
752
|
+
|
|
753
|
+
// Hot pool acquisition for terminal/agent widgets
|
|
754
|
+
let hotSession = null
|
|
755
|
+
if ((type === 'terminal' || type === 'agent') && pool !== 'cold') {
|
|
756
|
+
const poolId = resolvePoolId(type, props)
|
|
757
|
+
hotSession = acquireFromPool(hotPool, poolId, pool)
|
|
758
|
+
if (!hotSession && pool === 'hot') {
|
|
759
|
+
sendJson(res, 409, { error: `No warm sessions available in pool "${poolId}"` })
|
|
760
|
+
return
|
|
761
|
+
}
|
|
411
762
|
}
|
|
412
763
|
|
|
413
764
|
const widget = stampBounds({ id: widgetId, type, position, props })
|
|
@@ -418,7 +769,10 @@ export function createCanvasHandler(ctx) {
|
|
|
418
769
|
widget,
|
|
419
770
|
})
|
|
420
771
|
|
|
421
|
-
|
|
772
|
+
const response = { success: true, widget }
|
|
773
|
+
if (hotSession) response.hotSession = { id: hotSession.id, tmuxName: hotSession.tmuxName || null }
|
|
774
|
+
sendJson(res, 201, response)
|
|
775
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
422
776
|
} catch (err) {
|
|
423
777
|
sendJson(res, 500, { error: `Failed to add widget: ${err.message}` })
|
|
424
778
|
}
|
|
@@ -456,22 +810,86 @@ export function createCanvasHandler(ctx) {
|
|
|
456
810
|
})
|
|
457
811
|
|
|
458
812
|
// Orphan terminal session when a terminal widget is deleted (not killed)
|
|
459
|
-
if (widget.type === 'terminal') {
|
|
813
|
+
if (widget.type === 'terminal' || widget.type === 'agent') {
|
|
460
814
|
try {
|
|
461
815
|
const { orphanTerminalSession } = await import('./terminal-server.js')
|
|
462
816
|
orphanTerminalSession(widgetId)
|
|
463
817
|
} catch (err) {
|
|
464
|
-
|
|
818
|
+
devLog().logEvent('warn', `Failed to orphan terminal session for ${widgetId}`, { widgetId, error: err.message })
|
|
465
819
|
}
|
|
466
820
|
}
|
|
467
821
|
|
|
468
822
|
sendJson(res, 200, { success: true, removed: 1 })
|
|
823
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
469
824
|
} catch (err) {
|
|
470
825
|
sendJson(res, 500, { error: `Failed to remove widget: ${err.message}` })
|
|
471
826
|
}
|
|
472
827
|
return
|
|
473
828
|
}
|
|
474
829
|
|
|
830
|
+
// PATCH /widget — update a single widget's props
|
|
831
|
+
if (routePath === '/widget' && method === 'PATCH') {
|
|
832
|
+
const { name, widgetId, props, position } = body
|
|
833
|
+
|
|
834
|
+
if (!name || !widgetId) {
|
|
835
|
+
sendJson(res, 400, { error: 'Canvas name and widgetId are required' })
|
|
836
|
+
return
|
|
837
|
+
}
|
|
838
|
+
if (!props && !position) {
|
|
839
|
+
sendJson(res, 400, { error: 'At least one of props or position is required' })
|
|
840
|
+
return
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const filePath = findCanvasPath(root, name)
|
|
844
|
+
if (!filePath) {
|
|
845
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
846
|
+
return
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
try {
|
|
850
|
+
const data = readCanvas(filePath)
|
|
851
|
+
const widget = (data.widgets || []).find((w) => w.id === widgetId)
|
|
852
|
+
if (!widget) {
|
|
853
|
+
sendJson(res, 404, { error: `Widget "${widgetId}" not found in canvas "${name}"` })
|
|
854
|
+
return
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const ts = new Date().toISOString()
|
|
858
|
+
|
|
859
|
+
if (props) {
|
|
860
|
+
appendEvent(filePath, {
|
|
861
|
+
event: 'widget_updated',
|
|
862
|
+
timestamp: ts,
|
|
863
|
+
widgetId,
|
|
864
|
+
props,
|
|
865
|
+
})
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if (position) {
|
|
869
|
+
// Merge with existing position so partial updates (only --x or --y) are safe
|
|
870
|
+
const mergedPosition = { ...widget.position, ...position }
|
|
871
|
+
appendEvent(filePath, {
|
|
872
|
+
event: 'widget_moved',
|
|
873
|
+
timestamp: ts,
|
|
874
|
+
widgetId,
|
|
875
|
+
position: mergedPosition,
|
|
876
|
+
})
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Return the merged widget for convenience
|
|
880
|
+
const merged = {
|
|
881
|
+
...widget,
|
|
882
|
+
props: { ...widget.props, ...(props || {}) },
|
|
883
|
+
position: position ? { ...widget.position, ...position } : widget.position,
|
|
884
|
+
}
|
|
885
|
+
sendJson(res, 200, { success: true, widget: merged })
|
|
886
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
887
|
+
} catch (err) {
|
|
888
|
+
sendJson(res, 500, { error: `Failed to update widget: ${err.message}` })
|
|
889
|
+
}
|
|
890
|
+
return
|
|
891
|
+
}
|
|
892
|
+
|
|
475
893
|
// POST /connector — append a connector_added event
|
|
476
894
|
if (routePath === '/connector' && method === 'POST') {
|
|
477
895
|
const { name, startWidgetId, startAnchor, endWidgetId, endAnchor, connectorType = 'default' } = body
|
|
@@ -529,12 +947,115 @@ export function createCanvasHandler(ctx) {
|
|
|
529
947
|
})
|
|
530
948
|
|
|
531
949
|
sendJson(res, 201, { success: true, connector })
|
|
950
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
532
951
|
} catch (err) {
|
|
533
952
|
sendJson(res, 500, { error: `Failed to add connector: ${err.message}` })
|
|
534
953
|
}
|
|
535
954
|
return
|
|
536
955
|
}
|
|
537
956
|
|
|
957
|
+
// PATCH /connector — update connector anchors and/or meta
|
|
958
|
+
if (routePath === '/connector' && method === 'PATCH') {
|
|
959
|
+
const { name, connectorId, meta, startAnchor, endAnchor } = body
|
|
960
|
+
|
|
961
|
+
if (!name || !connectorId) {
|
|
962
|
+
sendJson(res, 400, { error: 'Canvas name and connectorId are required' })
|
|
963
|
+
return
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
const filePath = findCanvasPath(root, name)
|
|
967
|
+
if (!filePath) {
|
|
968
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
969
|
+
return
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
try {
|
|
973
|
+
const data = readCanvas(filePath)
|
|
974
|
+
const connector = (data.connectors || []).find((c) => c.id === connectorId)
|
|
975
|
+
if (!connector) {
|
|
976
|
+
sendJson(res, 404, { error: `Connector "${connectorId}" not found in canvas "${name}"` })
|
|
977
|
+
return
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const validAnchors = ['top', 'right', 'bottom', 'left']
|
|
981
|
+
if (startAnchor && !validAnchors.includes(startAnchor)) {
|
|
982
|
+
sendJson(res, 400, { error: `Invalid startAnchor "${startAnchor}". Must be one of: ${validAnchors.join(', ')}` })
|
|
983
|
+
return
|
|
984
|
+
}
|
|
985
|
+
if (endAnchor && !validAnchors.includes(endAnchor)) {
|
|
986
|
+
sendJson(res, 400, { error: `Invalid endAnchor "${endAnchor}". Must be one of: ${validAnchors.join(', ')}` })
|
|
987
|
+
return
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const updates = {}
|
|
991
|
+
if (meta) updates.meta = { ...meta }
|
|
992
|
+
if (startAnchor) updates.startAnchor = startAnchor
|
|
993
|
+
if (endAnchor) updates.endAnchor = endAnchor
|
|
994
|
+
|
|
995
|
+
appendEvent(filePath, {
|
|
996
|
+
event: 'connector_updated',
|
|
997
|
+
timestamp: new Date().toISOString(),
|
|
998
|
+
connectorId,
|
|
999
|
+
updates,
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
sendJson(res, 200, { success: true })
|
|
1003
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1004
|
+
|
|
1005
|
+
// Inject messaging skill into both terminals when mode changes
|
|
1006
|
+
if (meta?.messagingMode || meta?.messaging) {
|
|
1007
|
+
const widgets = data.widgets || []
|
|
1008
|
+
const startWidget = widgets.find((w) => w.id === connector.start?.widgetId)
|
|
1009
|
+
const endWidget = widgets.find((w) => w.id === connector.end?.widgetId)
|
|
1010
|
+
const isTerminalType = (w) => w && (w.type === 'terminal' || w.type === 'agent')
|
|
1011
|
+
|
|
1012
|
+
if (isTerminalType(startWidget) && isTerminalType(endWidget)) {
|
|
1013
|
+
try {
|
|
1014
|
+
const { execSync } = await import('node:child_process')
|
|
1015
|
+
const { findTmuxNameForWidget } = await import('./terminal-registry.js')
|
|
1016
|
+
|
|
1017
|
+
// Resolve effective mode per widget
|
|
1018
|
+
const getMode = (w) => {
|
|
1019
|
+
if (meta.messagingMode === 'two-way') return 'two-way'
|
|
1020
|
+
if (meta.messaging?.[w.id]) return meta.messaging[w.id]
|
|
1021
|
+
return 'none'
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
const pairs = [
|
|
1025
|
+
{ widget: startWidget, peer: endWidget },
|
|
1026
|
+
{ widget: endWidget, peer: startWidget },
|
|
1027
|
+
]
|
|
1028
|
+
|
|
1029
|
+
for (const { widget: w, peer } of pairs) {
|
|
1030
|
+
const tmuxName = findTmuxNameForWidget(w.id)
|
|
1031
|
+
if (!tmuxName) continue
|
|
1032
|
+
|
|
1033
|
+
const peerName = peer.props?.prettyName || peer.id
|
|
1034
|
+
const mode = getMode(w)
|
|
1035
|
+
let skillMsg
|
|
1036
|
+
|
|
1037
|
+
if (mode === 'two-way') {
|
|
1038
|
+
skillMsg = `📡 [Two-way messaging ACTIVE with ${peerName}]\n\nYou are in a live conversation with ${peerName} (${peer.id}). Messages appear as: 📩 ${peerName}: <their message>\n\nWhen you receive a message:\n1. Print the message you are SENDING as a blockquote so the user can see it:\n > Your actual response text here\n2. Then save and send in one step:\n npx storyboard terminal output --summary "summary" --content "your response" && npx storyboard terminal send ${peer.id} "your response"\n\nIMPORTANT: Always show your actual response text as a > blockquote BEFORE the shell command. The user needs to see what you said, not just "I fired back with a joke."\n\nDo NOT ask for permission. Respond immediately. Keep it concise.`
|
|
1039
|
+
} else if (mode === 'one-way') {
|
|
1040
|
+
skillMsg = `📡 [One-way messaging → to ${peerName}]\nYou can send messages to ${peerName} (${peer.id}).\n\nAfter EVERY response, save your output:\n npx storyboard terminal output --summary "summary" --content "your full response"\n\nTo send a message:\n npx storyboard terminal send ${peer.id} "message"`
|
|
1041
|
+
} else {
|
|
1042
|
+
skillMsg = `📡 [Messaging with ${peerName} disabled]`
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
try {
|
|
1046
|
+
execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(skillMsg)}`, { stdio: 'ignore' })
|
|
1047
|
+
execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
|
|
1048
|
+
} catch { /* tmux session may not be active */ }
|
|
1049
|
+
}
|
|
1050
|
+
} catch { /* best effort */ }
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
sendJson(res, 500, { error: `Failed to update connector: ${err.message}` })
|
|
1055
|
+
}
|
|
1056
|
+
return
|
|
1057
|
+
}
|
|
1058
|
+
|
|
538
1059
|
// DELETE /connector — append a connector_removed event
|
|
539
1060
|
if (routePath === '/connector' && method === 'DELETE') {
|
|
540
1061
|
const { name, connectorId } = body
|
|
@@ -565,12 +1086,454 @@ export function createCanvasHandler(ctx) {
|
|
|
565
1086
|
})
|
|
566
1087
|
|
|
567
1088
|
sendJson(res, 200, { success: true, removed: 1 })
|
|
1089
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
568
1090
|
} catch (err) {
|
|
569
1091
|
sendJson(res, 500, { error: `Failed to remove connector: ${err.message}` })
|
|
570
1092
|
}
|
|
571
1093
|
return
|
|
572
1094
|
}
|
|
573
1095
|
|
|
1096
|
+
// POST /broadcast — toggle broadcast messaging for a widget and its connections.
|
|
1097
|
+
// Default: direct neighbors only. passThrough: true → BFS full connected component.
|
|
1098
|
+
if (routePath === '/broadcast' && method === 'POST') {
|
|
1099
|
+
const { name, widgetId, mode = 'two-way', passThrough = false } = body
|
|
1100
|
+
|
|
1101
|
+
if (!name || !widgetId) {
|
|
1102
|
+
sendJson(res, 400, { error: 'Canvas name and widgetId are required' })
|
|
1103
|
+
return
|
|
1104
|
+
}
|
|
1105
|
+
if (mode !== 'two-way' && mode !== 'one-way' && mode !== 'none') {
|
|
1106
|
+
sendJson(res, 400, { error: 'mode must be "two-way", "one-way", or "none"' })
|
|
1107
|
+
return
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const filePath = findCanvasPath(root, name)
|
|
1111
|
+
if (!filePath) {
|
|
1112
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
1113
|
+
return
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
try {
|
|
1117
|
+
const data = readCanvas(filePath)
|
|
1118
|
+
const widgets = data.widgets || []
|
|
1119
|
+
const connectors = data.connectors || []
|
|
1120
|
+
const widgetMap = new Map(widgets.map((w) => [w.id, w]))
|
|
1121
|
+
|
|
1122
|
+
const sourceWidget = widgetMap.get(widgetId)
|
|
1123
|
+
if (!sourceWidget) {
|
|
1124
|
+
sendJson(res, 404, { error: `Widget "${widgetId}" not found` })
|
|
1125
|
+
return
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const isTerminalType = (w) => w && (w.type === 'terminal' || w.type === 'agent')
|
|
1129
|
+
|
|
1130
|
+
// Find connectors to update via BFS (or direct neighbors only)
|
|
1131
|
+
const affectedConnectorIds = new Set()
|
|
1132
|
+
const affectedWidgetIds = new Set([widgetId])
|
|
1133
|
+
|
|
1134
|
+
if (passThrough) {
|
|
1135
|
+
// BFS: traverse entire connected component of terminal/agent widgets
|
|
1136
|
+
const visited = new Set([widgetId])
|
|
1137
|
+
const queue = [widgetId]
|
|
1138
|
+
while (queue.length > 0) {
|
|
1139
|
+
const current = queue.shift()
|
|
1140
|
+
for (const conn of connectors) {
|
|
1141
|
+
let peerId = null
|
|
1142
|
+
if (conn.start?.widgetId === current && conn.end?.widgetId) peerId = conn.end.widgetId
|
|
1143
|
+
if (conn.end?.widgetId === current && conn.start?.widgetId) peerId = conn.start.widgetId
|
|
1144
|
+
if (!peerId || visited.has(peerId)) continue
|
|
1145
|
+
const peer = widgetMap.get(peerId)
|
|
1146
|
+
if (!isTerminalType(peer)) continue
|
|
1147
|
+
affectedConnectorIds.add(conn.id)
|
|
1148
|
+
affectedWidgetIds.add(peerId)
|
|
1149
|
+
visited.add(peerId)
|
|
1150
|
+
queue.push(peerId)
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
} else {
|
|
1154
|
+
// Direct neighbors only
|
|
1155
|
+
for (const conn of connectors) {
|
|
1156
|
+
let peerId = null
|
|
1157
|
+
if (conn.start?.widgetId === widgetId && conn.end?.widgetId) peerId = conn.end.widgetId
|
|
1158
|
+
if (conn.end?.widgetId === widgetId && conn.start?.widgetId) peerId = conn.start.widgetId
|
|
1159
|
+
if (!peerId) continue
|
|
1160
|
+
const peer = widgetMap.get(peerId)
|
|
1161
|
+
if (!isTerminalType(peer)) continue
|
|
1162
|
+
affectedConnectorIds.add(conn.id)
|
|
1163
|
+
affectedWidgetIds.add(peerId)
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Update all affected connectors
|
|
1168
|
+
const ts = new Date().toISOString()
|
|
1169
|
+
const messagingMode = mode === 'none' ? null : mode
|
|
1170
|
+
for (const connId of affectedConnectorIds) {
|
|
1171
|
+
appendEvent(filePath, {
|
|
1172
|
+
event: 'connector_updated',
|
|
1173
|
+
timestamp: ts,
|
|
1174
|
+
connectorId: connId,
|
|
1175
|
+
updates: { meta: { messagingMode } },
|
|
1176
|
+
})
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
sendJson(res, 200, {
|
|
1180
|
+
success: true,
|
|
1181
|
+
affectedConnectors: [...affectedConnectorIds],
|
|
1182
|
+
affectedWidgets: [...affectedWidgetIds],
|
|
1183
|
+
})
|
|
1184
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1185
|
+
|
|
1186
|
+
// Inject messaging skill into affected terminals
|
|
1187
|
+
if (affectedConnectorIds.size > 0) {
|
|
1188
|
+
try {
|
|
1189
|
+
const { execSync } = await import('node:child_process')
|
|
1190
|
+
const { findTmuxNameForWidget } = await import('./terminal-registry.js')
|
|
1191
|
+
|
|
1192
|
+
for (const wId of affectedWidgetIds) {
|
|
1193
|
+
const w = widgetMap.get(wId)
|
|
1194
|
+
if (!isTerminalType(w)) continue
|
|
1195
|
+
const tmuxName = findTmuxNameForWidget(wId)
|
|
1196
|
+
if (!tmuxName) continue
|
|
1197
|
+
|
|
1198
|
+
// Build peer list for this widget
|
|
1199
|
+
const peers = []
|
|
1200
|
+
for (const conn of connectors) {
|
|
1201
|
+
let peerId = null
|
|
1202
|
+
if (conn.start?.widgetId === wId) peerId = conn.end?.widgetId
|
|
1203
|
+
if (conn.end?.widgetId === wId) peerId = conn.start?.widgetId
|
|
1204
|
+
if (peerId && affectedWidgetIds.has(peerId) && peerId !== wId) {
|
|
1205
|
+
const peer = widgetMap.get(peerId)
|
|
1206
|
+
if (peer) peers.push(peer)
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
if (mode === 'none') {
|
|
1211
|
+
const msg = '📡 [Broadcast disabled]'
|
|
1212
|
+
try {
|
|
1213
|
+
execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(msg)}`, { stdio: 'ignore' })
|
|
1214
|
+
execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
|
|
1215
|
+
} catch { /* session may not be active */ }
|
|
1216
|
+
} else {
|
|
1217
|
+
const peerNames = peers.map((p) => p.props?.prettyName || p.id).join(', ')
|
|
1218
|
+
const msg = `📡 [Broadcast ${mode} ACTIVE with ${peerNames}]`
|
|
1219
|
+
try {
|
|
1220
|
+
execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(msg)}`, { stdio: 'ignore' })
|
|
1221
|
+
execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
|
|
1222
|
+
} catch { /* session may not be active */ }
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
} catch { /* best effort */ }
|
|
1226
|
+
}
|
|
1227
|
+
} catch (err) {
|
|
1228
|
+
sendJson(res, 500, { error: `Failed to update broadcast: ${err.message}` })
|
|
1229
|
+
}
|
|
1230
|
+
return
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// POST /batch — execute multiple canvas operations in a single request.
|
|
1234
|
+
// Reads the canvas once, appends all events, pushes ONE HMR update at the end.
|
|
1235
|
+
// Operations reference earlier results via $index (auto) or $refName (opt-in).
|
|
1236
|
+
if (routePath === '/batch' && method === 'POST') {
|
|
1237
|
+
const { name, operations } = body
|
|
1238
|
+
|
|
1239
|
+
if (!name) {
|
|
1240
|
+
sendJson(res, 400, { error: 'Canvas name is required' })
|
|
1241
|
+
return
|
|
1242
|
+
}
|
|
1243
|
+
if (!Array.isArray(operations) || operations.length === 0) {
|
|
1244
|
+
sendJson(res, 400, { error: 'operations must be a non-empty array' })
|
|
1245
|
+
return
|
|
1246
|
+
}
|
|
1247
|
+
if (operations.length > 200) {
|
|
1248
|
+
sendJson(res, 400, { error: 'Maximum 200 operations per batch' })
|
|
1249
|
+
return
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
const filePath = findCanvasPath(root, name)
|
|
1253
|
+
if (!filePath) {
|
|
1254
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
1255
|
+
return
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
try {
|
|
1259
|
+
const canvasData = readCanvas(filePath)
|
|
1260
|
+
const widgetIds = new Set((canvasData.widgets || []).map((w) => w.id))
|
|
1261
|
+
const connectorIds = new Set((canvasData.connectors || []).map((c) => c.id))
|
|
1262
|
+
const widgetMap = new Map((canvasData.widgets || []).map((w) => [w.id, { ...w }]))
|
|
1263
|
+
const connectorMap = new Map((canvasData.connectors || []).map((c) => [c.id, { ...c }]))
|
|
1264
|
+
|
|
1265
|
+
const refs = {}
|
|
1266
|
+
const results = []
|
|
1267
|
+
const validAnchors = ['top', 'bottom', 'left', 'right']
|
|
1268
|
+
|
|
1269
|
+
// Resolve $ref strings — "$0", "$myName", etc.
|
|
1270
|
+
function resolveRef(val) {
|
|
1271
|
+
if (typeof val !== 'string' || !val.startsWith('$')) return val
|
|
1272
|
+
const refName = val.slice(1)
|
|
1273
|
+
if (refs[refName] !== undefined) return refs[refName]
|
|
1274
|
+
throw new Error(`Unknown ref "${val}"`)
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
for (let i = 0; i < operations.length; i++) {
|
|
1278
|
+
const op = operations[i]
|
|
1279
|
+
const ts = new Date().toISOString()
|
|
1280
|
+
|
|
1281
|
+
try {
|
|
1282
|
+
switch (op.op) {
|
|
1283
|
+
case 'create-widget': {
|
|
1284
|
+
const { type, props = {}, ref, pool, near, direction, resolve: doResolve, source: opSource } = op
|
|
1285
|
+
let position = op.position || { x: 0, y: 0 }
|
|
1286
|
+
if (!type) throw new Error('type is required')
|
|
1287
|
+
|
|
1288
|
+
// Detect whether an explicit position was provided
|
|
1289
|
+
const hasExplicitPos = op.position && (op.position.x !== 0 || op.position.y !== 0)
|
|
1290
|
+
const hasNearOptOut = near === false
|
|
1291
|
+
const needsAuto = !near && !hasExplicitPos && !hasNearOptOut
|
|
1292
|
+
|
|
1293
|
+
// --near: compute position relative to a reference widget
|
|
1294
|
+
if (near) {
|
|
1295
|
+
const nearId = resolveRef(near)
|
|
1296
|
+
const refWidget = widgetMap.get(nearId)
|
|
1297
|
+
if (!refWidget) throw new Error(`Widget "${nearId}" not found (near)`)
|
|
1298
|
+
position = computeNearPosition(refWidget, direction || 'right', type, props)
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// Auto-position: no --near, no explicit x,y → smart default
|
|
1302
|
+
if (needsAuto && !near) {
|
|
1303
|
+
const currentWidgets = Array.from(widgetMap.values())
|
|
1304
|
+
position = await computeAutoPosition(currentWidgets, type, props, root, name, opSource || null)
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// Collision resolution: uses live widgetMap (includes earlier batch creates)
|
|
1308
|
+
if (near || doResolve || needsAuto) {
|
|
1309
|
+
const resolved = resolvePosition({
|
|
1310
|
+
x: position.x, y: position.y, type, props,
|
|
1311
|
+
widgets: Array.from(widgetMap.values()),
|
|
1312
|
+
gridSize: canvasData.gridSize || 24,
|
|
1313
|
+
})
|
|
1314
|
+
position = { x: resolved.x, y: resolved.y }
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const widgetId = generateWidgetId(type)
|
|
1318
|
+
await prepareTerminalWidget({ type, props, widgetId, canvasName: name, req })
|
|
1319
|
+
|
|
1320
|
+
let hotSession = null
|
|
1321
|
+
if ((type === 'terminal' || type === 'agent') && pool !== 'cold') {
|
|
1322
|
+
const poolId = resolvePoolId(type, props)
|
|
1323
|
+
hotSession = acquireFromPool(hotPool, poolId, pool)
|
|
1324
|
+
if (!hotSession && pool === 'hot') throw new Error(`No warm sessions available in pool "${poolId}"`)
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const widget = stampBounds({ id: widgetId, type, position, props })
|
|
1328
|
+
|
|
1329
|
+
appendEvent(filePath, { event: 'widget_added', timestamp: ts, widget })
|
|
1330
|
+
|
|
1331
|
+
widgetIds.add(widgetId)
|
|
1332
|
+
widgetMap.set(widgetId, widget)
|
|
1333
|
+
refs[String(i)] = widgetId
|
|
1334
|
+
if (ref) refs[ref] = widgetId
|
|
1335
|
+
|
|
1336
|
+
const result = { index: i, op: 'create-widget', ref: ref || undefined, widgetId, widget }
|
|
1337
|
+
if (hotSession) result.hotSession = { id: hotSession.id, tmuxName: hotSession.tmuxName || null }
|
|
1338
|
+
results.push(result)
|
|
1339
|
+
break
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
case 'update-widget': {
|
|
1343
|
+
const widgetId = resolveRef(op.widgetId)
|
|
1344
|
+
const { props } = op
|
|
1345
|
+
if (!widgetId) throw new Error('widgetId is required')
|
|
1346
|
+
if (!props) throw new Error('props is required')
|
|
1347
|
+
if (!widgetIds.has(widgetId)) throw new Error(`Widget "${widgetId}" not found`)
|
|
1348
|
+
|
|
1349
|
+
appendEvent(filePath, { event: 'widget_updated', timestamp: ts, widgetId, props })
|
|
1350
|
+
|
|
1351
|
+
const existing = widgetMap.get(widgetId)
|
|
1352
|
+
if (existing) existing.props = { ...existing.props, ...props }
|
|
1353
|
+
|
|
1354
|
+
results.push({ index: i, op: 'update-widget', widgetId, success: true })
|
|
1355
|
+
break
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
case 'move-widget': {
|
|
1359
|
+
const widgetId = resolveRef(op.widgetId)
|
|
1360
|
+
const { position } = op
|
|
1361
|
+
if (!widgetId) throw new Error('widgetId is required')
|
|
1362
|
+
if (!position) throw new Error('position is required')
|
|
1363
|
+
if (!widgetIds.has(widgetId)) throw new Error(`Widget "${widgetId}" not found`)
|
|
1364
|
+
|
|
1365
|
+
const existing = widgetMap.get(widgetId)
|
|
1366
|
+
const mergedPosition = { ...(existing?.position || {}), ...position }
|
|
1367
|
+
|
|
1368
|
+
appendEvent(filePath, { event: 'widget_moved', timestamp: ts, widgetId, position: mergedPosition })
|
|
1369
|
+
|
|
1370
|
+
if (existing) existing.position = mergedPosition
|
|
1371
|
+
|
|
1372
|
+
results.push({ index: i, op: 'move-widget', widgetId, success: true })
|
|
1373
|
+
break
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
case 'delete-widget': {
|
|
1377
|
+
const widgetId = resolveRef(op.widgetId)
|
|
1378
|
+
if (!widgetId) throw new Error('widgetId is required')
|
|
1379
|
+
if (!widgetIds.has(widgetId)) throw new Error(`Widget "${widgetId}" not found`)
|
|
1380
|
+
|
|
1381
|
+
appendEvent(filePath, { event: 'widget_removed', timestamp: ts, widgetId })
|
|
1382
|
+
|
|
1383
|
+
widgetIds.delete(widgetId)
|
|
1384
|
+
widgetMap.delete(widgetId)
|
|
1385
|
+
|
|
1386
|
+
results.push({ index: i, op: 'delete-widget', widgetId, success: true })
|
|
1387
|
+
break
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
case 'create-connector': {
|
|
1391
|
+
const startWidgetId = resolveRef(op.startWidgetId)
|
|
1392
|
+
const endWidgetId = resolveRef(op.endWidgetId)
|
|
1393
|
+
const { startAnchor = 'right', endAnchor = 'left', connectorType = 'default', ref } = op
|
|
1394
|
+
|
|
1395
|
+
if (!startWidgetId || !endWidgetId) throw new Error('startWidgetId and endWidgetId are required')
|
|
1396
|
+
if (!validAnchors.includes(startAnchor) || !validAnchors.includes(endAnchor)) {
|
|
1397
|
+
throw new Error(`Anchors must be one of: ${validAnchors.join(', ')}`)
|
|
1398
|
+
}
|
|
1399
|
+
if (startWidgetId === endWidgetId) throw new Error('Cannot connect a widget to itself')
|
|
1400
|
+
if (!widgetIds.has(startWidgetId)) throw new Error(`Widget "${startWidgetId}" not found`)
|
|
1401
|
+
if (!widgetIds.has(endWidgetId)) throw new Error(`Widget "${endWidgetId}" not found`)
|
|
1402
|
+
|
|
1403
|
+
const connectorId = generateWidgetId('connector')
|
|
1404
|
+
const connector = {
|
|
1405
|
+
id: connectorId,
|
|
1406
|
+
type: 'connector',
|
|
1407
|
+
connectorType,
|
|
1408
|
+
start: { widgetId: startWidgetId, anchor: startAnchor },
|
|
1409
|
+
end: { widgetId: endWidgetId, anchor: endAnchor },
|
|
1410
|
+
meta: {},
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
appendEvent(filePath, { event: 'connector_added', timestamp: ts, connector })
|
|
1414
|
+
|
|
1415
|
+
connectorIds.add(connectorId)
|
|
1416
|
+
connectorMap.set(connectorId, connector)
|
|
1417
|
+
refs[String(i)] = connectorId
|
|
1418
|
+
if (ref) refs[ref] = connectorId
|
|
1419
|
+
|
|
1420
|
+
results.push({ index: i, op: 'create-connector', ref: ref || undefined, connectorId, success: true })
|
|
1421
|
+
break
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
case 'delete-connector': {
|
|
1425
|
+
const connectorId = resolveRef(op.connectorId)
|
|
1426
|
+
if (!connectorId) throw new Error('connectorId is required')
|
|
1427
|
+
if (!connectorIds.has(connectorId)) throw new Error(`Connector "${connectorId}" not found`)
|
|
1428
|
+
|
|
1429
|
+
appendEvent(filePath, { event: 'connector_removed', timestamp: ts, connectorId })
|
|
1430
|
+
connectorIds.delete(connectorId)
|
|
1431
|
+
connectorMap.delete(connectorId)
|
|
1432
|
+
|
|
1433
|
+
results.push({ index: i, op: 'delete-connector', connectorId, success: true })
|
|
1434
|
+
break
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
case 'update-connector': {
|
|
1438
|
+
const connectorId = resolveRef(op.connectorId)
|
|
1439
|
+
const { meta } = op
|
|
1440
|
+
if (!connectorId) throw new Error('connectorId is required')
|
|
1441
|
+
if (!meta) throw new Error('meta is required')
|
|
1442
|
+
if (!connectorIds.has(connectorId)) throw new Error(`Connector "${connectorId}" not found`)
|
|
1443
|
+
|
|
1444
|
+
appendEvent(filePath, { event: 'connector_updated', timestamp: ts, connectorId, updates: { meta } })
|
|
1445
|
+
|
|
1446
|
+
const existing = connectorMap.get(connectorId)
|
|
1447
|
+
if (existing) existing.meta = { ...(existing.meta || {}), ...meta }
|
|
1448
|
+
|
|
1449
|
+
results.push({ index: i, op: 'update-connector', connectorId, success: true })
|
|
1450
|
+
break
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
case 'broadcast': {
|
|
1454
|
+
const wId = resolveRef(op.widgetId)
|
|
1455
|
+
const mode = op.mode || 'two-way'
|
|
1456
|
+
const passThrough = !!op.passThrough
|
|
1457
|
+
if (!wId) throw new Error('widgetId is required')
|
|
1458
|
+
if (!widgetIds.has(wId)) throw new Error(`Widget "${wId}" not found`)
|
|
1459
|
+
|
|
1460
|
+
const isTerminalType = (w) => w && (w.type === 'terminal' || w.type === 'agent')
|
|
1461
|
+
const allConnectors = [...connectorMap.values()]
|
|
1462
|
+
const affectedConnectorIds = new Set()
|
|
1463
|
+
const affectedWidgetIds = new Set([wId])
|
|
1464
|
+
|
|
1465
|
+
if (passThrough) {
|
|
1466
|
+
const visited = new Set([wId])
|
|
1467
|
+
const queue = [wId]
|
|
1468
|
+
while (queue.length > 0) {
|
|
1469
|
+
const current = queue.shift()
|
|
1470
|
+
for (const conn of allConnectors) {
|
|
1471
|
+
let peerId = null
|
|
1472
|
+
if (conn.start?.widgetId === current && conn.end?.widgetId) peerId = conn.end.widgetId
|
|
1473
|
+
if (conn.end?.widgetId === current && conn.start?.widgetId) peerId = conn.start.widgetId
|
|
1474
|
+
if (!peerId || visited.has(peerId)) continue
|
|
1475
|
+
const peer = widgetMap.get(peerId)
|
|
1476
|
+
if (!isTerminalType(peer)) continue
|
|
1477
|
+
affectedConnectorIds.add(conn.id)
|
|
1478
|
+
affectedWidgetIds.add(peerId)
|
|
1479
|
+
visited.add(peerId)
|
|
1480
|
+
queue.push(peerId)
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
} else {
|
|
1484
|
+
for (const conn of allConnectors) {
|
|
1485
|
+
let peerId = null
|
|
1486
|
+
if (conn.start?.widgetId === wId && conn.end?.widgetId) peerId = conn.end.widgetId
|
|
1487
|
+
if (conn.end?.widgetId === wId && conn.start?.widgetId) peerId = conn.start.widgetId
|
|
1488
|
+
if (!peerId) continue
|
|
1489
|
+
const peer = widgetMap.get(peerId)
|
|
1490
|
+
if (!isTerminalType(peer)) continue
|
|
1491
|
+
affectedConnectorIds.add(conn.id)
|
|
1492
|
+
affectedWidgetIds.add(peerId)
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
const messagingMode = mode === 'none' ? null : mode
|
|
1497
|
+
for (const connId of affectedConnectorIds) {
|
|
1498
|
+
appendEvent(filePath, { event: 'connector_updated', timestamp: ts, connectorId: connId, updates: { meta: { messagingMode } } })
|
|
1499
|
+
const conn = connectorMap.get(connId)
|
|
1500
|
+
if (conn) conn.meta = { ...(conn.meta || {}), messagingMode }
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
results.push({
|
|
1504
|
+
index: i, op: 'broadcast',
|
|
1505
|
+
affectedConnectors: [...affectedConnectorIds],
|
|
1506
|
+
affectedWidgets: [...affectedWidgetIds],
|
|
1507
|
+
success: true,
|
|
1508
|
+
})
|
|
1509
|
+
break
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
default:
|
|
1513
|
+
throw new Error(`Unknown operation "${op.op}"`)
|
|
1514
|
+
}
|
|
1515
|
+
} catch (opErr) {
|
|
1516
|
+
// Fail-fast: push what we have so far, then return the error
|
|
1517
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1518
|
+
sendJson(res, 400, {
|
|
1519
|
+
success: false,
|
|
1520
|
+
error: `Operation ${i} (${op.op}) failed: ${opErr.message}`,
|
|
1521
|
+
failedAt: i,
|
|
1522
|
+
results,
|
|
1523
|
+
refs,
|
|
1524
|
+
})
|
|
1525
|
+
return
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
sendJson(res, 200, { success: true, results, refs })
|
|
1530
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
1531
|
+
} catch (err) {
|
|
1532
|
+
sendJson(res, 500, { error: `Batch failed: ${err.message}` })
|
|
1533
|
+
}
|
|
1534
|
+
return
|
|
1535
|
+
}
|
|
1536
|
+
|
|
574
1537
|
// PUT /rename-page — rename a canvas page file
|
|
575
1538
|
if (routePath === '/rename-page' && method === 'PUT') {
|
|
576
1539
|
const { name, newTitle } = body
|
|
@@ -1258,15 +2221,25 @@ export function Default() {
|
|
|
1258
2221
|
const now = new Date()
|
|
1259
2222
|
const pad = (n) => String(n).padStart(2, '0')
|
|
1260
2223
|
const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}--${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`
|
|
2224
|
+
const suffix = `-${Math.random().toString(36).slice(2, 6)}`
|
|
1261
2225
|
const prefix = canvasName ? `${canvasName.replace(/[/:]/g, '--')}--` : ''
|
|
1262
2226
|
|
|
1263
2227
|
// Support explicit filename for snapshot uploads (stable naming)
|
|
2228
|
+
// and cropped image uploads (user-initiated crop)
|
|
1264
2229
|
const explicitName = body.filename
|
|
1265
2230
|
let filename
|
|
1266
2231
|
if (explicitName && /^snapshot-[a-z0-9_-]+--(latest|light|dark)\.webp$/i.test(explicitName)) {
|
|
1267
2232
|
filename = explicitName
|
|
2233
|
+
} else if (explicitName && /--cropped--\d{4}-\d{2}-\d{2}--\d{2}-\d{2}-\d{2}\.\w+$/.test(explicitName)) {
|
|
2234
|
+
// Cropped image: validate format, strip path traversal
|
|
2235
|
+
const safeName = explicitName.replace(/[/\\]/g, '')
|
|
2236
|
+
if (safeName === explicitName && !explicitName.includes('..')) {
|
|
2237
|
+
filename = explicitName
|
|
2238
|
+
} else {
|
|
2239
|
+
filename = `${prefix}${dateStr}${suffix}.${ext}`
|
|
2240
|
+
}
|
|
1268
2241
|
} else {
|
|
1269
|
-
filename = `${prefix}${dateStr}.${ext}`
|
|
2242
|
+
filename = `${prefix}${dateStr}${suffix}.${ext}`
|
|
1270
2243
|
}
|
|
1271
2244
|
const targetDir = resolveWriteDir(canvasName || '')
|
|
1272
2245
|
|
|
@@ -1316,7 +2289,48 @@ export function Default() {
|
|
|
1316
2289
|
return
|
|
1317
2290
|
}
|
|
1318
2291
|
|
|
1319
|
-
// POST /image/
|
|
2292
|
+
// POST /image/duplicate — copy an image file with a new timestamped name
|
|
2293
|
+
if (routePath === '/image/duplicate' && method === 'POST') {
|
|
2294
|
+
const { filename } = body
|
|
2295
|
+
|
|
2296
|
+
if (!filename || typeof filename !== 'string') {
|
|
2297
|
+
sendJson(res, 400, { error: 'filename is required' })
|
|
2298
|
+
return
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
|
2302
|
+
sendJson(res, 400, { error: 'Invalid filename' })
|
|
2303
|
+
return
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
const sourcePath = resolveImagePath(filename)
|
|
2307
|
+
if (!sourcePath) {
|
|
2308
|
+
sendJson(res, 404, { error: 'Image not found' })
|
|
2309
|
+
return
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
try {
|
|
2313
|
+
const ext = path.extname(filename)
|
|
2314
|
+
const now = new Date()
|
|
2315
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
2316
|
+
const dateStr = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}--${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`
|
|
2317
|
+
// Preserve privacy prefix
|
|
2318
|
+
const prefix = filename.startsWith('~') ? '~' : ''
|
|
2319
|
+
const baseName = filename.replace(/^~/, '').replace(ext, '')
|
|
2320
|
+
// Extract canvas prefix (everything before the date pattern or the full base)
|
|
2321
|
+
const canvasMatch = baseName.match(/^(.+?--)\d{4}-/)
|
|
2322
|
+
const canvasPrefix = canvasMatch ? canvasMatch[1] : ''
|
|
2323
|
+
const newFilename = `${prefix}${canvasPrefix}${dateStr}${ext}`
|
|
2324
|
+
const targetDir = path.dirname(sourcePath)
|
|
2325
|
+
fs.copyFileSync(sourcePath, path.join(targetDir, newFilename))
|
|
2326
|
+
sendJson(res, 201, { success: true, filename: newFilename })
|
|
2327
|
+
} catch (err) {
|
|
2328
|
+
sendJson(res, 500, { error: `Failed to duplicate image: ${err.message}` })
|
|
2329
|
+
}
|
|
2330
|
+
return
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
// POST /image/toggle-private — toggle tilde prefix on image filename
|
|
1320
2334
|
if (routePath === '/image/toggle-private' && method === 'POST') {
|
|
1321
2335
|
const { filename } = body
|
|
1322
2336
|
|
|
@@ -1330,8 +2344,8 @@ export function Default() {
|
|
|
1330
2344
|
return
|
|
1331
2345
|
}
|
|
1332
2346
|
|
|
1333
|
-
const isPrivate = filename.startsWith('
|
|
1334
|
-
const newFilename = isPrivate ? filename.slice(1) :
|
|
2347
|
+
const isPrivate = filename.startsWith('~')
|
|
2348
|
+
const newFilename = isPrivate ? filename.slice(1) : `~${filename}`
|
|
1335
2349
|
const oldPath = resolveImagePath(filename)
|
|
1336
2350
|
if (!oldPath) {
|
|
1337
2351
|
sendJson(res, 404, { error: 'Image not found' })
|
|
@@ -1349,6 +2363,771 @@ export function Default() {
|
|
|
1349
2363
|
return
|
|
1350
2364
|
}
|
|
1351
2365
|
|
|
2366
|
+
// ── Agent Signal API ──────────────────────────────────────────────────
|
|
2367
|
+
|
|
2368
|
+
// POST /agent/signal — agent signals status (done/error/running)
|
|
2369
|
+
if (routePath === '/agent/signal' && method === 'POST') {
|
|
2370
|
+
const { widgetId, canvasId, branch, status, message, data: payload } = body
|
|
2371
|
+
|
|
2372
|
+
if (!widgetId || !status) {
|
|
2373
|
+
sendJson(res, 400, { error: 'widgetId and status are required' })
|
|
2374
|
+
return
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
const validStatuses = ['done', 'error', 'running']
|
|
2378
|
+
if (!validStatuses.includes(status)) {
|
|
2379
|
+
sendJson(res, 400, { error: `status must be one of: ${validStatuses.join(', ')}` })
|
|
2380
|
+
return
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
try {
|
|
2384
|
+
const { updateAgentStatus, initTerminalConfig } = await import('./terminal-config.js')
|
|
2385
|
+
initTerminalConfig(root)
|
|
2386
|
+
updateAgentStatus({
|
|
2387
|
+
branch: branch || 'unknown',
|
|
2388
|
+
canvasId: canvasId || 'unknown',
|
|
2389
|
+
widgetId,
|
|
2390
|
+
status,
|
|
2391
|
+
message: message || null,
|
|
2392
|
+
data: payload || null,
|
|
2393
|
+
})
|
|
2394
|
+
|
|
2395
|
+
// Push status to canvas clients via Vite WS custom event
|
|
2396
|
+
if (__viteWs) {
|
|
2397
|
+
__viteWs.send({
|
|
2398
|
+
type: 'custom',
|
|
2399
|
+
event: 'storyboard:agent-status',
|
|
2400
|
+
data: { widgetId, canvasId, status, message, timestamp: new Date().toISOString() },
|
|
2401
|
+
})
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
sendJson(res, 200, { success: true, status })
|
|
2405
|
+
} catch (err) {
|
|
2406
|
+
sendJson(res, 500, { error: `Failed to update agent status: ${err.message}` })
|
|
2407
|
+
}
|
|
2408
|
+
return
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
// GET /agent/status — poll agent status for a widget
|
|
2412
|
+
if (routePath === '/agent/status' && method === 'GET') {
|
|
2413
|
+
const url = new URL(req.url, 'http://localhost')
|
|
2414
|
+
const widgetId = url.searchParams.get('widgetId')
|
|
2415
|
+
const canvasId = url.searchParams.get('canvasId') || 'unknown'
|
|
2416
|
+
const branch = url.searchParams.get('branch') || 'unknown'
|
|
2417
|
+
|
|
2418
|
+
if (!widgetId) {
|
|
2419
|
+
sendJson(res, 400, { error: 'widgetId query parameter is required' })
|
|
2420
|
+
return
|
|
2421
|
+
}
|
|
2422
|
+
|
|
2423
|
+
try {
|
|
2424
|
+
const { readTerminalConfig, initTerminalConfig } = await import('./terminal-config.js')
|
|
2425
|
+
initTerminalConfig(root)
|
|
2426
|
+
const config = readTerminalConfig({ branch, canvasId, widgetId })
|
|
2427
|
+
sendJson(res, 200, { agentStatus: config?.agentStatus || null })
|
|
2428
|
+
} catch (err) {
|
|
2429
|
+
sendJson(res, 500, { error: `Failed to read agent status: ${err.message}` })
|
|
2430
|
+
}
|
|
2431
|
+
return
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
// POST /agent/spawn — spawn a headless agent session
|
|
2435
|
+
if (routePath === '/agent/spawn' && method === 'POST') {
|
|
2436
|
+
const { canvasId, widgetId, prompt, autopilot = true, branch: reqBranch } = body
|
|
2437
|
+
|
|
2438
|
+
if (!canvasId || !widgetId || !prompt) {
|
|
2439
|
+
sendJson(res, 400, { error: 'canvasId, widgetId, and prompt are required' })
|
|
2440
|
+
return
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
try {
|
|
2444
|
+
const { execSync } = await import('node:child_process')
|
|
2445
|
+
const { writeTerminalConfig, updateAgentStatus, initTerminalConfig } = await import('./terminal-config.js')
|
|
2446
|
+
const { generateTmuxName, registerSession } = await import('./terminal-registry.js')
|
|
2447
|
+
const fsModule = await import('node:fs')
|
|
2448
|
+
|
|
2449
|
+
initTerminalConfig(root)
|
|
2450
|
+
|
|
2451
|
+
let branch = reqBranch || 'unknown'
|
|
2452
|
+
try {
|
|
2453
|
+
branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
|
|
2454
|
+
} catch {}
|
|
2455
|
+
|
|
2456
|
+
const tmuxName = generateTmuxName(branch, canvasId, widgetId)
|
|
2457
|
+
|
|
2458
|
+
// Register in session registry
|
|
2459
|
+
registerSession({ branch, canvasId, widgetId, prettyName: null })
|
|
2460
|
+
|
|
2461
|
+
// Write terminal config with connected widget context
|
|
2462
|
+
writeTerminalConfig({ branch, canvasId, widgetId })
|
|
2463
|
+
|
|
2464
|
+
// Mark as running
|
|
2465
|
+
updateAgentStatus({ branch, canvasId, widgetId, status: 'running', message: 'Agent spawning...' })
|
|
2466
|
+
|
|
2467
|
+
// Push running status to clients
|
|
2468
|
+
if (__viteWs) {
|
|
2469
|
+
__viteWs.send({
|
|
2470
|
+
type: 'custom',
|
|
2471
|
+
event: 'storyboard:agent-status',
|
|
2472
|
+
data: { widgetId, canvasId, status: 'running', timestamp: new Date().toISOString() },
|
|
2473
|
+
})
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
// Build server URL for agent env vars
|
|
2477
|
+
const serverUrl = `http://localhost:${req.socket?.localPort || 1234}`
|
|
2478
|
+
|
|
2479
|
+
// Create headless tmux session
|
|
2480
|
+
try {
|
|
2481
|
+
execSync(`tmux new-session -d -s "${tmuxName}" -c "${root}"`, { stdio: 'ignore' })
|
|
2482
|
+
execSync(`tmux set-option -t "${tmuxName}" status off`, { stdio: 'ignore' })
|
|
2483
|
+
execSync(`tmux set-option -t "${tmuxName}" mouse on`, { stdio: 'ignore' })
|
|
2484
|
+
execSync(`tmux set-option -t "${tmuxName}" set-clipboard off`, { stdio: 'ignore' })
|
|
2485
|
+
} catch (err) {
|
|
2486
|
+
// Session may already exist
|
|
2487
|
+
devLog().logEvent('warn', 'tmux session create failed', { tmuxName, error: err.message })
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
// Set environment variables at tmux session level (inherited by new panes)
|
|
2491
|
+
const envMap = {
|
|
2492
|
+
STORYBOARD_WIDGET_ID: widgetId,
|
|
2493
|
+
STORYBOARD_CANVAS_ID: canvasId,
|
|
2494
|
+
STORYBOARD_BRANCH: branch,
|
|
2495
|
+
STORYBOARD_SERVER_URL: serverUrl,
|
|
2496
|
+
}
|
|
2497
|
+
for (const [key, val] of Object.entries(envMap)) {
|
|
2498
|
+
execSync(`tmux setenv -t "${tmuxName}" ${key} "${val}"`, { stdio: 'ignore' })
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
// Write env file for this terminal session — sourced before copilot launch
|
|
2502
|
+
// This avoids race conditions with tmux send-keys export
|
|
2503
|
+
const envFile = join(root, '.storyboard', 'terminals', `${tmuxName}.env`)
|
|
2504
|
+
const envContent = Object.entries(envMap).map(([k, v]) => `export ${k}=${JSON.stringify(v)}`).join('\n') + '\n'
|
|
2505
|
+
fsModule.writeFileSync(envFile, envContent)
|
|
2506
|
+
|
|
2507
|
+
// Build command from widgets.config.json (prompt mode) or storyboard.config.json (interactive)
|
|
2508
|
+
let copilotCmd
|
|
2509
|
+
if (autopilot) {
|
|
2510
|
+
copilotCmd = buildPromptCmd({ prompt, envFile })
|
|
2511
|
+
if (!copilotCmd) {
|
|
2512
|
+
const execution = getPromptExecution()
|
|
2513
|
+
sendJson(res, 400, { error: `Default agent "${execution?.default || 'unknown'}" has no prompt command configured` })
|
|
2514
|
+
return
|
|
2515
|
+
}
|
|
2516
|
+
} else {
|
|
2517
|
+
// Interactive mode — read startupCommand from storyboard.config.json
|
|
2518
|
+
let startupCmd = 'copilot'
|
|
2519
|
+
try {
|
|
2520
|
+
const configPath = path.join(root, 'storyboard.config.json')
|
|
2521
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
|
2522
|
+
const agents = config?.canvas?.agents || {}
|
|
2523
|
+
const defaultAgent = Object.values(agents).find(a => a.default) || Object.values(agents)[0]
|
|
2524
|
+
if (defaultAgent?.startupCommand) startupCmd = defaultAgent.startupCommand
|
|
2525
|
+
} catch {}
|
|
2526
|
+
copilotCmd = `source ${envFile} && ${startupCmd}`
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
setTimeout(() => {
|
|
2530
|
+
try {
|
|
2531
|
+
execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(copilotCmd)}`, { stdio: 'ignore' })
|
|
2532
|
+
execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
|
|
2533
|
+
} catch (err) {
|
|
2534
|
+
devLog().logEvent('warn', 'Failed to launch copilot', { tmuxName, error: err.message })
|
|
2535
|
+
}
|
|
2536
|
+
// Poll for copilot readiness, then send /autopilot + Enter once
|
|
2537
|
+
let sent = false
|
|
2538
|
+
const poll = setInterval(() => {
|
|
2539
|
+
if (sent) { clearInterval(poll); return }
|
|
2540
|
+
try {
|
|
2541
|
+
const pane = execSync(`tmux capture-pane -t "${tmuxName}" -p`, { encoding: 'utf8', timeout: 1000 })
|
|
2542
|
+
if (pane.includes('Environment loaded:')) {
|
|
2543
|
+
sent = true
|
|
2544
|
+
clearInterval(poll)
|
|
2545
|
+
setTimeout(() => {
|
|
2546
|
+
try {
|
|
2547
|
+
execSync(`tmux send-keys -t "${tmuxName}" -l "/allow-all on"`, { stdio: 'ignore' })
|
|
2548
|
+
execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
|
|
2549
|
+
} catch {}
|
|
2550
|
+
}, 500)
|
|
2551
|
+
}
|
|
2552
|
+
} catch {}
|
|
2553
|
+
}, 1000)
|
|
2554
|
+
setTimeout(() => { if (!sent) { sent = true; clearInterval(poll) } }, 15000)
|
|
2555
|
+
}, 500)
|
|
2556
|
+
|
|
2557
|
+
// Set up idle timeout (5 minutes)
|
|
2558
|
+
const IDLE_TIMEOUT = 5 * 60 * 1000
|
|
2559
|
+
setTimeout(async () => {
|
|
2560
|
+
try {
|
|
2561
|
+
const { readTerminalConfig } = await import('./terminal-config.js')
|
|
2562
|
+
const config = readTerminalConfig({ branch, canvasId, widgetId })
|
|
2563
|
+
if (config?.agentStatus?.status === 'running') {
|
|
2564
|
+
updateAgentStatus({ branch, canvasId, widgetId, status: 'error', message: 'Agent timed out (5 min idle)' })
|
|
2565
|
+
if (__viteWs) {
|
|
2566
|
+
__viteWs.send({
|
|
2567
|
+
type: 'custom',
|
|
2568
|
+
event: 'storyboard:agent-status',
|
|
2569
|
+
data: { widgetId, canvasId, status: 'error', message: 'Agent timed out', timestamp: new Date().toISOString() },
|
|
2570
|
+
})
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
} catch {}
|
|
2574
|
+
}, IDLE_TIMEOUT)
|
|
2575
|
+
|
|
2576
|
+
sendJson(res, 200, { success: true, tmuxName, status: 'running' })
|
|
2577
|
+
} catch (err) {
|
|
2578
|
+
sendJson(res, 500, { error: `Failed to spawn agent: ${err.message}` })
|
|
2579
|
+
}
|
|
2580
|
+
return
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
// POST /agent/peek — reconnect a headless agent session to a visible terminal widget
|
|
2584
|
+
if (routePath === '/agent/peek' && method === 'POST') {
|
|
2585
|
+
const { widgetId, canvasId } = body
|
|
2586
|
+
|
|
2587
|
+
if (!widgetId) {
|
|
2588
|
+
sendJson(res, 400, { error: 'widgetId is required' })
|
|
2589
|
+
return
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
try {
|
|
2593
|
+
const { execSync } = await import('node:child_process')
|
|
2594
|
+
const { generateTmuxName } = await import('./terminal-registry.js')
|
|
2595
|
+
|
|
2596
|
+
let branch = 'unknown'
|
|
2597
|
+
try {
|
|
2598
|
+
branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
|
|
2599
|
+
} catch {}
|
|
2600
|
+
|
|
2601
|
+
const tmuxName = generateTmuxName(branch, canvasId || 'unknown', widgetId)
|
|
2602
|
+
|
|
2603
|
+
// Check if the tmux session exists
|
|
2604
|
+
try {
|
|
2605
|
+
execSync(`tmux has-session -t "${tmuxName}"`, { stdio: 'ignore' })
|
|
2606
|
+
} catch {
|
|
2607
|
+
sendJson(res, 404, { error: `No tmux session found for widget ${widgetId}` })
|
|
2608
|
+
return
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
// The session exists — return info so the client can create a terminal widget
|
|
2612
|
+
// that connects to it
|
|
2613
|
+
sendJson(res, 200, {
|
|
2614
|
+
success: true,
|
|
2615
|
+
tmuxName,
|
|
2616
|
+
widgetId,
|
|
2617
|
+
canvasId: canvasId || 'unknown',
|
|
2618
|
+
message: 'Session is alive. Create a terminal widget to connect.',
|
|
2619
|
+
})
|
|
2620
|
+
} catch (err) {
|
|
2621
|
+
sendJson(res, 500, { error: `Failed to peek agent session: ${err.message}` })
|
|
2622
|
+
}
|
|
2623
|
+
return
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
// ── Terminal Messaging API ──────────────────────────────────────────
|
|
2627
|
+
|
|
2628
|
+
// POST /terminal/send — send a message to a terminal via tmux send-keys
|
|
2629
|
+
if (routePath === '/terminal/send' && method === 'POST') {
|
|
2630
|
+
const { widgetId: targetWidgetId, message, from: senderWidgetId } = body
|
|
2631
|
+
|
|
2632
|
+
if (!targetWidgetId || !message) {
|
|
2633
|
+
sendJson(res, 400, { error: 'widgetId and message are required' })
|
|
2634
|
+
return
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
try {
|
|
2638
|
+
const { execSync } = await import('node:child_process')
|
|
2639
|
+
const { findTmuxNameForWidget } = await import('./terminal-registry.js')
|
|
2640
|
+
const { readTerminalConfigById, updatePendingMessages, initTerminalConfig } = await import('./terminal-config.js')
|
|
2641
|
+
|
|
2642
|
+
initTerminalConfig(root)
|
|
2643
|
+
|
|
2644
|
+
const tmuxName = findTmuxNameForWidget(targetWidgetId)
|
|
2645
|
+
if (!tmuxName) {
|
|
2646
|
+
sendJson(res, 404, { error: `No active session for widget ${targetWidgetId}` })
|
|
2647
|
+
return
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
// Check session is live (widget open in browser)
|
|
2651
|
+
const { getSession } = await import('./terminal-registry.js')
|
|
2652
|
+
const session = getSession(tmuxName)
|
|
2653
|
+
const isLive = session?.status === 'live'
|
|
2654
|
+
|
|
2655
|
+
// Resolve sender display name
|
|
2656
|
+
let senderName = senderWidgetId || 'unknown'
|
|
2657
|
+
if (senderWidgetId) {
|
|
2658
|
+
try {
|
|
2659
|
+
const senderConfig = readTerminalConfigById(senderWidgetId)
|
|
2660
|
+
if (senderConfig?.displayName) senderName = senderConfig.displayName
|
|
2661
|
+
} catch { /* use widgetId as fallback */ }
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
// Deterministic agent detection: get the pane's shell PID, then
|
|
2665
|
+
// check its child process. Known agent CLIs (copilot, claude) run
|
|
2666
|
+
// as direct children of the shell. We match against the process
|
|
2667
|
+
// name (comm) to identify which agent is running.
|
|
2668
|
+
let runningAgent = null // null = no agent, 'copilot' | 'claude' | 'codex' = which one
|
|
2669
|
+
try {
|
|
2670
|
+
const panePid = execSync(
|
|
2671
|
+
`tmux list-panes -t "${tmuxName}" -F '#{pane_pid}'`,
|
|
2672
|
+
{ encoding: 'utf8', timeout: 2000 }
|
|
2673
|
+
).trim()
|
|
2674
|
+
if (panePid && isLive) {
|
|
2675
|
+
const children = execSync(
|
|
2676
|
+
`ps -o comm= -p $(pgrep -P ${panePid} 2>/dev/null | tr '\\n' ',') 2>/dev/null || true`,
|
|
2677
|
+
{ encoding: 'utf8', timeout: 2000 }
|
|
2678
|
+
).trim().split('\n').map(s => s.trim()).filter(Boolean)
|
|
2679
|
+
for (const cmd of children) {
|
|
2680
|
+
if (cmd === 'copilot') { runningAgent = 'copilot'; break }
|
|
2681
|
+
if (cmd === 'claude') { runningAgent = 'claude'; break }
|
|
2682
|
+
if (cmd === 'codex') { runningAgent = 'codex'; break }
|
|
2683
|
+
}
|
|
2684
|
+
}
|
|
2685
|
+
} catch { /* tmux/ps not available */ }
|
|
2686
|
+
|
|
2687
|
+
const isAgentRunning = runningAgent !== null
|
|
2688
|
+
|
|
2689
|
+
if (isAgentRunning) {
|
|
2690
|
+
// Agent is running — send the full message directly (like a chat bubble)
|
|
2691
|
+
const formatted = `📩 ${senderName}: ${message}`
|
|
2692
|
+
|
|
2693
|
+
try {
|
|
2694
|
+
execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(formatted)}`, { stdio: 'ignore' })
|
|
2695
|
+
execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
|
|
2696
|
+
} catch (err) {
|
|
2697
|
+
sendJson(res, 500, { error: `Failed to send via tmux: ${err.message}` })
|
|
2698
|
+
return
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
sendJson(res, 200, { success: true, delivered: true })
|
|
2702
|
+
} else {
|
|
2703
|
+
// Shell prompt or unknown — queue the message
|
|
2704
|
+
updatePendingMessages(targetWidgetId, {
|
|
2705
|
+
from: senderWidgetId || null,
|
|
2706
|
+
fromName: senderName,
|
|
2707
|
+
message,
|
|
2708
|
+
createdAt: new Date().toISOString(),
|
|
2709
|
+
})
|
|
2710
|
+
|
|
2711
|
+
sendJson(res, 200, { success: true, queued: true })
|
|
2712
|
+
}
|
|
2713
|
+
} catch (err) {
|
|
2714
|
+
sendJson(res, 500, { error: `Failed to send message: ${err.message}` })
|
|
2715
|
+
}
|
|
2716
|
+
return
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
// POST /terminal/output — save latest output to terminal config
|
|
2720
|
+
if (routePath === '/terminal/output' && method === 'POST') {
|
|
2721
|
+
const { widgetId: outputWidgetId, content, summary } = body
|
|
2722
|
+
|
|
2723
|
+
if (!outputWidgetId) {
|
|
2724
|
+
sendJson(res, 400, { error: 'widgetId is required' })
|
|
2725
|
+
return
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
try {
|
|
2729
|
+
const { updateLatestOutput, initTerminalConfig } = await import('./terminal-config.js')
|
|
2730
|
+
initTerminalConfig(root)
|
|
2731
|
+
|
|
2732
|
+
updateLatestOutput(outputWidgetId, {
|
|
2733
|
+
content: content || '',
|
|
2734
|
+
summary: summary || '',
|
|
2735
|
+
updatedAt: new Date().toISOString(),
|
|
2736
|
+
})
|
|
2737
|
+
|
|
2738
|
+
sendJson(res, 200, { success: true })
|
|
2739
|
+
} catch (err) {
|
|
2740
|
+
sendJson(res, 500, { error: `Failed to save output: ${err.message}` })
|
|
2741
|
+
}
|
|
2742
|
+
return
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
// POST /prompt/spawn — spawn a prompt agent session (acquires from hot pool)
|
|
2746
|
+
if (routePath === '/prompt/spawn' && method === 'POST') {
|
|
2747
|
+
const { canvasId, widgetId, prompt } = body
|
|
2748
|
+
|
|
2749
|
+
if (!canvasId || !widgetId || !prompt) {
|
|
2750
|
+
sendJson(res, 400, { error: 'canvasId, widgetId, and prompt are required' })
|
|
2751
|
+
return
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
// Try to acquire a warm tmux session from the prompt pool
|
|
2755
|
+
const warmSession = hotPool?.acquire('prompt') || null
|
|
2756
|
+
|
|
2757
|
+
// Delegate to agent/spawn — the prompt widget is just a specialized agent
|
|
2758
|
+
// We reuse the same tmux-based infrastructure
|
|
2759
|
+
try {
|
|
2760
|
+
const { execSync } = await import('node:child_process')
|
|
2761
|
+
const { writeTerminalConfig, updateAgentStatus, updateTerminalConnections, initTerminalConfig } = await import('./terminal-config.js')
|
|
2762
|
+
const { generateTmuxName, registerSession } = await import('./terminal-registry.js')
|
|
2763
|
+
const fsModule = await import('node:fs')
|
|
2764
|
+
|
|
2765
|
+
initTerminalConfig(root)
|
|
2766
|
+
|
|
2767
|
+
let branch = 'unknown'
|
|
2768
|
+
try {
|
|
2769
|
+
branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
|
|
2770
|
+
} catch {}
|
|
2771
|
+
|
|
2772
|
+
const serverUrl = `http://localhost:${req.socket?.localPort || 1234}`
|
|
2773
|
+
const tmuxName = generateTmuxName(branch, canvasId, widgetId)
|
|
2774
|
+
|
|
2775
|
+
registerSession({ branch, canvasId, widgetId, prettyName: null })
|
|
2776
|
+
writeTerminalConfig({ branch, canvasId, widgetId, serverUrl, tmuxName })
|
|
2777
|
+
updateAgentStatus({ branch, canvasId, widgetId, status: 'running', message: 'Prompt agent spawning...' })
|
|
2778
|
+
|
|
2779
|
+
// Resolve connected widgets so the terminal-agent has context
|
|
2780
|
+
try {
|
|
2781
|
+
const canvasFilePath = findCanvasPath(root, canvasId)
|
|
2782
|
+
if (canvasFilePath) {
|
|
2783
|
+
const canvasData = readCanvas(canvasFilePath)
|
|
2784
|
+
const widgetMap = new Map((canvasData.widgets || []).map(w => [w.id, w]))
|
|
2785
|
+
const connectors = canvasData.connectors || []
|
|
2786
|
+
const connectedIds = new Set()
|
|
2787
|
+
for (const conn of connectors) {
|
|
2788
|
+
if (conn.start?.widgetId === widgetId) connectedIds.add(conn.end?.widgetId)
|
|
2789
|
+
if (conn.end?.widgetId === widgetId) connectedIds.add(conn.start?.widgetId)
|
|
2790
|
+
}
|
|
2791
|
+
connectedIds.delete(undefined)
|
|
2792
|
+
connectedIds.delete(null)
|
|
2793
|
+
const connectedWidgets = [...connectedIds]
|
|
2794
|
+
.map(id => widgetMap.get(id))
|
|
2795
|
+
.filter(Boolean)
|
|
2796
|
+
.map(w => ({ id: w.id, type: w.type, props: w.props, position: w.position }))
|
|
2797
|
+
if (connectedWidgets.length > 0) {
|
|
2798
|
+
updateTerminalConnections({ branch, canvasId, widgetId, connectedWidgets })
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
} catch (err) {
|
|
2802
|
+
devLog().logEvent('warn', 'Failed to resolve prompt connections', { error: err.message })
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
if (__viteWs) {
|
|
2806
|
+
__viteWs.send({
|
|
2807
|
+
type: 'custom',
|
|
2808
|
+
event: 'storyboard:agent-status',
|
|
2809
|
+
data: { widgetId, canvasId, status: 'running', timestamp: new Date().toISOString() },
|
|
2810
|
+
})
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
// If we got a warm tmux session, rename it to the canonical name.
|
|
2814
|
+
// Otherwise, create a fresh tmux session from scratch.
|
|
2815
|
+
let usedWarm = false
|
|
2816
|
+
if (warmSession?.tmuxName) {
|
|
2817
|
+
try {
|
|
2818
|
+
// Kill any existing session with the canonical name first
|
|
2819
|
+
try { execSync(`tmux kill-session -t "${tmuxName}" 2>/dev/null`, { stdio: 'ignore' }) } catch {}
|
|
2820
|
+
// Rename the warm session to the canonical name
|
|
2821
|
+
execSync(`tmux rename-session -t "${warmSession.tmuxName}" "${tmuxName}"`, { stdio: 'ignore' })
|
|
2822
|
+
usedWarm = true
|
|
2823
|
+
hotPool.consume('prompt', warmSession.id)
|
|
2824
|
+
} catch {
|
|
2825
|
+
// Rename failed — fall back to creating fresh session
|
|
2826
|
+
hotPool.release('prompt', warmSession.id)
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
if (!usedWarm) {
|
|
2831
|
+
// Fresh tmux session (cold path)
|
|
2832
|
+
try {
|
|
2833
|
+
execSync(`tmux -f /dev/null new-session -d -s "${tmuxName}" -c "${root}"`, { stdio: 'ignore' })
|
|
2834
|
+
execSync(`tmux set-option -t "${tmuxName}" status off`, { stdio: 'ignore' })
|
|
2835
|
+
execSync(`tmux set-option -t "${tmuxName}" set-clipboard off 2>/dev/null`, { stdio: 'ignore' })
|
|
2836
|
+
} catch { /* session may already exist */ }
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
// Set env vars — use send-keys to export into the running shell
|
|
2840
|
+
const envMap = {
|
|
2841
|
+
STORYBOARD_WIDGET_ID: widgetId,
|
|
2842
|
+
STORYBOARD_CANVAS_ID: canvasId,
|
|
2843
|
+
STORYBOARD_BRANCH: branch,
|
|
2844
|
+
STORYBOARD_SERVER_URL: serverUrl,
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
// Write env file for the copilot command to source
|
|
2848
|
+
const { join } = await import('node:path')
|
|
2849
|
+
const envFile = join(root, '.storyboard', 'terminals', `${tmuxName}.env`)
|
|
2850
|
+
const envContent = Object.entries(envMap).map(([k, v]) => `export ${k}=${JSON.stringify(v)}`).join('\n') + '\n'
|
|
2851
|
+
fsModule.writeFileSync(envFile, envContent)
|
|
2852
|
+
|
|
2853
|
+
const copilotCmd = buildPromptCmd({ prompt, envFile })
|
|
2854
|
+
if (!copilotCmd) {
|
|
2855
|
+
const execution = getPromptExecution()
|
|
2856
|
+
sendJson(res, 400, { error: `Default agent "${execution?.default || 'unknown'}" has no prompt command configured` })
|
|
2857
|
+
return
|
|
2858
|
+
}
|
|
2859
|
+
|
|
2860
|
+
// Send the copilot command — warm sessions have a shell ready, no delay needed
|
|
2861
|
+
const delay = usedWarm ? 0 : 500
|
|
2862
|
+
const displayName = (() => {
|
|
2863
|
+
try {
|
|
2864
|
+
const canvasFilePath = findCanvasPath(root, canvasId)
|
|
2865
|
+
if (!canvasFilePath) return null
|
|
2866
|
+
const canvasData = readCanvas(canvasFilePath)
|
|
2867
|
+
const w = (canvasData.widgets || []).find(w => w.id === widgetId)
|
|
2868
|
+
return w?.props?.prettyName || null
|
|
2869
|
+
} catch { return null }
|
|
2870
|
+
})()
|
|
2871
|
+
const sendCmd = () => {
|
|
2872
|
+
try {
|
|
2873
|
+
execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(copilotCmd)}`, { stdio: 'ignore' })
|
|
2874
|
+
execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
|
|
2875
|
+
} catch {}
|
|
2876
|
+
// Inject identity after the agent command starts
|
|
2877
|
+
setTimeout(() => {
|
|
2878
|
+
const configFile = `.storyboard/terminals/${widgetId}.json`
|
|
2879
|
+
const msg = `[System] Your terminal identity has been set. widgetId=${widgetId} displayName=${displayName || widgetId} canvasId=${canvasId} configFile=${configFile} serverUrl=${serverUrl} — this is a configuration step, no response needed.`
|
|
2880
|
+
try {
|
|
2881
|
+
execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(msg)}`, { stdio: 'ignore' })
|
|
2882
|
+
execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
|
|
2883
|
+
} catch {}
|
|
2884
|
+
}, 3000)
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
if (delay > 0) {
|
|
2888
|
+
setTimeout(sendCmd, delay)
|
|
2889
|
+
} else {
|
|
2890
|
+
sendCmd()
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
// Idle timeout (5 min)
|
|
2894
|
+
setTimeout(async () => {
|
|
2895
|
+
try {
|
|
2896
|
+
const { readTerminalConfig } = await import('./terminal-config.js')
|
|
2897
|
+
const cfg = readTerminalConfig({ branch, canvasId, widgetId })
|
|
2898
|
+
if (cfg?.agentStatus?.status === 'running') {
|
|
2899
|
+
updateAgentStatus({ branch, canvasId, widgetId, status: 'error', message: 'Prompt timed out (5 min)' })
|
|
2900
|
+
if (__viteWs) {
|
|
2901
|
+
__viteWs.send({
|
|
2902
|
+
type: 'custom',
|
|
2903
|
+
event: 'storyboard:agent-status',
|
|
2904
|
+
data: { widgetId, canvasId, status: 'error', message: 'Prompt timed out', timestamp: new Date().toISOString() },
|
|
2905
|
+
})
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
} catch {}
|
|
2909
|
+
}, 5 * 60 * 1000)
|
|
2910
|
+
|
|
2911
|
+
sendJson(res, 200, { success: true, tmuxName, status: 'running', warm: usedWarm })
|
|
2912
|
+
} catch (err) {
|
|
2913
|
+
sendJson(res, 500, { error: `Failed to spawn prompt agent: ${err.message}` })
|
|
2914
|
+
}
|
|
2915
|
+
return
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
// POST /terminal/kill — kill a terminal/prompt tmux session
|
|
2919
|
+
if (routePath === '/terminal/kill' && method === 'POST') {
|
|
2920
|
+
const { widgetId: targetWidgetId } = body
|
|
2921
|
+
|
|
2922
|
+
if (!targetWidgetId) {
|
|
2923
|
+
sendJson(res, 400, { error: 'widgetId is required' })
|
|
2924
|
+
return
|
|
2925
|
+
}
|
|
2926
|
+
|
|
2927
|
+
try {
|
|
2928
|
+
const { findTmuxNameForWidget, killSession } = await import('./terminal-registry.js')
|
|
2929
|
+
const { updateAgentStatus, initTerminalConfig } = await import('./terminal-config.js')
|
|
2930
|
+
|
|
2931
|
+
initTerminalConfig(root)
|
|
2932
|
+
|
|
2933
|
+
const tmuxName = findTmuxNameForWidget(targetWidgetId)
|
|
2934
|
+
if (!tmuxName) {
|
|
2935
|
+
sendJson(res, 404, { error: `No active session for widget ${targetWidgetId}` })
|
|
2936
|
+
return
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
// Close any WS connections for this session
|
|
2940
|
+
const { orphanTerminalSession } = await import('./terminal-server.js')
|
|
2941
|
+
orphanTerminalSession(targetWidgetId)
|
|
2942
|
+
|
|
2943
|
+
// Kill the tmux session and clean up registry
|
|
2944
|
+
killSession(tmuxName)
|
|
2945
|
+
|
|
2946
|
+
// Update agent status
|
|
2947
|
+
const pathParts = req.url.split('/')
|
|
2948
|
+
const canvasIdx = pathParts.indexOf('canvas')
|
|
2949
|
+
let branch = 'unknown'
|
|
2950
|
+
try {
|
|
2951
|
+
const { execSync } = await import('node:child_process')
|
|
2952
|
+
branch = execSync('git branch --show-current', { encoding: 'utf8', cwd: root }).trim()
|
|
2953
|
+
} catch {}
|
|
2954
|
+
|
|
2955
|
+
try {
|
|
2956
|
+
updateAgentStatus({ branch, canvasId: 'unknown', widgetId: targetWidgetId, status: 'cancelled', message: 'Cancelled by user' })
|
|
2957
|
+
} catch {}
|
|
2958
|
+
|
|
2959
|
+
// Notify via HMR
|
|
2960
|
+
if (__viteWs) {
|
|
2961
|
+
__viteWs.send({
|
|
2962
|
+
type: 'custom',
|
|
2963
|
+
event: 'storyboard:agent-status',
|
|
2964
|
+
data: { widgetId: targetWidgetId, status: 'cancelled', message: 'Cancelled by user', timestamp: new Date().toISOString() },
|
|
2965
|
+
})
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
sendJson(res, 200, { success: true, killed: tmuxName })
|
|
2969
|
+
} catch (err) {
|
|
2970
|
+
sendJson(res, 500, { error: `Failed to kill session: ${err.message}` })
|
|
2971
|
+
}
|
|
2972
|
+
return
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
// GET /terminal-buffer/:widgetId — read terminal buffer JSON
|
|
2976
|
+
// Accepts optional ?length=N query param to truncate scrollback
|
|
2977
|
+
if (routePath.startsWith('/terminal-buffer/') && method === 'GET') {
|
|
2978
|
+
const widgetId = routePath.slice('/terminal-buffer/'.length).split('?')[0]
|
|
2979
|
+
if (!widgetId || widgetId.includes('..') || widgetId.includes('/')) {
|
|
2980
|
+
sendJson(res, 400, { error: 'Invalid widgetId' })
|
|
2981
|
+
return
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
const urlObj = new URL(req.url, 'http://localhost')
|
|
2985
|
+
const lengthParam = urlObj.searchParams.get('length')
|
|
2986
|
+
const maxLength = lengthParam ? parseInt(lengthParam, 10) : undefined
|
|
2987
|
+
|
|
2988
|
+
try {
|
|
2989
|
+
const { readTerminalBuffer } = await import('./terminal-server.js')
|
|
2990
|
+
const buffer = readTerminalBuffer(widgetId, { maxLength: maxLength || undefined })
|
|
2991
|
+
if (buffer) {
|
|
2992
|
+
sendJson(res, 200, buffer)
|
|
2993
|
+
return
|
|
2994
|
+
}
|
|
2995
|
+
sendJson(res, 404, { error: 'Buffer not found' })
|
|
2996
|
+
} catch (err) {
|
|
2997
|
+
sendJson(res, 500, { error: `Failed to read buffer: ${err.message}` })
|
|
2998
|
+
}
|
|
2999
|
+
return
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
// GET /terminal-snapshot/:widgetId — read terminal snapshot JSON (new + legacy fallback)
|
|
3003
|
+
if (routePath.startsWith('/terminal-snapshot/') && method === 'GET') {
|
|
3004
|
+
const widgetId = routePath.slice('/terminal-snapshot/'.length)
|
|
3005
|
+
if (!widgetId || widgetId.includes('..') || widgetId.includes('/')) {
|
|
3006
|
+
sendJson(res, 400, { error: 'Invalid widgetId' })
|
|
3007
|
+
return
|
|
3008
|
+
}
|
|
3009
|
+
|
|
3010
|
+
try {
|
|
3011
|
+
const { readTerminalSnapshot } = await import('./terminal-server.js')
|
|
3012
|
+
|
|
3013
|
+
// Try new path first
|
|
3014
|
+
const snapshot = readTerminalSnapshot(widgetId)
|
|
3015
|
+
if (snapshot) {
|
|
3016
|
+
sendJson(res, 200, snapshot)
|
|
3017
|
+
return
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
// Legacy fallback: .storyboard/terminal-snapshots/<canvasDir>/<widgetId>.json
|
|
3021
|
+
const snapshotsRoot = path.join(root, '.storyboard', 'terminal-snapshots')
|
|
3022
|
+
if (fs.existsSync(snapshotsRoot)) {
|
|
3023
|
+
const dirs = fs.readdirSync(snapshotsRoot, { withFileTypes: true })
|
|
3024
|
+
for (const d of dirs) {
|
|
3025
|
+
if (!d.isDirectory()) continue
|
|
3026
|
+
const filePath = path.join(snapshotsRoot, d.name, `${widgetId}.json`)
|
|
3027
|
+
if (fs.existsSync(filePath)) {
|
|
3028
|
+
const data = fs.readFileSync(filePath, 'utf8')
|
|
3029
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
3030
|
+
res.end(data)
|
|
3031
|
+
return
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3035
|
+
sendJson(res, 404, { error: 'Snapshot not found' })
|
|
3036
|
+
} catch (err) {
|
|
3037
|
+
sendJson(res, 500, { error: `Failed to read snapshot: ${err.message}` })
|
|
3038
|
+
}
|
|
3039
|
+
return
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
// DELETE /delete-canvas — delete a canvas and its directory
|
|
3043
|
+
if (routePath === '/delete-canvas' && method === 'DELETE') {
|
|
3044
|
+
const { name } = body
|
|
3045
|
+
if (!name || typeof name !== 'string') {
|
|
3046
|
+
sendJson(res, 400, { error: 'Canvas name is required' })
|
|
3047
|
+
return
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
const filePath = findCanvasPath(root, name)
|
|
3051
|
+
if (!filePath) {
|
|
3052
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
3053
|
+
return
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
try {
|
|
3057
|
+
const dir = path.dirname(filePath)
|
|
3058
|
+
const canvasDir = path.join(root, 'src', 'canvas')
|
|
3059
|
+
|
|
3060
|
+
// Delete the canvas file
|
|
3061
|
+
fs.unlinkSync(filePath)
|
|
3062
|
+
|
|
3063
|
+
// If the parent directory is inside src/canvas/ and now empty (or only has .meta.json), remove it
|
|
3064
|
+
if (dir !== canvasDir) {
|
|
3065
|
+
const remaining = fs.readdirSync(dir).filter(f => !f.endsWith('.meta.json'))
|
|
3066
|
+
if (remaining.length === 0) {
|
|
3067
|
+
for (const f of fs.readdirSync(dir)) {
|
|
3068
|
+
fs.unlinkSync(path.join(dir, f))
|
|
3069
|
+
}
|
|
3070
|
+
fs.rmdirSync(dir)
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
sendJson(res, 200, { success: true, deleted: name })
|
|
3075
|
+
} catch (err) {
|
|
3076
|
+
sendJson(res, 500, { error: `Failed to delete canvas: ${err.message}` })
|
|
3077
|
+
}
|
|
3078
|
+
return
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
// PUT /update-meta — update canvas metadata
|
|
3082
|
+
if (routePath === '/update-meta' && method === 'PUT') {
|
|
3083
|
+
const { name, title, description, author } = body
|
|
3084
|
+
if (!name || typeof name !== 'string') {
|
|
3085
|
+
sendJson(res, 400, { error: 'Canvas name is required' })
|
|
3086
|
+
return
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
const filePath = findCanvasPath(root, name)
|
|
3090
|
+
if (!filePath) {
|
|
3091
|
+
sendJson(res, 404, { error: `Canvas "${name}" not found` })
|
|
3092
|
+
return
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
try {
|
|
3096
|
+
// Try to find and update .meta.json first
|
|
3097
|
+
const dir = path.dirname(filePath)
|
|
3098
|
+
const dirName = path.basename(dir).replace(/\.folder$/, '')
|
|
3099
|
+
const metaPath = path.join(dir, `${dirName}.meta.json`)
|
|
3100
|
+
|
|
3101
|
+
if (fs.existsSync(metaPath)) {
|
|
3102
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'))
|
|
3103
|
+
if (title !== undefined) meta.title = title
|
|
3104
|
+
if (description !== undefined) meta.description = description
|
|
3105
|
+
if (author !== undefined) meta.author = author
|
|
3106
|
+
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf-8')
|
|
3107
|
+
} else {
|
|
3108
|
+
// Update the canvas JSONL's canvas_created event metadata
|
|
3109
|
+
const text = fs.readFileSync(filePath, 'utf-8')
|
|
3110
|
+
const lines = text.split('\n').filter(Boolean)
|
|
3111
|
+
if (lines.length > 0) {
|
|
3112
|
+
const firstEvent = JSON.parse(lines[0])
|
|
3113
|
+
if (title !== undefined) firstEvent.title = title
|
|
3114
|
+
if (description !== undefined) firstEvent.description = description
|
|
3115
|
+
if (author !== undefined) firstEvent.author = author
|
|
3116
|
+
lines[0] = JSON.stringify(firstEvent)
|
|
3117
|
+
fs.writeFileSync(filePath, lines.join('\n') + '\n', 'utf-8')
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
// Notify via WebSocket
|
|
3122
|
+
pushCanvasUpdate(name, filePath, __viteWs)
|
|
3123
|
+
|
|
3124
|
+
sendJson(res, 200, { success: true, updated: name })
|
|
3125
|
+
} catch (err) {
|
|
3126
|
+
sendJson(res, 500, { error: `Failed to update canvas metadata: ${err.message}` })
|
|
3127
|
+
}
|
|
3128
|
+
return
|
|
3129
|
+
}
|
|
3130
|
+
|
|
1352
3131
|
sendJson(res, 404, { error: `Unknown route: ${method} ${routePath}` })
|
|
1353
3132
|
}
|
|
1354
3133
|
}
|