@dfosco/storyboard 0.6.0-beta.5 → 0.6.0-beta.7

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",
3
- "version": "0.6.0-beta.5",
3
+ "version": "0.6.0-beta.7",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -52,8 +52,18 @@ packages/core/dist/storyboard-ui.*
52
52
  # Agent Browser
53
53
  agent-browser.json
54
54
 
55
- # Selected widgets bridge (real-time canvas selection for Copilot)
56
- .storyboard
55
+ # Auto-generated scaffold dir (copies of library config defaults — overwritten on every dev-server boot)
56
+ .storyboard/scaffold/
57
+
58
+ # Real-time canvas selection bridge for Copilot
59
+ .storyboard/.selectedwidgets.json
60
+
61
+ # Runtime/transient state (per-machine, per-session)
62
+ .storyboard/agent-sessions/
63
+ .storyboard/hot-pool/
64
+ .storyboard/logs/
65
+ .storyboard/terminal-buffers/
66
+ .storyboard/messages/
57
67
 
58
68
  # Private canvas images (tilde prefix = not committed)
59
69
  src/canvas/images/~*
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Server-side reader for terminal + agents config.
3
+ *
4
+ * Replaces direct `JSON.parse(readFileSync('storyboard.config.json')).canvas.agents`
5
+ * reads in terminal-server / hot-pool / canvas server / terminal-welcome /
6
+ * server-plugin so the new `terminal.config.json` (and the library's default
7
+ * one shipped under `node_modules/@dfosco/storyboard/terminal.config.json`)
8
+ * is honored everywhere with leaf-level merge.
9
+ *
10
+ * Resolution order (lowest → highest priority), all leaf-merged:
11
+ * 1. Library default `<root>/{packages/storyboard,node_modules/@dfosco/storyboard}/terminal.config.json`
12
+ * 2. `storyboard.config.json` `canvas.terminal` + `canvas.agents` (legacy)
13
+ * 3. Root `terminal.config.json`
14
+ *
15
+ * Returns `{ terminal, agents, showAgentsInAddMenu }`. Empty objects when
16
+ * nothing is configured (rather than null) so callers can spread freely.
17
+ */
18
+
19
+ import { readFileSync, existsSync } from 'node:fs'
20
+ import { resolve, join } from 'node:path'
21
+
22
+ /** Same shape as data-plugin's `deepMergeBuild`. */
23
+ function deepMerge(target, source) {
24
+ if (!source || typeof source !== 'object') return target
25
+ if (!target || typeof target !== 'object') return source
26
+ const result = { ...target }
27
+ for (const key of Object.keys(source)) {
28
+ const sv = source[key]
29
+ const tv = target[key]
30
+ if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
31
+ result[key] = deepMerge(tv, sv)
32
+ } else {
33
+ result[key] = sv
34
+ }
35
+ }
36
+ return result
37
+ }
38
+
39
+ function readJson(filePath) {
40
+ try {
41
+ return JSON.parse(readFileSync(filePath, 'utf8'))
42
+ } catch {
43
+ return null
44
+ }
45
+ }
46
+
47
+ function resolveLibTerminalConfig(root) {
48
+ const candidates = [
49
+ join(root, 'packages', 'storyboard', 'terminal.config.json'),
50
+ join(root, 'node_modules', '@dfosco', 'storyboard', 'terminal.config.json'),
51
+ ]
52
+ for (const p of candidates) {
53
+ if (existsSync(p)) {
54
+ const parsed = readJson(p)
55
+ if (parsed) return parsed
56
+ }
57
+ }
58
+ return null
59
+ }
60
+
61
+ /**
62
+ * Read the merged terminal + agents + hotPool config for a project root.
63
+ *
64
+ * @param {string} [root] - Project root, defaults to `process.cwd()`.
65
+ * @returns {{ terminal: object, agents: object, showAgentsInAddMenu: boolean|undefined, hotPool: object }}
66
+ */
67
+ export function readTerminalConfigMerged(root = process.cwd()) {
68
+ const lib = resolveLibTerminalConfig(root) || {}
69
+ const sb = readJson(resolve(root, 'storyboard.config.json')) || {}
70
+ const userTerminal = readJson(resolve(root, 'terminal.config.json')) || {}
71
+
72
+ const sbCanvas = sb.canvas || {}
73
+ const sbLayer = {
74
+ ...(sbCanvas.terminal ? { terminal: sbCanvas.terminal } : {}),
75
+ ...(sbCanvas.agents ? { agents: sbCanvas.agents } : {}),
76
+ ...(sbCanvas.showAgentsInAddMenu !== undefined
77
+ ? { showAgentsInAddMenu: sbCanvas.showAgentsInAddMenu }
78
+ : {}),
79
+ ...(sb.hotPool ? { hotPool: sb.hotPool } : {}),
80
+ }
81
+
82
+ const merged = deepMerge(deepMerge(lib, sbLayer), userTerminal)
83
+ return {
84
+ terminal: merged.terminal || {},
85
+ agents: merged.agents || {},
86
+ showAgentsInAddMenu: merged.showAgentsInAddMenu,
87
+ hotPool: merged.hotPool || {},
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Convenience: just the agents map.
93
+ */
94
+ export function readAgentsConfig(root = process.cwd()) {
95
+ return readTerminalConfigMerged(root).agents
96
+ }
97
+
98
+ /**
99
+ * Convenience: just the terminal-widget settings.
100
+ */
101
+ export function readTerminalSettings(root = process.cwd()) {
102
+ return readTerminalConfigMerged(root).terminal
103
+ }
104
+
105
+ /**
106
+ * Convenience: just the hotPool config.
107
+ */
108
+ export function readHotPoolConfig(root = process.cwd()) {
109
+ return readTerminalConfigMerged(root).hotPool
110
+ }
@@ -25,7 +25,7 @@
25
25
  * Scale-down: After cooldown minutes with no acquisitions, the pool scales
26
26
  * back to pool_size by killing excess warm sessions.
27
27
  *
28
- * ## Configuration (storyboard.config.json → hotPool)
28
+ * ## Configuration (terminal.config.json → hotPool, or storyboard.config.json → hotPool for legacy back-compat)
29
29
  *
30
30
  * hotPool.enabled — enable/disable all pools (default: true)
31
31
  * hotPool.verbose — log to Vite terminal (default: false)
@@ -52,6 +52,7 @@ import { markCanvasWrite, unmarkCanvasWrite } from './writeGuard.js'
52
52
  import { devLog } from '../logger/devLogger.js'
53
53
  import widgetsConfig from '../../../widgets.config.json' with { type: 'json' }
54
54
  import { listHubRoles, getDefaultRoleId } from './hub-roles.js'
55
+ import { readAgentsConfig } from './configReader.js'
55
56
 
56
57
  /**
57
58
  * Read the prompt widget's execution config from widgets.config.json.
@@ -713,9 +714,7 @@ export function createCanvasHandler(ctx) {
713
714
  // For agent widgets, resolve startupCommand from canvas.agents config if not provided
714
715
  if (type === 'agent' && props.agentId && !props.startupCommand) {
715
716
  try {
716
- const configPath = path.join(root, 'storyboard.config.json')
717
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
718
- const agentCfg = config?.canvas?.agents?.[props.agentId]
717
+ const agentCfg = readAgentsConfig(root)?.[props.agentId]
719
718
  if (agentCfg?.startupCommand) {
720
719
  props.startupCommand = agentCfg.startupCommand
721
720
  }
@@ -725,9 +724,7 @@ export function createCanvasHandler(ctx) {
725
724
  // For agent widgets without agentId, default to the first canvas.agents entry
726
725
  if (type === 'agent' && !props.agentId) {
727
726
  try {
728
- const configPath = path.join(root, 'storyboard.config.json')
729
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
730
- const agents = config?.canvas?.agents || {}
727
+ const agents = readAgentsConfig(root) || {}
731
728
  const defaultEntry = Object.entries(agents).find(([, cfg]) => cfg.default) || Object.entries(agents)[0]
732
729
  if (defaultEntry) {
733
730
  const [id, cfg] = defaultEntry
@@ -3106,15 +3103,16 @@ export function Default() {
3106
3103
  // Write env file for this terminal session — sourced before copilot launch
3107
3104
  // This avoids race conditions with tmux send-keys export
3108
3105
  const envFile = path.join(root, '.storyboard', 'terminals', `${tmuxName}.env`)
3109
- const envContent = Object.entries(envMap).map(([k, v]) => `export ${k}=${JSON.stringify(v)}`).join('\n') + '\n'
3106
+ // Trailing echo is the readiness signal the post-startup poller
3107
+ // matches against. Don't drop it — without it /allow-all and the
3108
+ // identity/role/broadcast bind wait the full 30s timeout fallback.
3109
+ const envContent = Object.entries(envMap).map(([k, v]) => `export ${k}=${JSON.stringify(v)}`).join('\n') + '\necho "Environment loaded:"\n'
3110
3110
  fsModule.writeFileSync(envFile, envContent)
3111
3111
 
3112
3112
  // Resolve agent config from storyboard.config.json
3113
3113
  let agentConfig = null
3114
3114
  try {
3115
- const configPath = path.join(root, 'storyboard.config.json')
3116
- const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
3117
- const agents = config?.canvas?.agents || {}
3115
+ const agents = readAgentsConfig(root) || {}
3118
3116
  if (agentId && agents[agentId]) {
3119
3117
  agentConfig = agents[agentId]
3120
3118
  } else {
@@ -158,15 +158,11 @@ function cleanEnv() {
158
158
  return filtered
159
159
  }
160
160
 
161
- /** Read terminal config from storyboard.config.json */
161
+ import { readTerminalSettings, readAgentsConfig } from './configReader.js'
162
+
163
+ /** Read terminal-widget settings (merged from lib defaults + storyboard.config.json + terminal.config.json). */
162
164
  function readTerminalConfig() {
163
- try {
164
- const raw = readFileSync(resolve(process.cwd(), 'storyboard.config.json'), 'utf8')
165
- const config = JSON.parse(raw)
166
- return config?.canvas?.terminal ?? {}
167
- } catch {
168
- return {}
169
- }
165
+ return readTerminalSettings()
170
166
  }
171
167
 
172
168
  /**
@@ -175,22 +171,19 @@ function readTerminalConfig() {
175
171
  */
176
172
  function resolveAgentConfig(startupCommand) {
177
173
  if (!startupCommand || startupCommand === 'shell') return null
178
- try {
179
- const raw = readFileSync(resolve(process.cwd(), 'storyboard.config.json'), 'utf8')
180
- const agentsConfig = JSON.parse(raw)?.canvas?.agents
181
- if (!agentsConfig || typeof agentsConfig !== 'object') return null
182
- for (const [id, cfg] of Object.entries(agentsConfig)) {
183
- if (!cfg?.startupCommand) continue
184
- // Prefer exact/prefix match for deterministic routing; keep binary fallback for backwards compat.
185
- if (
186
- startupCommand === cfg.startupCommand
187
- || startupCommand.startsWith(`${cfg.startupCommand} `)
188
- || startupCommand.startsWith(cfg.startupCommand.split(' ')[0])
189
- ) {
190
- return { id, cfg }
191
- }
174
+ const agentsConfig = readAgentsConfig()
175
+ if (!agentsConfig || typeof agentsConfig !== 'object') return null
176
+ for (const [id, cfg] of Object.entries(agentsConfig)) {
177
+ if (!cfg?.startupCommand) continue
178
+ // Prefer exact/prefix match for deterministic routing; keep binary fallback for backwards compat.
179
+ if (
180
+ startupCommand === cfg.startupCommand
181
+ || startupCommand.startsWith(`${cfg.startupCommand} `)
182
+ || startupCommand.startsWith(cfg.startupCommand.split(' ')[0])
183
+ ) {
184
+ return { id, cfg }
192
185
  }
193
- } catch { /* empty */ }
186
+ }
194
187
  return null
195
188
  }
196
189
 
@@ -1107,8 +1100,7 @@ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupComma
1107
1100
 
1108
1101
  // Agent shorthand scripts (copilot, claude, codex, etc.)
1109
1102
  try {
1110
- const raw = readFileSync(resolve(process.cwd(), 'storyboard.config.json'), 'utf8')
1111
- const agentsConfig = JSON.parse(raw)?.canvas?.agents
1103
+ const agentsConfig = readAgentsConfig()
1112
1104
  if (agentsConfig && typeof agentsConfig === 'object') {
1113
1105
  for (const [id, cfg] of Object.entries(agentsConfig)) {
1114
1106
  if (!cfg.startupCommand) continue
@@ -1138,8 +1130,7 @@ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupComma
1138
1130
  `start() { if [ $# -eq 0 ]; then ${welcomeBase}; else ${welcomeBase} --startup "$*"; fi; }`,
1139
1131
  ]
1140
1132
  try {
1141
- const raw = readFileSync(resolve(process.cwd(), 'storyboard.config.json'), 'utf8')
1142
- const agentsConfig = JSON.parse(raw)?.canvas?.agents
1133
+ const agentsConfig = readAgentsConfig()
1143
1134
  if (agentsConfig && typeof agentsConfig === 'object') {
1144
1135
  for (const [id, cfg] of Object.entries(agentsConfig)) {
1145
1136
  if (!cfg.startupCommand) continue
@@ -1246,17 +1237,27 @@ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupComma
1246
1237
  const binDir = join(cwd, '.storyboard', 'terminals', 'bin')
1247
1238
  envParts.push(`export PATH="${binDir}:$PATH"`)
1248
1239
 
1249
- // Chain clear before exports so the typed env soup is wiped from
1250
- // view as soon as Enter executes, then chain clear again after so
1251
- // the agent starts on a clean screen.
1252
- let envExports = envParts.join(' && ')
1253
- if (startupCommand) {
1254
- envExports = `clear && ${envExports} && clear`
1255
- }
1240
+ // Write env exports to a per-widget shell script and source it via a
1241
+ // short send-keys. Avoids tmux send-keys -l truncation when the env
1242
+ // soup (especially PATH expansion) gets large — observed: a 4 KB+
1243
+ // chain stops mid-line on macOS, the agent command never runs.
1244
+ const envScriptDir = join(cwd, '.storyboard', 'terminals')
1245
+ try { mkdirSync(envScriptDir, { recursive: true }) } catch { /* empty */ }
1246
+ const envScriptPath = join(envScriptDir, `${widgetId}.env.sh`)
1247
+ try {
1248
+ // Trailing echo is the readiness signal the post-startup poller
1249
+ // looks for. Without it, the 30s timeout fallback fires before
1250
+ // /allow-all, identity, role/broadcast bind are sent — making
1251
+ // the agent feel "stuck" for the first half-minute after launch.
1252
+ writeFileSync(envScriptPath, envParts.join('\n') + '\necho "Environment loaded:"\n')
1253
+ } catch { /* empty */ }
1254
+ const envSourceCmd = startupCommand
1255
+ ? `clear && source ${JSON.stringify(envScriptPath)} && clear`
1256
+ : `source ${JSON.stringify(envScriptPath)}`
1256
1257
 
1257
1258
  setTimeout(() => {
1258
1259
  try {
1259
- execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(envExports)}`, { stdio: 'ignore' })
1260
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(envSourceCmd)}`, { stdio: 'ignore' })
1260
1261
  execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
1261
1262
  } catch { /* empty */ }
1262
1263
  }, 300)
@@ -21,6 +21,21 @@ import { detectWorktreeName, getPort, releasePort } from '../worktree/port.js'
21
21
  import { startRenameWatcher } from '../rename-watcher/watcher.js'
22
22
  import { compactAll } from '../canvas/compact.js'
23
23
  import { parseFlags } from './flags.js'
24
+ import { setupNeeded, writeUserState, getInstalledStoryboardVersion } from './userState.js'
25
+ import { dim, magenta, bold } from './intro.js'
26
+
27
+ /** Render the storyboard mascot with two info lines beside it. */
28
+ function mascotBanner(line1, line2) {
29
+ const d = dim('·')
30
+ const f = magenta
31
+ const b = dim
32
+ return [
33
+ ` ${b('╭─────────────────╮')}`,
34
+ ` ${b('│')} ${d} ${f('◠')} ${f('◡')} ${f('◠')} ${d} ${b('│')} ${line1}`,
35
+ ` ${b('│')} ${d} ${d} ${d} ${d} ${d} ${b('│')} ${line2}`,
36
+ ` ${b('╰─────────────────╯')}`,
37
+ ].join('\n')
38
+ }
24
39
 
25
40
  const flagSchema = {
26
41
  port: { type: 'number', description: 'Override dev server port' },
@@ -55,6 +70,34 @@ async function main() {
55
70
  p.intro('storyboard dev')
56
71
  p.log.info(`worktree: ${worktreeName}`)
57
72
 
73
+ // Re-run setup automatically if it has never run here, or if the installed
74
+ // @dfosco/storyboard version no longer matches the one setup was last run
75
+ // against. This lets `npm install` upgrades trigger fresh scaffolding
76
+ // without requiring `npx storyboard update`.
77
+ {
78
+ const need = setupNeeded(targetCwd)
79
+ if (need) {
80
+ const why = need.reason === 'first-run'
81
+ ? 'first run in this repo'
82
+ : `version changed ${need.from} → ${need.to}`
83
+ p.log.info(`Running setup (${why})…`)
84
+ await new Promise((resolveSetup) => {
85
+ const setupChild = spawn(
86
+ process.platform === 'win32' ? 'npx.cmd' : 'npx',
87
+ ['storyboard', 'setup', '--skip-branch'],
88
+ { cwd: targetCwd, stdio: 'inherit' }
89
+ )
90
+ setupChild.on('exit', () => resolveSetup())
91
+ setupChild.on('error', () => resolveSetup())
92
+ })
93
+ // Belt-and-suspenders: even if setup failed to write the marker,
94
+ // stamp the current version so dev doesn't loop forever asking to run
95
+ // setup on every boot.
96
+ const version = getInstalledStoryboardVersion(targetCwd)
97
+ if (version) writeUserState({ setupVersion: version, setupRanAt: new Date().toISOString() }, targetCwd)
98
+ }
99
+ }
100
+
58
101
  // Compact bloated canvas JSONL files before booting Vite.
59
102
  const compacted = compactAll(targetCwd)
60
103
  for (const r of compacted) {
@@ -78,15 +121,23 @@ async function main() {
78
121
  const viteArgs = ['vite', '--port', String(port)]
79
122
  if (strictPort) viteArgs.push('--strictPort')
80
123
  if (strictPort) p.log.info(`port ${port} (strict — from storyboard.config.json)`)
124
+
125
+ // Render the storyboard mascot just before Vite takes over stdio. The
126
+ // mascot acts as a visual anchor between our setup output and Vite's
127
+ // own "ready in Xms" banner.
128
+ console.log()
129
+ console.log(mascotBanner(
130
+ bold(`http://localhost:${port}/storyboard/`),
131
+ dim('Stop with Ctrl+C'),
132
+ ))
133
+ console.log()
134
+
81
135
  const child = spawn(npmBin, viteArgs, {
82
136
  cwd: targetCwd,
83
137
  stdio: 'inherit',
84
138
  env: { ...process.env, STORYBOARD_WORKTREE: worktreeName },
85
139
  })
86
140
 
87
- p.log.success(`http://localhost:${port}/storyboard/`)
88
- p.log.info('Stop with Ctrl+C')
89
-
90
141
  function shutdown() {
91
142
  clearInterval(compactInterval)
92
143
  renameWatcher.close()
@@ -12,9 +12,10 @@
12
12
  import * as p from '@clack/prompts'
13
13
  import { existsSync, writeFileSync, readFileSync, mkdirSync, readdirSync, symlinkSync } from 'fs'
14
14
  import path from 'path'
15
- import { execSync } from 'child_process'
15
+ import { execSync, spawn } from 'child_process'
16
16
  import { gettingStartedLines, dim, magenta, bold, yellow, green } from './intro.js'
17
17
  import { parseFlags } from './flags.js'
18
+ import { readUserState, writeUserState, getInstalledStoryboardVersion } from './userState.js'
18
19
 
19
20
  const flagSchema = {
20
21
  'skip-branch': { type: 'boolean', default: false, description: 'Skip the branch prompt at the end' },
@@ -43,7 +44,8 @@ if (flags.nuke) {
43
44
 
44
45
  /**
45
46
  * Run a potentially slow task with a spinner that only appears after 500ms.
46
- * If the task completes quickly, shows the done message immediately.
47
+ * IMPORTANT: `fn` must be async (don't use execSync — it blocks the event loop
48
+ * and prevents the spinner from animating).
47
49
  */
48
50
  async function withSpin(label, doneMsg, fn) {
49
51
  const spin = p.spinner()
@@ -59,6 +61,54 @@ async function withSpin(label, doneMsg, fn) {
59
61
  }
60
62
  }
61
63
 
64
+ /**
65
+ * Async command runner — does NOT block the event loop, so spinners animate.
66
+ */
67
+ function runAsync(cmd, args = [], opts = {}) {
68
+ return new Promise((resolve, reject) => {
69
+ const child = spawn(cmd, args, { stdio: 'ignore', ...opts })
70
+ child.on('error', reject)
71
+ child.on('exit', (code) => {
72
+ if (code === 0) resolve()
73
+ else reject(new Error(`${cmd} exited with code ${code}`))
74
+ })
75
+ })
76
+ }
77
+
78
+ /**
79
+ * Install a brew package with an animated spinner.
80
+ */
81
+ async function brewInstall(pkg, label) {
82
+ const spin = p.spinner()
83
+ spin.start(`Installing ${label}`)
84
+ try {
85
+ await runAsync('brew', ['install', pkg])
86
+ spin.stop(`${label} installed`)
87
+ return true
88
+ } catch {
89
+ spin.stop(`Failed to install ${label}`)
90
+ p.log.warning(`Install manually: brew install ${pkg}`)
91
+ return false
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Quick network probe — alerts the user if GitHub looks unreachable.
97
+ * We don't block on failure; downstream installers will surface their own errors.
98
+ */
99
+ async function checkNetwork() {
100
+ const http = await import('node:https')
101
+ return new Promise((resolve) => {
102
+ const req = http.request('https://api.github.com', { method: 'HEAD', timeout: 4000 }, (res) => {
103
+ resolve(res.statusCode != null && res.statusCode < 500)
104
+ res.resume()
105
+ })
106
+ req.on('error', () => resolve(false))
107
+ req.on('timeout', () => { req.destroy(); resolve(false) })
108
+ req.end()
109
+ })
110
+ }
111
+
62
112
  function mascot() {
63
113
  const d = dim('·')
64
114
  const f = magenta
@@ -87,13 +137,20 @@ function isInstalled(cmd) {
87
137
 
88
138
  p.intro('storyboard setup')
89
139
 
90
- // 1. Check for node_modules (quick sanity check full install runs at the end)
91
- if (!existsSync('node_modules')) {
92
- p.log.info('node_modules not found will install at end of setup')
93
- } else {
94
- p.log.success('Dependencies present')
140
+ // 0. Network probe alert if GitHub is unreachable; don't block.
141
+ {
142
+ const online = await checkNetwork()
143
+ if (!online) {
144
+ p.log.warning('Network looks offline — installers/downloads may fail')
145
+ p.log.info(dim(' Tried HEAD https://api.github.com; check VPN/proxy/DNS'))
146
+ } else {
147
+ p.log.success('Network reachable')
148
+ }
95
149
  }
96
150
 
151
+ // Node is assumed to be present (you already ran `npx storyboard setup`).
152
+ // node_modules will be installed at the end if missing.
153
+
97
154
  // 2. Homebrew
98
155
  let hasBrew = isInstalled('brew')
99
156
  if (!hasBrew) {
@@ -121,35 +178,43 @@ if (hasBrew) {
121
178
  if (isInstalled('git')) {
122
179
  p.log.success('Git installed')
123
180
  } else {
124
- const gitSpin = p.spinner()
125
- gitSpin.start('Installing Git')
126
- try {
127
- run('brew install git')
128
- gitSpin.stop('Git installed')
129
- } catch {
130
- gitSpin.stop('Failed to install Git')
131
- p.log.warning('Install manually: brew install git')
132
- }
181
+ await brewInstall('git', 'Git')
133
182
  }
134
183
  }
135
184
 
136
185
  // 4. Caddy is no longer used. Worktrees run their own Vite directly on
137
- // `http://localhost:<port>/storyboard/`. The block below intentionally
138
- // skips the previous Caddy install + start steps.
186
+ // `http://localhost:<port>/storyboard/`.
139
187
 
140
188
  if (hasBrew) {
141
- // 5. GitHub CLI
189
+ // 5. GitHub CLI — required for `gh auth`, `gh pr`, `gh issue` in agents.
190
+ let ghNewlyInstalled = false
142
191
  if (isInstalled('gh')) {
143
192
  p.log.success('GitHub CLI installed')
144
193
  } else {
145
- const ghSpin = p.spinner()
146
- ghSpin.start('Installing GitHub CLI')
194
+ ghNewlyInstalled = await brewInstall('gh', 'GitHub CLI')
195
+ }
196
+
197
+ // 5a. tmux (required for headless agent sessions)
198
+ if (isInstalled('tmux')) {
199
+ p.log.success('tmux installed')
200
+ } else {
201
+ await brewInstall('tmux', 'tmux')
202
+ }
203
+
204
+ // 5b. Surface gh auth status. Even if gh was already installed, we should
205
+ // prompt the user to log in — agents that shell out to `gh` will fail
206
+ // silently otherwise.
207
+ if (isInstalled('gh')) {
208
+ let authed = false
147
209
  try {
148
- run('brew install gh')
149
- ghSpin.stop('GitHub CLI installed')
150
- } catch {
151
- ghSpin.stop('Failed to install GitHub CLI')
152
- p.log.warning('Install manually: brew install gh')
210
+ execSync('gh auth status', { stdio: 'ignore' })
211
+ authed = true
212
+ } catch { /* not authed */ }
213
+ if (authed) {
214
+ p.log.success('GitHub CLI authenticated')
215
+ } else {
216
+ p.log.warning(ghNewlyInstalled ? 'GitHub CLI installed but not logged in' : 'GitHub CLI is not logged in')
217
+ p.log.info(` Run ${yellow('gh auth login')} to authenticate`)
153
218
  }
154
219
  }
155
220
  }
@@ -195,25 +260,102 @@ if (isInstalled('code')) {
195
260
  }
196
261
  }
197
262
 
198
- // 6a. Copilot CLI
199
- if (isInstalled('copilot')) {
200
- p.log.success('Copilot CLI installed')
201
- } else {
202
- const copilotSpin = p.spinner()
203
- copilotSpin.start('Installing Copilot CLI')
204
- try {
205
- run('curl -fsSL https://gh.io/copilot-install | bash')
206
- // Add ~/.local/bin to PATH if not already there
263
+ // 6a. Coding agents (Copilot CLI / Claude Code / Codex CLI).
264
+ // On first-time setup, ask the user which agents they want installed.
265
+ // On subsequent runs, only install the previously-opted-in agents that
266
+ // went missing — never re-prompt to avoid being annoying after upgrades.
267
+ {
268
+ const priorState = readUserState()
269
+ const priorAgents = priorState.agents || null
270
+ const firstRun = !priorState.setupVersion
271
+
272
+ const ensureLocalBinOnPath = () => {
207
273
  const localBin = `${process.env.HOME}/.local/bin`
208
274
  if (!process.env.PATH.includes(localBin)) {
209
275
  process.env.PATH = `${localBin}:${process.env.PATH}`
210
276
  }
211
- copilotSpin.stop('Copilot CLI installed')
212
- p.log.info(dim(' Note: You may need to restart your terminal or add ~/.local/bin to PATH'))
213
- } catch {
214
- copilotSpin.stop('Failed to install Copilot CLI')
215
- p.log.warning('Install manually: curl -fsSL https://gh.io/copilot-install | bash')
216
277
  }
278
+
279
+ const installers = {
280
+ copilot: {
281
+ label: 'Copilot CLI',
282
+ bin: 'copilot',
283
+ install: async () => {
284
+ await runAsync('bash', ['-c', 'curl -fsSL https://gh.io/copilot-install | bash'])
285
+ ensureLocalBinOnPath()
286
+ },
287
+ manualHint: 'curl -fsSL https://gh.io/copilot-install | bash',
288
+ authHint: `Auth is separate from gh — run ${yellow('copilot')} then ${yellow('/login')}`,
289
+ },
290
+ claude: {
291
+ label: 'Claude Code',
292
+ bin: 'claude',
293
+ install: async () => {
294
+ await runAsync('bash', ['-c', 'curl -fsSL https://claude.ai/install.sh | bash'])
295
+ ensureLocalBinOnPath()
296
+ },
297
+ manualHint: 'curl -fsSL https://claude.ai/install.sh | bash',
298
+ authHint: `Run ${yellow('claude')} once to authenticate`,
299
+ },
300
+ codex: {
301
+ label: 'Codex CLI',
302
+ bin: 'codex',
303
+ install: async () => {
304
+ await runAsync('npm', ['install', '-g', '@openai/codex'])
305
+ },
306
+ manualHint: 'npm i -g @openai/codex',
307
+ authHint: `Run ${yellow('codex login')} to authenticate`,
308
+ },
309
+ }
310
+
311
+ let chosen
312
+ if (firstRun) {
313
+ const selection = await p.multiselect({
314
+ message: 'Which coding agents do you want installed?',
315
+ options: [
316
+ { value: 'copilot', label: 'Copilot CLI', hint: 'recommended' },
317
+ { value: 'claude', label: 'Claude Code' },
318
+ { value: 'codex', label: 'Codex CLI' },
319
+ ],
320
+ initialValues: ['copilot'],
321
+ required: false,
322
+ })
323
+ if (p.isCancel(selection)) {
324
+ chosen = { copilot: true, claude: false, codex: false }
325
+ } else {
326
+ chosen = Object.fromEntries(Object.keys(installers).map((k) => [k, selection.includes(k)]))
327
+ }
328
+ } else {
329
+ // Returning user — install only what they previously opted into and is
330
+ // currently missing. If we have no record (older setup), default to
331
+ // re-installing copilot only when missing.
332
+ chosen = priorAgents || { copilot: true, claude: false, codex: false }
333
+ }
334
+
335
+ for (const [key, agent] of Object.entries(installers)) {
336
+ if (!chosen[key]) continue
337
+ if (isInstalled(agent.bin)) {
338
+ p.log.success(`${agent.label} installed`)
339
+ p.log.info(` ${agent.authHint}`)
340
+ continue
341
+ }
342
+ const spin = p.spinner()
343
+ spin.start(`Installing ${agent.label}`)
344
+ try {
345
+ await agent.install()
346
+ spin.stop(`${agent.label} installed`)
347
+ if (!isInstalled(agent.bin)) {
348
+ p.log.warning(`${agent.bin} not on PATH — add ${yellow('export PATH="$HOME/.local/bin:$PATH"')} to your shell rc`)
349
+ }
350
+ p.log.info(` ${agent.authHint}`)
351
+ } catch {
352
+ spin.stop(`Failed to install ${agent.label}`)
353
+ p.log.warning(`Install manually: ${agent.manualHint}`)
354
+ }
355
+ }
356
+
357
+ // Persist the choice so future setups know what to keep installed.
358
+ writeUserState({ agents: chosen })
217
359
  }
218
360
 
219
361
  // 8. Git hooks
@@ -283,6 +425,8 @@ if (isInstalled('copilot')) {
283
425
  'src/canvas/~*/',
284
426
  'src/prototypes/~*/',
285
427
  'src/prototypes/**/~*.{flow,object,record,prototype,folder}.json',
428
+ // Per-user local state (setup version marker, agent prefs, future onboarding state)
429
+ '.storyboard/.user.json',
286
430
  ]
287
431
  if (existsSync(gitignorePath)) {
288
432
  try {
@@ -352,18 +496,26 @@ if (isInstalled('copilot')) {
352
496
 
353
497
  // 10. Install / sync dependencies
354
498
  {
499
+ const installSpin = p.spinner()
500
+ installSpin.start('Installing dependencies')
355
501
  try {
356
- await withSpin(
357
- 'Installing dependencies...',
358
- 'Dependencies installed',
359
- () => { run('npm install', { stdio: 'ignore' }) }
360
- )
502
+ await runAsync('npm', ['install'])
503
+ installSpin.stop('Dependencies installed')
361
504
  } catch {
362
- p.log.warning('npm install failed — run it manually to see details')
505
+ installSpin.stop('npm install failed')
506
+ p.log.warning('Run it manually to see details:')
363
507
  p.log.info(` ${dim('npm install')}`)
364
508
  }
365
509
  }
366
510
 
511
+ // 11. Stamp the user-state marker so `npm run dev` knows setup is fresh
512
+ // and won't re-run it until the storyboard package version changes.
513
+ {
514
+ const version = getInstalledStoryboardVersion() || 'unknown'
515
+ writeUserState({ setupVersion: version, setupRanAt: new Date().toISOString() })
516
+ p.log.success(`Setup marker written (.storyboard/.user.json @ ${version})`)
517
+ }
518
+
367
519
  p.note(
368
520
  [
369
521
  ...gettingStartedLines(),
@@ -17,11 +17,12 @@
17
17
 
18
18
  import * as p from '@clack/prompts'
19
19
  import { execSync, spawn } from 'node:child_process'
20
- import { readFileSync, existsSync } from 'node:fs'
21
- import { resolve, join } from 'node:path'
20
+ import { existsSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
22
  import { parseFlags } from './flags.js'
23
23
  import { dim, bold } from './intro.js'
24
24
  import { takePendingMessages } from '../canvas/terminal-config.js'
25
+ import { readAgentsConfig } from '../canvas/configReader.js'
25
26
 
26
27
  const blue = (s) => `\x1b[34m${s}\x1b[0m`
27
28
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`
@@ -76,14 +77,12 @@ function agentEnv() {
76
77
  }
77
78
 
78
79
  /**
79
- * Read agents config from storyboard.config.json.
80
+ * Read agents config (lib defaults + storyboard.config.json + terminal.config.json merged).
80
81
  * Returns an array of { id, label, startupCommand, resumeCommand } entries.
81
82
  */
82
83
  function loadAgents() {
83
84
  try {
84
- const raw = readFileSync(resolve(process.cwd(), 'storyboard.config.json'), 'utf8')
85
- const config = JSON.parse(raw)
86
- const agents = config?.canvas?.agents
85
+ const agents = readAgentsConfig()
87
86
  if (!agents || typeof agents !== 'object') return []
88
87
  return Object.entries(agents).map(([id, cfg]) => ({
89
88
  id,
@@ -0,0 +1,63 @@
1
+ /**
2
+ * userState — Per-user local state for the current repo, stored in
3
+ * `.storyboard/.user.json` (gitignored).
4
+ *
5
+ * This is a free-form key/value bag for things that should persist across
6
+ * runs but never be committed:
7
+ * - setupVersion (string) — @dfosco/storyboard version setup was last
8
+ * run against. Compared against the installed version on `npm run dev`
9
+ * to decide whether scaffolding needs to re-run.
10
+ * - setupRanAt (ISO date) — last setup timestamp.
11
+ * - agents (object) — per-agent opt-in flags from the
12
+ * first-run install prompt (copilot/claude/codex booleans).
13
+ * - … future: onboarded, username, lastSeenChangelog, etc.
14
+ */
15
+
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
17
+ import { resolve, dirname } from 'node:path'
18
+
19
+ const FILE = '.storyboard/.user.json'
20
+
21
+ export function userStatePath(cwd = process.cwd()) {
22
+ return resolve(cwd, FILE)
23
+ }
24
+
25
+ export function readUserState(cwd = process.cwd()) {
26
+ const file = userStatePath(cwd)
27
+ if (!existsSync(file)) return {}
28
+ try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return {} }
29
+ }
30
+
31
+ export function writeUserState(patch, cwd = process.cwd()) {
32
+ const file = userStatePath(cwd)
33
+ const dir = dirname(file)
34
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
35
+ const next = { ...readUserState(cwd), ...patch }
36
+ writeFileSync(file, JSON.stringify(next, null, 2) + '\n', 'utf8')
37
+ return next
38
+ }
39
+
40
+ /**
41
+ * Read the installed @dfosco/storyboard package version, or null if not
42
+ * resolvable (e.g. running from a worktree without node_modules).
43
+ */
44
+ export function getInstalledStoryboardVersion(cwd = process.cwd()) {
45
+ try {
46
+ const pkgPath = resolve(cwd, 'node_modules', '@dfosco', 'storyboard', 'package.json')
47
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version || null
48
+ } catch { return null }
49
+ }
50
+
51
+ /**
52
+ * Decide whether `storyboard setup` should run before `dev`.
53
+ * Returns null when up-to-date, or { reason, ... } otherwise.
54
+ */
55
+ export function setupNeeded(cwd = process.cwd()) {
56
+ const state = readUserState(cwd)
57
+ const current = getInstalledStoryboardVersion(cwd)
58
+ if (!state.setupVersion) return { reason: 'first-run', current }
59
+ if (current && state.setupVersion !== current) {
60
+ return { reason: 'version-changed', from: state.setupVersion, to: current }
61
+ }
62
+ return null
63
+ }
@@ -19,6 +19,7 @@ import { serverFeatures as workshopFeatures } from '../workshop/features/registr
19
19
  import { docsHandler, collectFiles } from './docs-handler.js'
20
20
  import { createCanvasHandler } from '../canvas/server.js'
21
21
  import { setupSelectedWidgets } from '../canvas/selectedWidgets.js'
22
+ import { readAgentsConfig, readHotPoolConfig } from '../canvas/configReader.js'
22
23
  import { HotPoolManager } from '../canvas/hot-pool.js'
23
24
  import { createAutosyncHandler } from '../autosync/server.js'
24
25
  import { setupTerminalServer } from '../canvas/terminal-server.js'
@@ -279,8 +280,8 @@ export default function storyboardServer() {
279
280
  routeHandlers.set('docs', docsHandler({ root, sendJson: sendJsonLogged }))
280
281
 
281
282
  // Create shared hot pool manager (per-type pre-warmed sessions)
282
- const hotPoolConfig = config.hotPool || {}
283
- const agentsConfig = config.canvas?.agents || {}
283
+ const hotPoolConfig = readHotPoolConfig(root)
284
+ const agentsConfig = readAgentsConfig(root)
284
285
  const wsSend = server.ws.send.bind(server.ws)
285
286
  const hotPool = new HotPoolManager({ root, config: hotPoolConfig, agentsConfig, wsSend })
286
287
  hotPool.start().catch((err) => {
@@ -44,5 +44,20 @@
44
44
  "resizable": true
45
45
  }
46
46
  },
47
- "showAgentsInAddMenu": false
47
+ "showAgentsInAddMenu": false,
48
+ "hotPool": {
49
+ "enabled": true,
50
+ "verbose": false,
51
+ "default_pool_size": 1,
52
+ "default_max_pool_size": 3,
53
+ "load_balancer": true,
54
+ "load_balancer_cooldown_mins": 10,
55
+ "pools": {
56
+ "terminal": { "pool_size": 1 },
57
+ "copilot": { "pool_size": 1, "webgl_ready_slots": 1 },
58
+ "claude": { "pool_size": 1, "webgl_ready_slots": 1 },
59
+ "codex": { "pool_size": 0 },
60
+ "prompt": { "pool_size": 1, "webgl_ready_slots": 1 }
61
+ }
62
+ }
48
63
  }