@dfosco/storyboard-core 4.2.0-alpha.6 → 4.2.0-alpha.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-core",
3
- "version": "4.2.0-alpha.6",
3
+ "version": "4.2.0-alpha.8",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -0,0 +1,144 @@
1
+ ---
2
+ name: terminal-agent
3
+ description: "Canvas-aware terminal agent that reads connected widget context and signals completion via the storyboard API."
4
+ tools:
5
+ - read
6
+ - edit
7
+ - shell
8
+ - search
9
+ ---
10
+
11
+ # Terminal Agent Context
12
+
13
+ Before processing ANY user prompt, read the terminal config file for this session.
14
+
15
+ ## Step 1: Read terminal config
16
+
17
+ Your widget ID is available via `$STORYBOARD_WIDGET_ID`. Use it to read your config directly:
18
+ ```bash
19
+ cat .storyboard/terminals/${STORYBOARD_WIDGET_ID}.json
20
+ ```
21
+
22
+ If the env var is empty, source it from the terminal env file first:
23
+ ```bash
24
+ # Find the env file for this tmux session
25
+ ENV_FILE=$(ls -t .storyboard/terminals/*.env 2>/dev/null | head -1)
26
+ if [ -n "$ENV_FILE" ]; then source "$ENV_FILE"; fi
27
+ cat .storyboard/terminals/${STORYBOARD_WIDGET_ID}.json
28
+ ```
29
+
30
+ As a last resort, list all configs and pick the most recent non-deleted one with `connectedWidgets`:
31
+ ```bash
32
+ cat .storyboard/terminals/*.json
33
+ ```
34
+
35
+ The config file contains everything you need — no additional API calls required:
36
+
37
+ ```json
38
+ {
39
+ "widgetId": "terminal-abc123",
40
+ "canvasId": "storyboarding/my-canvas",
41
+ "branch": "4.2.0--terminal-agents",
42
+ "worktree": "4.2.0--terminal-agents",
43
+ "devDomain": "storyboard-core",
44
+ "serverUrl": "http://localhost:1269",
45
+ "workingDirectory": "/path/to/worktree",
46
+ "connectedWidgets": [
47
+ {
48
+ "id": "sticky-def456",
49
+ "type": "sticky-note",
50
+ "props": { "text": "Build a login form", "color": "yellow" }
51
+ },
52
+ {
53
+ "id": "markdown-ghi789",
54
+ "type": "markdown",
55
+ "props": { "content": "# Requirements\n- Email + password\n- OAuth support" }
56
+ }
57
+ ]
58
+ }
59
+ ```
60
+
61
+ ## Step 2: Use connected widget context
62
+
63
+ The `connectedWidgets` array contains the FULL props of every widget connected to this terminal. This is your highest priority context:
64
+
65
+ - **sticky-note**: `props.text` — instructions, notes, or requirements
66
+ - **markdown**: `props.content` — documentation, specs, or prose
67
+ - **image**: `props.src` — image filename at `assets/canvas/images/{props.src}`
68
+ - **story**: `props.storyId` + `props.exportName` — component to work with
69
+ - **link-preview**: `props.url` — external reference
70
+ - **prototype**: `props.src` — prototype path
71
+
72
+ Interpret the user's prompt in light of these connected widgets.
73
+
74
+ ## Step 3: Prefer CLI commands for canvas operations
75
+
76
+ **Always prefer `npx storyboard` CLI commands over HTTP API calls.** CLI commands run directly in the worktree and resolve the dev server automatically — no port numbers or URLs needed.
77
+
78
+ ### Reading canvas state
79
+ ```bash
80
+ npx storyboard canvas read <canvas-name> --json
81
+ npx storyboard canvas read <canvas-name> --id <widget-id> --json
82
+ ```
83
+
84
+ ### Updating a widget
85
+ ```bash
86
+ # Update text on a sticky note
87
+ npx storyboard canvas update <widget-id> --canvas <canvas-name> --text "New text"
88
+
89
+ # Update markdown content
90
+ npx storyboard canvas update <widget-id> --canvas <canvas-name> --content "# New heading"
91
+
92
+ # Update arbitrary props
93
+ npx storyboard canvas update <widget-id> --canvas <canvas-name> --props '{"key":"value"}'
94
+
95
+ # Move a widget
96
+ npx storyboard canvas update <widget-id> --canvas <canvas-name> --x 100 --y 200
97
+
98
+ # Shorthand flags: --text, --content, --src, --url, --color
99
+ ```
100
+
101
+ ### Adding a widget
102
+ ```bash
103
+ npx storyboard canvas add sticky-note --canvas <canvas-name> --props '{"text":"Hello"}'
104
+ npx storyboard canvas add markdown --canvas <canvas-name> --x 100 --y 200
105
+ ```
106
+
107
+ **Why CLI over API:** The CLI resolves the correct dev server port automatically via the Caddy proxy or ports.json. You never need to know the port number. All commands work from any worktree directory.
108
+
109
+ ## Step 4: Signal completion
110
+
111
+ When your task is complete:
112
+ ```bash
113
+ npx storyboard agent signal --status done --message "Brief summary"
114
+ ```
115
+
116
+ On failure:
117
+ ```bash
118
+ npx storyboard agent signal --status error --message "What went wrong"
119
+ ```
120
+
121
+ **IMPORTANT:**
122
+ - NEVER write directly to `.canvas.jsonl` files — use the canvas CLI or server API
123
+ - **Prefer CLI commands** (`npx storyboard canvas ...`) over direct HTTP calls — they resolve ports automatically
124
+ - Only fall back to HTTP API (`{serverUrl}/_storyboard/canvas/`) if the CLI doesn't support the operation
125
+ - Environment variables `$STORYBOARD_WIDGET_ID`, `$STORYBOARD_CANVAS_ID`, `$STORYBOARD_BRANCH`, `$STORYBOARD_SERVER_URL` are also available in the shell
126
+
127
+ ## HTTP API Reference (fallback only)
128
+
129
+ If the CLI fails, use these endpoints. The `serverUrl` is in your terminal config or `$STORYBOARD_SERVER_URL`.
130
+
131
+ ### Safe: Update a single widget (PATCH)
132
+ ```bash
133
+ curl -s -X PATCH "${STORYBOARD_SERVER_URL}/_storyboard/canvas/widget" \
134
+ -H "Content-Type: application/json" \
135
+ -d '{"name":"<canvasId>","widgetId":"<widgetId>","props":{"text":"new value"}}'
136
+ ```
137
+
138
+ ### Safe: Read canvas state (GET)
139
+ ```bash
140
+ curl -s "${STORYBOARD_SERVER_URL}/_storyboard/canvas/<canvasId>"
141
+ ```
142
+
143
+ ### ⚠️ NEVER use `PUT /_storyboard/canvas/update` with a `widgets` array
144
+ That endpoint **replaces ALL widgets** in the canvas. Sending one widget = deleting everything else.
@@ -15,7 +15,8 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, symlink
15
15
  import { join, dirname } from 'node:path'
16
16
  import { createHash } from 'node:crypto'
17
17
  import { execSync } from 'node:child_process'
18
- import { getPort, detectWorktreeName } from '../worktree/port.js'
18
+ import { findByWorktree } from '../worktree/serverRegistry.js'
19
+ import { detectWorktreeName } from '../worktree/port.js'
19
20
 
20
21
  const TERMINALS_DIR = '.storyboard/terminals'
21
22
 
@@ -70,7 +71,7 @@ function atomicWrite(filePath, data) {
70
71
  * Write or update a terminal config file.
71
72
  * Called when a terminal widget is created or reconnected.
72
73
  */
73
- export function writeTerminalConfig({ branch, canvasId, widgetId, canvasFile = null }) {
74
+ export function writeTerminalConfig({ branch, canvasId, widgetId, canvasFile = null, serverUrl = null }) {
74
75
  const fp = configPath(branch, canvasId, widgetId)
75
76
  const dir = dirname(fp)
76
77
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
@@ -82,9 +83,16 @@ export function writeTerminalConfig({ branch, canvasId, widgetId, canvasFile = n
82
83
 
83
84
  const worktree = getWorktreeName()
84
85
  const devDomain = readDevDomain()
85
- let serverPort = 1234
86
- try { serverPort = getPort(detectWorktreeName()) } catch {}
87
- const serverUrl = `http://localhost:${serverPort}`
86
+
87
+ // Resolve server URL: use passed value, or query server registry, or default
88
+ if (!serverUrl) {
89
+ try {
90
+ const name = detectWorktreeName()
91
+ const servers = findByWorktree(name)
92
+ if (servers.length > 0) serverUrl = `http://localhost:${servers[0].port}`
93
+ } catch {}
94
+ }
95
+ if (!serverUrl) serverUrl = 'http://localhost:1234'
88
96
 
89
97
  const config = {
90
98
  ...existing,
@@ -46,7 +46,8 @@ import {
46
46
  writeTerminalConfig as writeTermConfig,
47
47
  initTerminalConfig,
48
48
  } from './terminal-config.js'
49
- import { getPort, detectWorktreeName } from '../worktree/port.js'
49
+ import { findByWorktree } from '../worktree/serverRegistry.js'
50
+ import { detectWorktreeName } from '../worktree/port.js'
50
51
 
51
52
  let pty
52
53
  try {
@@ -86,6 +87,9 @@ const wsConnections = new Map()
86
87
  /** Branch name for this worktree, set during setup */
87
88
  let currentBranch = 'unknown'
88
89
 
90
+ /** Actual server port, resolved from httpServer at setup time */
91
+ let actualServerPort = null
92
+
89
93
  /** Check if a tmux session with the given name exists */
90
94
  function tmuxSessionExists(name) {
91
95
  try {
@@ -151,6 +155,18 @@ export function setupTerminalServer(httpServer, base = '/', branch = 'unknown')
151
155
 
152
156
  currentBranch = branch
153
157
 
158
+ // Capture the actual port from the running HTTP server
159
+ try {
160
+ const addr = httpServer.address()
161
+ if (addr && addr.port) actualServerPort = addr.port
162
+ } catch {}
163
+
164
+ // Ensure node-pty spawn-helper has execute permission (npm install can strip it)
165
+ try {
166
+ const nodePtyDir = resolve(process.cwd(), 'node_modules/node-pty/prebuilds')
167
+ execSync(`chmod +x "${nodePtyDir}"/darwin-*/spawn-helper 2>/dev/null || true`, { stdio: 'ignore' })
168
+ } catch {}
169
+
154
170
  // Initialize registry and terminal config
155
171
  const root = process.cwd()
156
172
  const termCfg = readTerminalConfig()
@@ -197,7 +213,7 @@ function handleConnection(ws, widgetId, canvasId, prettyName) {
197
213
  const { entry, conflict } = registerSession({ branch, canvasId, widgetId, prettyName })
198
214
 
199
215
  // Write terminal config for agent context
200
- writeTermConfig({ branch, canvasId, widgetId })
216
+ writeTermConfig({ branch, canvasId, widgetId, serverUrl })
201
217
 
202
218
  // Close any existing WS for this session (one viewer at a time)
203
219
  const existingWs = wsConnections.get(tmuxName)
@@ -226,9 +242,19 @@ function handleConnection(ws, widgetId, canvasId, prettyName) {
226
242
  writeFileSync(join(zdotdir, '.zshrc'), `export PS1='${prompt.replace(/'/g, "'\\''")}'\nunset RPS1\n`)
227
243
  } catch { /* best effort */ }
228
244
 
229
- // Derive server URL for agents to call back
230
- let serverPort = '1234'
231
- try { serverPort = String(getPort(detectWorktreeName())) } catch {}
245
+ // Resolve server URL deterministically:
246
+ // 1. Use the actual port from httpServer (set at setup time)
247
+ // 2. Fall back to server registry (tracks running dev servers)
248
+ // 3. Last resort: default port 1234
249
+ let serverPort = actualServerPort
250
+ if (!serverPort) {
251
+ try {
252
+ const name = detectWorktreeName()
253
+ const servers = findByWorktree(name)
254
+ if (servers.length > 0) serverPort = servers[0].port
255
+ } catch {}
256
+ }
257
+ if (!serverPort) serverPort = 1234
232
258
  const serverUrl = `http://localhost:${serverPort}`
233
259
 
234
260
  const env = {
@@ -250,6 +276,7 @@ function handleConnection(ws, widgetId, canvasId, prettyName) {
250
276
  let ptyProcess
251
277
  let isNewSession = false
252
278
 
279
+ try {
253
280
  if (hasTmux) {
254
281
  const reattach = tmuxSessionExists(tmuxName)
255
282
 
@@ -333,6 +360,15 @@ function handleConnection(ws, widgetId, canvasId, prettyName) {
333
360
  env,
334
361
  })
335
362
  }
363
+ } catch (spawnErr) {
364
+ console.error(`[storyboard] terminal spawn failed: ${spawnErr.message}`)
365
+ if (ws.readyState === ws.OPEN) {
366
+ ws.send(`\r\n\x1b[31m✖ Terminal failed to start: ${spawnErr.message}\x1b[0m\r\n`)
367
+ ws.send(`\x1b[2mTry: chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper\x1b[0m\r\n`)
368
+ ws.close()
369
+ }
370
+ return
371
+ }
336
372
 
337
373
  const generation = entry.generation
338
374
  ptyProcesses.set(tmuxName, ptyProcess)
package/src/cli/exit.js CHANGED
@@ -1,38 +1,37 @@
1
1
  /**
2
- * storyboard exit — Stop the Caddy proxy and all running dev servers.
2
+ * storyboard exit — Stop all running dev servers and the Caddy proxy.
3
3
  */
4
4
 
5
5
  import * as p from '@clack/prompts'
6
- import { execSync } from 'child_process'
6
+ import { list, unregister } from '../worktree/serverRegistry.js'
7
+ import { isCaddyRunning, stopCaddy } from './proxy.js'
7
8
 
8
9
  p.intro('storyboard exit')
9
10
 
10
- // 1. Stop all Vite dev servers started by storyboard
11
- try {
12
- const psOutput = execSync('ps aux', { encoding: 'utf8' })
13
- const vitePids = psOutput
14
- .split('\n')
15
- .filter((line) => line.includes('vite') && line.includes('VITE_BASE_PATH'))
16
- .map((line) => line.trim().split(/\s+/)[1])
17
- .filter(Boolean)
18
-
19
- if (vitePids.length > 0) {
20
- for (const pid of vitePids) {
21
- try { process.kill(Number(pid), 'SIGTERM') } catch { /* already dead */ }
22
- }
23
- p.log.success(`Stopped ${vitePids.length} dev server${vitePids.length > 1 ? 's' : ''}`)
24
- } else {
25
- p.log.info('No dev servers running')
11
+ // 1. Stop all registered dev servers
12
+ const servers = list()
13
+ if (servers.length > 0) {
14
+ let stopped = 0
15
+ for (const s of servers) {
16
+ try {
17
+ process.kill(s.pid, 'SIGTERM')
18
+ stopped++
19
+ } catch { /* already dead */ }
20
+ unregister(s.id)
26
21
  }
27
- } catch {
22
+ p.log.success(`Stopped ${stopped} dev server${stopped !== 1 ? 's' : ''}`)
23
+ } else {
28
24
  p.log.info('No dev servers running')
29
25
  }
30
26
 
31
- // 2. Stop Caddy proxy
32
- try {
33
- execSync('sudo caddy stop >/dev/null 2>&1', { stdio: ['inherit', 'pipe', 'pipe'] })
34
- p.log.success('Proxy stopped')
35
- } catch {
27
+ // 2. Stop Caddy proxy via admin API (no sudo needed)
28
+ if (isCaddyRunning()) {
29
+ if (stopCaddy()) {
30
+ p.log.success('Proxy stopped')
31
+ } else {
32
+ p.log.warning('Failed to stop proxy via admin API')
33
+ }
34
+ } else {
36
35
  p.log.info('Proxy was not running')
37
36
  }
38
37
 
package/src/cli/index.js CHANGED
@@ -53,6 +53,10 @@ function helpScreen(version) {
53
53
  ` ${bold(cyan('Development'))}`,
54
54
  cmd('dev', 'Start Vite dev server + update proxy'),
55
55
  cmd('dev [branch]', 'Start dev for a specific worktree/branch'),
56
+ cmd('server list', 'List running dev servers'),
57
+ cmd('server start [wt]', 'Start dev server for a worktree'),
58
+ cmd('server stop <wt|ID>', 'Stop a dev server'),
59
+ cmd('server stop-proxy', 'Stop Caddy proxy (no sudo)'),
56
60
  cmd('code [branch]', 'Open a worktree in VS Code'),
57
61
  cmd('exit', 'Stop all dev servers and proxy'),
58
62
  '',
package/src/cli/proxy.js CHANGED
@@ -222,6 +222,15 @@ export function startCaddy(caddyfilePath) {
222
222
  }
223
223
  }
224
224
 
225
+ export function stopCaddy() {
226
+ try {
227
+ execSync('curl -sf -X POST http://localhost:2019/stop', { timeout: 5000, stdio: 'ignore' })
228
+ return true
229
+ } catch {
230
+ return false
231
+ }
232
+ }
233
+
225
234
  // When run directly as `storyboard proxy` (not imported by setup.js)
226
235
  const isDirectRun = process.argv[2] === 'proxy'
227
236
  if (isDirectRun) {
package/src/cli/server.js CHANGED
@@ -1,35 +1,76 @@
1
1
  /**
2
- * storyboard server [branch] Start the persistent Storyboard dev server.
3
- *
4
- * Manages Vite child processes and serves the /_storyboard/ API.
5
- * If a branch is given, also starts Vite for that branch immediately.
2
+ * storyboard server — Dev server lifecycle management.
6
3
  *
7
4
  * Usage:
8
- * storyboard server # start server, detect branch from cwd
9
- * storyboard server main # start server + dev for main
10
- * storyboard server my-feature # start server + dev for my-feature
5
+ * storyboard server List running dev servers
6
+ * storyboard server list List running dev servers
7
+ * storyboard server start [wt] Start the persistent server + Vite for a worktree
8
+ * storyboard server stop <id> Stop a dev server by worktree name or ID
9
+ * storyboard server stop-proxy Stop the Caddy proxy (no sudo required)
11
10
  */
12
11
 
13
12
  import * as p from '@clack/prompts'
14
13
  import { startServer, SERVER_PORT, spawnViteForBranch } from '../server/index.js'
15
14
  import { parseFlags } from './flags.js'
16
- import { readDevDomain, generateCaddyfile, generateRouteConfig, upsertCaddyRoute, isCaddyRunning } from './proxy.js'
17
- import { detectWorktreeName, getPort } from '../worktree/port.js'
15
+ import { readDevDomain, generateCaddyfile, generateRouteConfig, upsertCaddyRoute, isCaddyRunning, stopCaddy } from './proxy.js'
16
+ import { detectWorktreeName, getPort, repoRoot } from '../worktree/port.js'
17
+ import {
18
+ list,
19
+ findByWorktree,
20
+ findById,
21
+ unregister,
22
+ prune,
23
+ } from '../worktree/serverRegistry.js'
18
24
 
19
25
  const flagSchema = {
20
26
  port: { type: 'number', description: 'Server port (default: 4100)' },
27
+ background: { type: 'boolean', default: false, description: 'Run in background (--bg alias)' },
28
+ bg: { type: 'boolean', default: false, description: 'Run in background' },
29
+ multiple: { type: 'boolean', default: false, description: 'Allow multiple servers per worktree' },
21
30
  }
22
31
 
23
- async function main() {
24
- const { flags, positional } = parseFlags(process.argv.slice(3), flagSchema)
25
- const port = flags.port || SERVER_PORT
26
- const branchArg = positional[0] || undefined
32
+ // ─── Commands ────────────────────────────────────────────
27
33
 
28
- p.intro('storyboard server')
34
+ function serverList() {
35
+ const servers = list()
36
+ if (servers.length === 0) {
37
+ p.log.info('No dev servers running.')
38
+ return
39
+ }
29
40
 
30
41
  const devDomain = readDevDomain()
31
42
 
32
- // Register server itself with Caddy so it's reachable at the dev domain
43
+ p.log.info('Running dev servers:\n')
44
+ for (const s of servers) {
45
+ const isMain = s.worktree === 'main'
46
+ const basePath = isMain ? '/' : `/branch--${s.worktree}/`
47
+ const proxyUrl = `http://${devDomain}${basePath}`
48
+ const bg = s.background ? ' (bg)' : ''
49
+ console.log(` ${s.id} ${s.worktree.padEnd(32)} :${String(s.port).padEnd(5)} PID ${String(s.pid).padEnd(7)} ${proxyUrl}${bg}`)
50
+ }
51
+ console.log()
52
+ }
53
+
54
+ async function serverStart(branchArg, flags) {
55
+ const { background, bg, multiple } = flags
56
+ const isBackground = background || bg
57
+ const worktreeName = branchArg || detectWorktreeName()
58
+
59
+ // Check for duplicate worktree servers
60
+ if (!multiple) {
61
+ const existing = findByWorktree(worktreeName)
62
+ if (existing.length > 0) {
63
+ p.log.error(`A dev server is already running for "${worktreeName}" (ID: ${existing[0].id}, PID: ${existing[0].pid}).`)
64
+ p.log.info(`To stop it: npx storyboard server stop ${existing[0].id}`)
65
+ p.log.info(`To allow multiple: npx storyboard server start ${worktreeName} --multiple`)
66
+ process.exit(1)
67
+ }
68
+ }
69
+
70
+ const devDomain = readDevDomain()
71
+ const port = flags.port || SERVER_PORT
72
+
73
+ // Register server itself with Caddy
33
74
  try {
34
75
  const serverRoute = generateRouteConfig({ __server__: port })
35
76
  if (isCaddyRunning()) {
@@ -39,31 +80,112 @@ async function main() {
39
80
 
40
81
  const server = startServer(port)
41
82
 
42
- // Determine initial branch to start
43
- const initialBranch = branchArg || detectWorktreeName()
44
- const isMain = initialBranch === 'main'
45
- const basePath = isMain ? '/' : `/branch--${initialBranch}/`
83
+ const isMain = worktreeName === 'main'
84
+ const basePath = isMain ? '/' : `/branch--${worktreeName}/`
46
85
  const proxyUrl = `http://${devDomain}${basePath}`
47
86
 
48
- p.log.step(`Starting dev session for ${initialBranch}...`)
87
+ p.log.step(`Starting dev session for ${worktreeName}...`)
49
88
 
50
89
  try {
51
- const entry = spawnViteForBranch(initialBranch)
52
-
53
- // Wait for ready
90
+ const entry = spawnViteForBranch(worktreeName)
54
91
  const { waitForPort } = await import('../server/index.js')
55
92
  const ready = await waitForPort(entry.port)
56
93
 
57
94
  if (ready) {
58
- p.log.success(proxyUrl)
95
+ p.log.success(`[${entry.serverId}] ${proxyUrl}`)
59
96
  } else {
60
97
  p.log.warning(`Vite started but may not be ready yet — check ${proxyUrl}`)
61
98
  }
62
99
  } catch (err) {
63
- p.log.error(`Failed to start dev for ${initialBranch}: ${err.message}`)
100
+ p.log.error(`Failed to start dev for ${worktreeName}: ${err.message}`)
64
101
  }
65
102
 
66
103
  p.outro('Server running')
67
104
  }
68
105
 
106
+ function serverStop(target) {
107
+ if (!target) {
108
+ p.log.error('Usage: storyboard server stop <worktree|ID>')
109
+ process.exit(1)
110
+ }
111
+
112
+ // Try by ID first
113
+ let entry = findById(target)
114
+
115
+ // Then by worktree name
116
+ if (!entry) {
117
+ const matches = findByWorktree(target)
118
+ if (matches.length === 0) {
119
+ p.log.error(`No server found for "${target}".`)
120
+ p.log.info('Run `npx storyboard server list` to see running servers.')
121
+ process.exit(1)
122
+ }
123
+ if (matches.length > 1) {
124
+ p.log.error(`Multiple servers running for worktree "${target}":`)
125
+ for (const s of matches) {
126
+ console.log(` ${s.id} PID: ${s.pid} Port: ${s.port}`)
127
+ }
128
+ p.log.info('Specify an ID: npx storyboard server stop <ID>')
129
+ process.exit(1)
130
+ }
131
+ entry = matches[0]
132
+ }
133
+
134
+ try {
135
+ process.kill(entry.pid, 'SIGTERM')
136
+ p.log.success(`Stopped server ${entry.id} (PID: ${entry.pid}, worktree: "${entry.worktree}")`)
137
+ } catch (err) {
138
+ if (err.code === 'ESRCH') {
139
+ p.log.info(`Server ${entry.id} (PID: ${entry.pid}) was already dead.`)
140
+ } else {
141
+ p.log.error(`Failed to kill PID ${entry.pid}: ${err.message}`)
142
+ }
143
+ }
144
+
145
+ unregister(entry.id)
146
+ }
147
+
148
+ function serverStopProxy() {
149
+ if (!isCaddyRunning()) {
150
+ p.log.info('Caddy proxy is not running.')
151
+ return
152
+ }
153
+
154
+ if (stopCaddy()) {
155
+ p.log.success('Caddy proxy stopped.')
156
+ } else {
157
+ p.log.error('Failed to stop Caddy proxy.')
158
+ process.exit(1)
159
+ }
160
+ }
161
+
162
+ // ─── Dispatch ────────────────────────────────────────────
163
+
164
+ async function main() {
165
+ const { flags, positional } = parseFlags(process.argv.slice(3), flagSchema)
166
+ const subcommand = positional[0]
167
+
168
+ p.intro('storyboard server')
169
+
170
+ switch (subcommand) {
171
+ case undefined:
172
+ case 'list':
173
+ serverList()
174
+ break
175
+ case 'start':
176
+ await serverStart(positional[1], flags)
177
+ break
178
+ case 'stop':
179
+ serverStop(positional[1])
180
+ break
181
+ case 'stop-proxy':
182
+ serverStopProxy()
183
+ break
184
+ default:
185
+ // Legacy behavior: treat argument as branch name (storyboard server <branch>)
186
+ await serverStart(subcommand, flags)
187
+ break
188
+ }
189
+ }
190
+
69
191
  main()
package/src/cli/setup.js CHANGED
@@ -188,7 +188,7 @@ if (isInstalled('code')) {
188
188
 
189
189
  // 7. Asset directories
190
190
  {
191
- const dirs = ['assets/canvas/images', '.storyboard']
191
+ const dirs = ['assets/canvas/images', '.storyboard', '.storyboard/terminals']
192
192
  for (const dir of dirs) {
193
193
  if (!existsSync(dir)) {
194
194
  try { mkdirSync(dir, { recursive: true }) } catch { /* ignore */ }
@@ -211,6 +211,30 @@ if (isInstalled('code')) {
211
211
  p.log.success('Canvas asset directories ready')
212
212
  }
213
213
 
214
+ // 7b. Copilot agents
215
+ {
216
+ const agentsDir = '.github/agents'
217
+ if (!existsSync(agentsDir)) {
218
+ try { mkdirSync(agentsDir, { recursive: true }) } catch { /* ignore */ }
219
+ }
220
+
221
+ const scaffoldAgents = path.resolve(import.meta.dirname, '..', '..', 'scaffold', 'agents')
222
+ if (existsSync(scaffoldAgents)) {
223
+ try {
224
+ const agentFiles = execSync(`ls "${scaffoldAgents}"`, { encoding: 'utf8' }).trim().split('\n').filter(Boolean)
225
+ for (const file of agentFiles) {
226
+ const dest = path.join(agentsDir, file)
227
+ if (!existsSync(dest)) {
228
+ run(`cp "${path.join(scaffoldAgents, file)}" "${dest}"`)
229
+ }
230
+ }
231
+ if (agentFiles.length > 0) {
232
+ p.log.success('Copilot agents scaffolded (.github/agents/)')
233
+ }
234
+ } catch { /* ignore */ }
235
+ }
236
+ }
237
+
214
238
  // 8. Proxy
215
239
  if (isCaddyInstalled()) {
216
240
  const proxySpin = p.spinner()
@@ -19,6 +19,7 @@ import { resolve, join, dirname } from 'node:path'
19
19
  import { getPort, portsFilePath, repoRoot, worktreeDir, listWorktrees } from '../worktree/port.js'
20
20
  import { generateCaddyfile, generateRouteConfig, upsertCaddyRoute, isCaddyRunning, reloadCaddy, readDevDomain } from '../cli/proxy.js'
21
21
  import { compactAll } from '../canvas/compact.js'
22
+ import { register, unregister, generateId } from '../worktree/serverRegistry.js'
22
23
 
23
24
  const SERVER_PORT_BASE = 4100
24
25
 
@@ -117,9 +118,12 @@ function spawnVite(branch) {
117
118
  stdio: ['ignore', 'pipe', 'pipe'],
118
119
  })
119
120
 
120
- const entry = { child, port, status: 'starting', cwd, branch }
121
+ const entry = { child, port, status: 'starting', cwd, branch, serverId: generateId() }
121
122
  processes.set(branch, entry)
122
123
 
124
+ // Register in persistent server registry
125
+ register({ id: entry.serverId, worktree: branch, pid: child.pid, port, background: true })
126
+
123
127
  // Detect ready state from stdout
124
128
  child.stdout.on('data', (data) => {
125
129
  const text = data.toString()
@@ -138,6 +142,7 @@ function spawnVite(branch) {
138
142
  child.on('exit', (code) => {
139
143
  entry.status = 'stopped'
140
144
  processes.delete(branch)
145
+ unregister(entry.serverId)
141
146
  })
142
147
 
143
148
  return entry
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Server Registry — tracks running dev servers in .storyboard/servers.json.
3
+ *
4
+ * Each server gets a unique hex ID. The registry is pruned on every read
5
+ * to remove entries whose PIDs are no longer alive.
6
+ */
7
+
8
+ import { randomBytes } from 'crypto'
9
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'fs'
10
+ import { join, dirname } from 'path'
11
+ import { repoRoot } from './port.js'
12
+
13
+ /**
14
+ * Absolute path to .storyboard/servers.json.
15
+ */
16
+ export function registryPath(cwd) {
17
+ return join(repoRoot(cwd), '.storyboard', 'servers.json')
18
+ }
19
+
20
+ /**
21
+ * Generate a short unique hex ID (6 chars).
22
+ */
23
+ export function generateId() {
24
+ return randomBytes(3).toString('hex')
25
+ }
26
+
27
+ /**
28
+ * Check whether a PID is alive.
29
+ */
30
+ function isAlive(pid) {
31
+ try {
32
+ process.kill(pid, 0)
33
+ return true
34
+ } catch {
35
+ return false
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Read the registry file. Returns an object keyed by server ID.
41
+ */
42
+ function readRegistry(cwd) {
43
+ const file = registryPath(cwd)
44
+ if (!existsSync(file)) return {}
45
+ try {
46
+ const data = JSON.parse(readFileSync(file, 'utf8'))
47
+ return data.servers || {}
48
+ } catch {
49
+ return {}
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Write the registry atomically (write tmp then rename).
55
+ */
56
+ function writeRegistry(servers, cwd) {
57
+ const file = registryPath(cwd)
58
+ const dir = dirname(file)
59
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
60
+ const tmp = file + '.tmp'
61
+ writeFileSync(tmp, JSON.stringify({ servers }, null, 2) + '\n')
62
+ renameSync(tmp, file)
63
+ }
64
+
65
+ /**
66
+ * Remove entries whose PIDs are dead.
67
+ * @returns {object} pruned servers map
68
+ */
69
+ export function prune(cwd) {
70
+ const servers = readRegistry(cwd)
71
+ const alive = {}
72
+ for (const [id, entry] of Object.entries(servers)) {
73
+ if (isAlive(entry.pid)) {
74
+ alive[id] = entry
75
+ }
76
+ }
77
+ writeRegistry(alive, cwd)
78
+ return alive
79
+ }
80
+
81
+ /**
82
+ * Register a new server entry.
83
+ */
84
+ export function register({ id, worktree, pid, port, background = false }, cwd) {
85
+ const servers = prune(cwd)
86
+ servers[id] = { id, worktree, pid, port, background, startedAt: new Date().toISOString() }
87
+ writeRegistry(servers, cwd)
88
+ return servers[id]
89
+ }
90
+
91
+ /**
92
+ * Unregister a server by ID.
93
+ */
94
+ export function unregister(id, cwd) {
95
+ const servers = readRegistry(cwd)
96
+ delete servers[id]
97
+ writeRegistry(servers, cwd)
98
+ }
99
+
100
+ /**
101
+ * List all live servers (prunes dead ones first).
102
+ */
103
+ export function list(cwd) {
104
+ return Object.values(prune(cwd))
105
+ }
106
+
107
+ /**
108
+ * Find servers running for a given worktree name.
109
+ */
110
+ export function findByWorktree(name, cwd) {
111
+ return list(cwd).filter((s) => s.worktree === name)
112
+ }
113
+
114
+ /**
115
+ * Find a server by its unique ID.
116
+ */
117
+ export function findById(id, cwd) {
118
+ const servers = prune(cwd)
119
+ return servers[id] || null
120
+ }