@dfosco/storyboard 0.6.0-beta.4 → 0.6.0-beta.6

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.4",
3
+ "version": "0.6.0-beta.6",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -21,7 +21,8 @@
21
21
  "toolbar.config.json",
22
22
  "widgets.config.json",
23
23
  "paste.config.json",
24
- "commandpalette.config.json"
24
+ "commandpalette.config.json",
25
+ "terminal.config.json"
25
26
  ],
26
27
  "scripts": {
27
28
  "build:css": "tailwindcss -i src/core/styles/tailwind.css -o dist/tailwind.css --minify",
@@ -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/~*
@@ -37,65 +37,81 @@ The storyboard homepage URL changed from `/viewfinder` to `/workspace`. The old
37
37
 
38
38
  #### 2. Canvas config — terminal + agents + hot pool
39
39
 
40
- Clients on 4.1.x likely have no `canvas` block at all. The full canvas config is required for terminal widgets, agent widgets, and prompt widgets to work on canvases.
40
+ **As of `0.6.0-beta.4`, terminal + agent config has its own dedicated file: `terminal.config.json` at the project root.** The library ships full defaults in `node_modules/@dfosco/storyboard/terminal.config.json` and a copy is auto-scaffolded to `.storyboard/scaffold/terminal.config.json` on every dev-server boot. Most clients won't need any project-level config — the defaults already cover Copilot/Claude/Codex with auto-resume.
41
41
 
42
- **Read the client's `storyboard.config.json`.** If the `canvas` key is missing or incomplete, merge the missing sections. Here is the complete reference config adapt values to the client's environment:
42
+ **Only create a root `terminal.config.json`** if you want to override specific keys. Leaf-level merge means you set only what you change; everything else inherits the library defaults (so future agents and tweaks reach you automatically). Example minimal override:
43
43
 
44
44
  ```jsonc
45
45
  {
46
- "canvas": {
47
- // Terminal widget settings (the plain terminal, not agents)
48
- "terminal": {
49
- "fontSize": 18,
50
- "fontFamily": "'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace",
51
- "prompt": "❯ ",
52
- "startupCommand": null,
53
- "defaultStartupSequence": null,
54
- "resizable": true,
55
- "defaultWidth": 1000,
56
- "defaultHeight": 600
57
- },
46
+ "terminal": {
47
+ "fontSize": 18,
48
+ "fontFamily": "'Ghostty', 'SF Mono', monospace"
49
+ },
50
+ "agents": {
51
+ "copilot": {
52
+ "startupCommand": "copilot --remote --agent terminal-agent"
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ **Legacy back-compat.** Existing clients with `canvas.terminal` and `canvas.agents` blocks under `storyboard.config.json` continue to work — the loader merges them with the new file (with `terminal.config.json` winning on overlap, and a warning logged). New clients should prefer `terminal.config.json` and keep `storyboard.config.json` lean.
58
59
 
59
- // Agent widgets each key becomes an entry in the "Add Agent" menu
60
- // Remove any agents the client doesn't have installed
61
- "agents": {
62
- "copilot": {
63
- "label": "Copilot CLI",
64
- "default": true,
65
- "icon": "primer/copilot",
66
- "startupCommand": "copilot --agent terminal-agent",
67
- "resumeCommand": "copilot --resume={id} --agent terminal-agent",
68
- "sessionIdEnv": "COPILOT_AGENT_SESSION_ID",
69
- "postStartup": "/allow-all on",
70
- "readinessSignal": "Environment loaded:",
71
- "resizable": true
72
- },
73
- "claude": {
74
- "label": "Claude Code",
75
- "icon": "claude",
76
- "startupCommand": "claude --agent terminal-agent --dangerously-skip-permissions",
77
- "resumeCommand": "claude --resume {id} --agent terminal-agent --dangerously-skip-permissions",
78
- "sessionIdEnv": "CLAUDE_SESSION_ID",
79
- "sessionStateGlob": "~/.claude/projects/*/{id}.jsonl",
80
- "resizable": true,
81
- "readinessSignal": "bypass permissions"
82
- },
83
- "codex": {
84
- "label": "Codex CLI",
85
- "icon": "codex",
86
- "startupCommand": "codex --full-auto",
87
- "resumeCommand": "codex resume {id}",
88
- "sessionIdEnv": "CODEX_SESSION_ID",
89
- "sessionStateGlob": "~/.codex/sessions/**/rollout-*-{id}.jsonl",
90
- "configFiles": [".codex/config.toml"],
91
- "resizable": true
92
- }
60
+ **Full reference for what `terminal.config.json` accepts** (don't copy this into a new project unless you actually need to override every key — the library ships these as defaults):
61
+
62
+ ```jsonc
63
+ {
64
+ // Terminal widget settings (the plain terminal, not agents)
65
+ "terminal": {
66
+ "fontSize": 18,
67
+ "fontFamily": "'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace",
68
+ "prompt": " ",
69
+ "startupCommand": null,
70
+ "defaultStartupSequence": null,
71
+ "resizable": true,
72
+ "defaultWidth": 1000,
73
+ "defaultHeight": 600
74
+ },
75
+
76
+ // Agent widgets — each key becomes an entry in the "Add Agent" menu
77
+ // Remove any agents the client doesn't have installed
78
+ "agents": {
79
+ "copilot": {
80
+ "label": "Copilot CLI",
81
+ "default": true,
82
+ "icon": "primer/copilot",
83
+ "startupCommand": "copilot --agent terminal-agent",
84
+ "resumeCommand": "copilot --resume={id} --agent terminal-agent",
85
+ "sessionIdEnv": "COPILOT_AGENT_SESSION_ID",
86
+ "postStartup": "/allow-all on",
87
+ "readinessSignal": "Environment loaded:",
88
+ "resizable": true
93
89
  },
90
+ "claude": {
91
+ "label": "Claude Code",
92
+ "icon": "claude",
93
+ "startupCommand": "claude --agent terminal-agent --dangerously-skip-permissions",
94
+ "resumeCommand": "claude --resume {id} --agent terminal-agent --dangerously-skip-permissions",
95
+ "sessionIdEnv": "CLAUDE_SESSION_ID",
96
+ "sessionStateGlob": "~/.claude/projects/*/{id}.jsonl",
97
+ "resizable": true,
98
+ "readinessSignal": "bypass permissions"
99
+ },
100
+ "codex": {
101
+ "label": "Codex CLI",
102
+ "icon": "codex",
103
+ "startupCommand": "codex --full-auto",
104
+ "resumeCommand": "codex resume {id}",
105
+ "sessionIdEnv": "CODEX_SESSION_ID",
106
+ "sessionStateGlob": "~/.codex/sessions/**/rollout-*-{id}.jsonl",
107
+ "configFiles": [".codex/config.toml"],
108
+ "resizable": true
109
+ }
110
+ },
94
111
 
95
- // Set to true to show agent entries in the canvas "+" add menu
96
- // Set to false to only show them in the command palette
97
- "showAgentsInAddMenu": false
98
- }
112
+ // Set to true to show agent entries in the canvas "+" add menu
113
+ // Set to false to only show them in the command palette
114
+ "showAgentsInAddMenu": false
99
115
  }
100
116
  ```
101
117
 
@@ -33,39 +33,6 @@
33
33
  "resizable": false,
34
34
  "defaultWidth": 800,
35
35
  "defaultHeight": 450
36
- },
37
- "agents": {
38
- "copilot": {
39
- "label": "Copilot CLI",
40
- "default": true,
41
- "icon": "primer/copilot",
42
- "startupCommand": "copilot --agent terminal-agent",
43
- "resumeCommand": "copilot --resume={id} --agent terminal-agent",
44
- "sessionIdEnv": "COPILOT_AGENT_SESSION_ID",
45
- "postStartup": "/allow-all on",
46
- "readinessSignal": "Environment loaded:",
47
- "resizable": true
48
- },
49
- "claude": {
50
- "label": "Claude Code",
51
- "icon": "claude",
52
- "startupCommand": "claude --agent terminal-agent --dangerously-skip-permissions",
53
- "resumeCommand": "claude --resume {id} --agent terminal-agent --dangerously-skip-permissions",
54
- "sessionIdEnv": "CLAUDE_SESSION_ID",
55
- "sessionStateGlob": "~/.claude/projects/*/{id}.jsonl",
56
- "readinessSignal": "bypass permissions",
57
- "resizable": true
58
- },
59
- "codex": {
60
- "label": "Codex CLI",
61
- "icon": "codex",
62
- "startupCommand": "codex --ask-for-approval never",
63
- "resumeCommand": "codex resume {id}",
64
- "sessionIdEnv": "CODEX_SESSION_ID",
65
- "sessionStateGlob": "~/.codex/sessions/**/rollout-*-{id}.jsonl",
66
- "configFiles": [".codex/config.toml"],
67
- "resizable": true
68
- }
69
36
  }
70
37
  },
71
38
  "customerMode": {
@@ -11,11 +11,11 @@
11
11
  *
12
12
  * A watcher on the per-widget capture file persists the captured id onto
13
13
  * the widget's terminal config as `lastAgentSessionId`. On the next cold
14
- * restart, the launch is rewritten to `copilot --resume=<id> --agent ...`,
15
- * with a pre-flight check that the session-state directory still exists
16
- * (copilot exits non-interactively when `--resume=<id>` doesn't match an
17
- * existing session, so we can't rely on `||` shell fallback we have to
18
- * pre-validate).
14
+ * restart, the launch is rewritten to `copilot --resume=<id> --agent ...`
15
+ * (with a pre-flight check that the on-disk session still exists), and is
16
+ * shell-chained with a `|| <fresh-startup>` fallback so that if the agent
17
+ * CLI rejects the id at runtime the widget still ends up with a working
18
+ * fresh session instead of a dead terminal.
19
19
  */
20
20
 
21
21
  import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, watch as fsWatch, readdirSync, statSync } from 'node:fs'
@@ -220,7 +220,16 @@ export function buildResumeStartupCommand({ startupCommand, sessionId, agentCfg
220
220
  const template = agentCfg?.resumeCommand
221
221
  if (!template || !template.includes('{id}')) return startupCommand
222
222
 
223
- return template.replace('{id}', sessionId)
223
+ const resumeCmd = template.replace('{id}', sessionId)
224
+
225
+ // Graceful fallback: if the resume command exits non-zero (e.g. the agent
226
+ // CLI rejected the id, the on-disk session is corrupt, or the binary
227
+ // doesn't actually support resume the way we expect), fall through to a
228
+ // fresh session instead of leaving the widget with a dead terminal.
229
+ // A clean exit (user `/exit`s) returns 0 and skips the fallback.
230
+ if (agentCfg?.resumeFallback === false) return resumeCmd
231
+ const notice = `printf '\\n\\033[33m[storyboard] resume failed; starting fresh session...\\033[0m\\n'`
232
+ return `${resumeCmd} || { ${notice}; ${startupCommand}; }`
224
233
  }
225
234
 
226
235
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -82,7 +82,7 @@ describe('agent-session', () => {
82
82
  expect(out).toBe('copilot --agent terminal-agent')
83
83
  })
84
84
 
85
- it('substitutes {id} into resumeCommand when valid', () => {
85
+ it('substitutes {id} into resumeCommand and chains a fresh-session fallback', () => {
86
86
  const out = buildResumeStartupCommand({
87
87
  startupCommand: 'copilot --agent terminal-agent',
88
88
  sessionId: '11111111-2222-4333-8444-555555555555',
@@ -91,6 +91,21 @@ describe('agent-session', () => {
91
91
  resumeCommand: 'copilot --resume={id} --agent terminal-agent',
92
92
  },
93
93
  })
94
+ expect(out).toContain('copilot --resume=11111111-2222-4333-8444-555555555555 --agent terminal-agent')
95
+ expect(out).toContain('|| {')
96
+ expect(out).toContain('copilot --agent terminal-agent')
97
+ })
98
+
99
+ it('skips the fallback when resumeFallback: false', () => {
100
+ const out = buildResumeStartupCommand({
101
+ startupCommand: 'copilot --agent terminal-agent',
102
+ sessionId: '11111111-2222-4333-8444-555555555555',
103
+ agentCfg: {
104
+ sessionStateDir: null,
105
+ resumeCommand: 'copilot --resume={id} --agent terminal-agent',
106
+ resumeFallback: false,
107
+ },
108
+ })
94
109
  expect(out).toBe('copilot --resume=11111111-2222-4333-8444-555555555555 --agent terminal-agent')
95
110
  })
96
111
 
@@ -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
@@ -3112,9 +3109,7 @@ export function Default() {
3112
3109
  // Resolve agent config from storyboard.config.json
3113
3110
  let agentConfig = null
3114
3111
  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 || {}
3112
+ const agents = readAgentsConfig(root) || {}
3118
3113
  if (agentId && agents[agentId]) {
3119
3114
  agentConfig = agents[agentId]
3120
3115
  } 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,23 @@ 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
+ writeFileSync(envScriptPath, envParts.join('\n') + '\n')
1249
+ } catch { /* empty */ }
1250
+ const envSourceCmd = startupCommand
1251
+ ? `clear && source ${JSON.stringify(envScriptPath)} && clear`
1252
+ : `source ${JSON.stringify(envScriptPath)}`
1256
1253
 
1257
1254
  setTimeout(() => {
1258
1255
  try {
1259
- execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(envExports)}`, { stdio: 'ignore' })
1256
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(envSourceCmd)}`, { stdio: 'ignore' })
1260
1257
  execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
1261
1258
  } catch { /* empty */ }
1262
1259
  }, 300)
@@ -12,7 +12,7 @@
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
18
 
@@ -43,7 +43,8 @@ if (flags.nuke) {
43
43
 
44
44
  /**
45
45
  * Run a potentially slow task with a spinner that only appears after 500ms.
46
- * If the task completes quickly, shows the done message immediately.
46
+ * IMPORTANT: `fn` must be async (don't use execSync — it blocks the event loop
47
+ * and prevents the spinner from animating).
47
48
  */
48
49
  async function withSpin(label, doneMsg, fn) {
49
50
  const spin = p.spinner()
@@ -59,6 +60,54 @@ async function withSpin(label, doneMsg, fn) {
59
60
  }
60
61
  }
61
62
 
63
+ /**
64
+ * Async command runner — does NOT block the event loop, so spinners animate.
65
+ */
66
+ function runAsync(cmd, args = [], opts = {}) {
67
+ return new Promise((resolve, reject) => {
68
+ const child = spawn(cmd, args, { stdio: 'ignore', ...opts })
69
+ child.on('error', reject)
70
+ child.on('exit', (code) => {
71
+ if (code === 0) resolve()
72
+ else reject(new Error(`${cmd} exited with code ${code}`))
73
+ })
74
+ })
75
+ }
76
+
77
+ /**
78
+ * Install a brew package with an animated spinner.
79
+ */
80
+ async function brewInstall(pkg, label) {
81
+ const spin = p.spinner()
82
+ spin.start(`Installing ${label}`)
83
+ try {
84
+ await runAsync('brew', ['install', pkg])
85
+ spin.stop(`${label} installed`)
86
+ return true
87
+ } catch {
88
+ spin.stop(`Failed to install ${label}`)
89
+ p.log.warning(`Install manually: brew install ${pkg}`)
90
+ return false
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Quick network probe — alerts the user if GitHub looks unreachable.
96
+ * We don't block on failure; downstream installers will surface their own errors.
97
+ */
98
+ async function checkNetwork() {
99
+ const http = await import('node:https')
100
+ return new Promise((resolve) => {
101
+ const req = http.request('https://api.github.com', { method: 'HEAD', timeout: 4000 }, (res) => {
102
+ resolve(res.statusCode != null && res.statusCode < 500)
103
+ res.resume()
104
+ })
105
+ req.on('error', () => resolve(false))
106
+ req.on('timeout', () => { req.destroy(); resolve(false) })
107
+ req.end()
108
+ })
109
+ }
110
+
62
111
  function mascot() {
63
112
  const d = dim('·')
64
113
  const f = magenta
@@ -87,13 +136,20 @@ function isInstalled(cmd) {
87
136
 
88
137
  p.intro('storyboard setup')
89
138
 
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')
139
+ // 0. Network probe alert if GitHub is unreachable; don't block.
140
+ {
141
+ const online = await checkNetwork()
142
+ if (!online) {
143
+ p.log.warning('Network looks offline — installers/downloads may fail')
144
+ p.log.info(dim(' Tried HEAD https://api.github.com; check VPN/proxy/DNS'))
145
+ } else {
146
+ p.log.success('Network reachable')
147
+ }
95
148
  }
96
149
 
150
+ // Node is assumed to be present (you already ran `npx storyboard setup`).
151
+ // node_modules will be installed at the end if missing.
152
+
97
153
  // 2. Homebrew
98
154
  let hasBrew = isInstalled('brew')
99
155
  if (!hasBrew) {
@@ -121,35 +177,43 @@ if (hasBrew) {
121
177
  if (isInstalled('git')) {
122
178
  p.log.success('Git installed')
123
179
  } 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
- }
180
+ await brewInstall('git', 'Git')
133
181
  }
134
182
  }
135
183
 
136
184
  // 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.
185
+ // `http://localhost:<port>/storyboard/`.
139
186
 
140
187
  if (hasBrew) {
141
- // 5. GitHub CLI
188
+ // 5. GitHub CLI — required for `gh auth`, `gh pr`, `gh issue` in agents.
189
+ let ghNewlyInstalled = false
142
190
  if (isInstalled('gh')) {
143
191
  p.log.success('GitHub CLI installed')
144
192
  } else {
145
- const ghSpin = p.spinner()
146
- ghSpin.start('Installing GitHub CLI')
193
+ ghNewlyInstalled = await brewInstall('gh', 'GitHub CLI')
194
+ }
195
+
196
+ // 5a. tmux (required for headless agent sessions)
197
+ if (isInstalled('tmux')) {
198
+ p.log.success('tmux installed')
199
+ } else {
200
+ await brewInstall('tmux', 'tmux')
201
+ }
202
+
203
+ // 5b. Surface gh auth status. Even if gh was already installed, we should
204
+ // prompt the user to log in — agents that shell out to `gh` will fail
205
+ // silently otherwise.
206
+ if (isInstalled('gh')) {
207
+ let authed = false
147
208
  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')
209
+ execSync('gh auth status', { stdio: 'ignore' })
210
+ authed = true
211
+ } catch { /* not authed */ }
212
+ if (authed) {
213
+ p.log.success('GitHub CLI authenticated')
214
+ } else {
215
+ p.log.warning(ghNewlyInstalled ? 'GitHub CLI installed but not logged in' : 'GitHub CLI is not logged in')
216
+ p.log.info(` Run ${yellow('gh auth login')} to authenticate`)
153
217
  }
154
218
  }
155
219
  }
@@ -195,24 +259,37 @@ if (isInstalled('code')) {
195
259
  }
196
260
  }
197
261
 
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
207
- const localBin = `${process.env.HOME}/.local/bin`
208
- if (!process.env.PATH.includes(localBin)) {
209
- process.env.PATH = `${localBin}:${process.env.PATH}`
262
+ // 6a. Copilot CLI — install via the official script (no homebrew dependency).
263
+ // curl ships with macOS and all major Linux distros.
264
+ // Auth is separate from `gh`: copilot has its own credential store.
265
+ {
266
+ let copilotNewlyInstalled = false
267
+ if (isInstalled('copilot')) {
268
+ p.log.success('Copilot CLI installed')
269
+ } else {
270
+ const spin = p.spinner()
271
+ spin.start('Installing Copilot CLI')
272
+ try {
273
+ await runAsync('bash', ['-c', 'curl -fsSL https://gh.io/copilot-install | bash'])
274
+ // Install script drops the binary in ~/.local/bin when run as
275
+ // non-root. Make sure the current process can find it for the
276
+ // remainder of setup, and warn the user to add it to their shell rc.
277
+ const localBin = `${process.env.HOME}/.local/bin`
278
+ if (!process.env.PATH.includes(localBin)) {
279
+ process.env.PATH = `${localBin}:${process.env.PATH}`
280
+ }
281
+ spin.stop('Copilot CLI installed')
282
+ copilotNewlyInstalled = true
283
+ if (!isInstalled('copilot')) {
284
+ p.log.warning(`copilot not on PATH — add ${yellow('export PATH="$HOME/.local/bin:$PATH"')} to your shell rc`)
285
+ }
286
+ } catch {
287
+ spin.stop('Failed to install Copilot CLI')
288
+ p.log.warning('Install manually: curl -fsSL https://gh.io/copilot-install | bash')
210
289
  }
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')
290
+ }
291
+ if (copilotNewlyInstalled || isInstalled('copilot')) {
292
+ p.log.info(` Auth is separate from gh — run ${yellow('copilot')} then ${yellow('/login')}`)
216
293
  }
217
294
  }
218
295
 
@@ -352,14 +429,14 @@ if (isInstalled('copilot')) {
352
429
 
353
430
  // 10. Install / sync dependencies
354
431
  {
432
+ const installSpin = p.spinner()
433
+ installSpin.start('Installing dependencies')
355
434
  try {
356
- await withSpin(
357
- 'Installing dependencies...',
358
- 'Dependencies installed',
359
- () => { run('npm install', { stdio: 'ignore' }) }
360
- )
435
+ await runAsync('npm', ['install'])
436
+ installSpin.stop('Dependencies installed')
361
437
  } catch {
362
- p.log.warning('npm install failed — run it manually to see details')
438
+ installSpin.stop('npm install failed')
439
+ p.log.warning('Run it manually to see details:')
363
440
  p.log.info(` ${dim('npm install')}`)
364
441
  }
365
442
  }
@@ -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,
@@ -65,6 +65,7 @@
65
65
  * @property {string} [sessionIdEnv] — env var exposed in the agent's SessionStart hook payload that holds its session id (e.g. "COPILOT_AGENT_SESSION_ID"). When set, the server captures the id per-widget so cold restarts can auto-resume.
66
66
  * @property {string} [sessionStateDir] — directory where the agent stores per-session state, used to pre-flight `--resume` (e.g. "~/.copilot/session-state"). Pass `null` to skip the fs check (UUID-only validation).
67
67
  * @property {string} [sessionStateGlob] — alternative to sessionStateDir for agents that store sessions under a per-project subdir, with `{id}` placeholder (e.g. "~/.claude/projects/*‍/{id}.jsonl").
68
+ * @property {boolean} [resumeFallback] — when true (default), the resume command is shell-chained with `|| <startupCommand>` so a runtime resume failure falls through to a fresh session instead of leaving the widget with a dead terminal. Set false to opt out.
68
69
  * @property {boolean} [resizable] — override terminal resizability for this agent
69
70
  * @property {number} [defaultWidth] — override default width
70
71
  * @property {number} [defaultHeight] — override default height
@@ -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) => {
@@ -547,6 +547,90 @@ function readCoreConfigFile(root, filename) {
547
547
  return null
548
548
  }
549
549
 
550
+ /**
551
+ * Resolve the absolute path of a library config file (returns the first
552
+ * candidate that exists, or null). Used by syncScaffoldDir to copy raw
553
+ * file contents (including comments) rather than re-serializing parsed JSON.
554
+ */
555
+ function resolveCoreConfigFilePath(root, filename) {
556
+ const candidates = [
557
+ path.resolve(root, `packages/storyboard/${filename}`),
558
+ path.resolve(root, `node_modules/@dfosco/storyboard/${filename}`),
559
+ ]
560
+ for (const p of candidates) {
561
+ try { fs.accessSync(p); return p } catch { /* try next */ }
562
+ }
563
+ return null
564
+ }
565
+
566
+ const SCAFFOLD_README = `# .storyboard/scaffold/
567
+
568
+ This directory is **always rewritten on dev-server boot** to reflect the
569
+ library's current default config files. The Storyboard server **never reads
570
+ config from this directory** — these files are reference copies for you to
571
+ customize.
572
+
573
+ ## How to customize
574
+
575
+ 1. Pick the config file you want to override (e.g. \`terminal.config.json\`).
576
+ 2. **Copy it to your project root** (next to \`storyboard.config.json\`).
577
+ 3. Edit only the keys you care about — leaf-level merge means everything
578
+ else continues to inherit the library defaults, so future updates
579
+ (new agents, new readiness signals, etc.) reach you automatically.
580
+
581
+ ## Why a separate directory?
582
+
583
+ - Customers who don't want to customize don't see config clutter at the root.
584
+ - Customers who do want to customize have all the defaults available as a
585
+ living reference, version-bumped with every storyboard release.
586
+ - Files at the root override the defaults; missing files mean "use library
587
+ defaults". No empty placeholder files cluttering the project.
588
+
589
+ ## What's in here
590
+
591
+ | File | What it covers |
592
+ |------|----------------|
593
+ | \`terminal.config.json\` | Terminal widgets + canvas agent CLIs (copilot/claude/codex) |
594
+ | \`toolbar.config.json\` | Toolbar tool registry + visibility |
595
+ | \`commandpalette.config.json\` | Command palette entries |
596
+ | \`paste.config.json\` | URL → widget paste rules |
597
+ | \`widgets.config.json\` | Widget defaults (size, behavior) |
598
+
599
+ Don't edit files in this directory — your changes will be overwritten on
600
+ the next dev-server boot. Always copy to the project root first.
601
+ `
602
+
603
+ const SCAFFOLD_FILES = [
604
+ 'terminal.config.json',
605
+ 'toolbar.config.json',
606
+ 'commandpalette.config.json',
607
+ 'paste.config.json',
608
+ 'widgets.config.json',
609
+ ]
610
+
611
+ /**
612
+ * Sync `.storyboard/scaffold/` with the library's current default config
613
+ * files. Always overwrites — users must copy to the project root to
614
+ * customize. Idempotent and best-effort.
615
+ */
616
+ function syncScaffoldDir(root) {
617
+ const scaffoldDir = path.resolve(root, '.storyboard', 'scaffold')
618
+ try { fs.mkdirSync(scaffoldDir, { recursive: true }) } catch { /* empty */ }
619
+
620
+ const readmePath = path.resolve(scaffoldDir, 'README.md')
621
+ try { fs.writeFileSync(readmePath, SCAFFOLD_README) } catch { /* empty */ }
622
+
623
+ for (const filename of SCAFFOLD_FILES) {
624
+ const src = resolveCoreConfigFilePath(root, filename)
625
+ if (!src) continue
626
+ const dest = path.resolve(scaffoldDir, filename)
627
+ try {
628
+ const raw = fs.readFileSync(src, 'utf-8')
629
+ fs.writeFileSync(dest, raw)
630
+ } catch { /* skip on error */ }
631
+ }
632
+ }
633
+
550
634
  /**
551
635
  * Deep-merge helper (same as loader.js deepMerge but available at build time).
552
636
  * Arrays are replaced, not concatenated. Objects are recursively merged.
@@ -597,6 +681,7 @@ function buildUnifiedConfig(root) {
597
681
  const coreCommandPalette = readCoreConfigFile(root, 'commandpalette.config.json') || {}
598
682
  const corePaste = readCoreConfigFile(root, 'paste.config.json') || {}
599
683
  const coreWidgets = readCoreConfigFile(root, 'widgets.config.json') || {}
684
+ const coreTerminal = readCoreConfigFile(root, 'terminal.config.json') || {}
600
685
 
601
686
  // 2. Read storyboard.config.json (middle priority)
602
687
  // Use the schema-defaulted config for most things, but also read
@@ -619,6 +704,17 @@ function buildUnifiedConfig(root) {
619
704
  const afterSbWidgets = rawSbConfig.widgets
620
705
  ? deepMergeBuild(coreWidgets, sbConfig.widgets || {})
621
706
  : coreWidgets
707
+ // For terminal/agents, slot canvas.terminal + canvas.agents from storyboard.config.json
708
+ // into the same shape as terminal.config.json so the merge is uniform.
709
+ const sbTerminalLike = (rawSbConfig.canvas && (rawSbConfig.canvas.terminal || rawSbConfig.canvas.agents))
710
+ ? {
711
+ ...(rawSbConfig.canvas.terminal ? { terminal: sbConfig.canvas.terminal } : {}),
712
+ ...(rawSbConfig.canvas.agents ? { agents: sbConfig.canvas.agents } : {}),
713
+ }
714
+ : null
715
+ const afterSbTerminal = sbTerminalLike
716
+ ? deepMergeBuild(coreTerminal, sbTerminalLike)
717
+ : coreTerminal
622
718
 
623
719
  // 4. Read user domain config files (highest priority)
624
720
  const userFiles = [
@@ -626,6 +722,7 @@ function buildUnifiedConfig(root) {
626
722
  { domain: 'paste', filename: 'paste.config.json' },
627
723
  { domain: 'toolbar', filename: 'toolbar.config.json' },
628
724
  { domain: 'commandPalette', filename: 'commandpalette.config.json' },
725
+ { domain: 'terminal', filename: 'terminal.config.json' },
629
726
  ]
630
727
 
631
728
  const userConfigs = {}
@@ -648,6 +745,9 @@ function buildUnifiedConfig(root) {
648
745
  const finalWidgets = userConfigs.widgets
649
746
  ? deepMergeBuild(afterSbWidgets, userConfigs.widgets.data)
650
747
  : afterSbWidgets
748
+ const finalTerminal = userConfigs.terminal
749
+ ? deepMergeBuild(afterSbTerminal, userConfigs.terminal.data)
750
+ : afterSbTerminal
651
751
 
652
752
  // 6. Detect overlaps between storyboard.config.json and user domain configs
653
753
  const domainOverlapChecks = [
@@ -664,18 +764,32 @@ function buildUnifiedConfig(root) {
664
764
  }
665
765
  }
666
766
  }
767
+ // Terminal overlap check: storyboard.config.json.canvas.{terminal,agents} vs terminal.config.json
768
+ if (sbTerminalLike && userConfigs.terminal) {
769
+ const overlaps = findOverlappingKeys(sbTerminalLike, userConfigs.terminal.data)
770
+ for (const key of overlaps) {
771
+ warnings.push(`Config overlap: "${key}" is defined in both storyboard.config.json.canvas and terminal.config.json — terminal.config.json wins.`)
772
+ }
773
+ }
667
774
 
668
775
  // 7. Build the unified config object.
669
776
  // Start from the schema-defaulted sbConfig so every top-level key from
670
777
  // storyboard.config.json (and every schema default) flows to initConfig().
671
778
  // Then override the domain-specific slices that have their own dedicated
672
- // config files merged above (toolbar/commandPalette/paste/widgets).
779
+ // config files merged above (toolbar/commandPalette/paste/widgets/terminal).
780
+ const sbCanvas = sbConfig?.canvas || {}
673
781
  const unified = {
674
- ...sbConfig,
782
+ ...(sbConfig || {}),
675
783
  toolbar: finalToolbar,
676
784
  commandPalette: finalCommandPalette,
677
785
  paste: finalPaste,
678
786
  widgets: finalWidgets,
787
+ canvas: {
788
+ ...sbCanvas,
789
+ terminal: deepMergeBuild(sbCanvas.terminal || {}, finalTerminal.terminal || {}),
790
+ agents: deepMergeBuild(sbCanvas.agents || {}, finalTerminal.agents || {}),
791
+ ...(finalTerminal.showAgentsInAddMenu !== undefined ? { showAgentsInAddMenu: finalTerminal.showAgentsInAddMenu } : {}),
792
+ },
679
793
  }
680
794
 
681
795
  return { unified, warnings }
@@ -1063,6 +1177,14 @@ export default function storyboardDataPlugin() {
1063
1177
  // dev so users can hit their routes, excluded from production builds
1064
1178
  // so private experiments don't ship.
1065
1179
  includeTilde = config.command === 'serve'
1180
+
1181
+ // On dev boot, sync .storyboard/scaffold/ with the library's current
1182
+ // default config files so users always have an up-to-date copy-source
1183
+ // for customizations. Files in .storyboard/scaffold/ are NEVER read by
1184
+ // the server — only files at the project root are. Always overwrites.
1185
+ if (config.command === 'serve') {
1186
+ try { syncScaffoldDir(root) } catch { /* best-effort */ }
1187
+ }
1066
1188
  },
1067
1189
 
1068
1190
  resolveId(id) {
@@ -1209,7 +1331,7 @@ export default function storyboardDataPlugin() {
1209
1331
  }
1210
1332
 
1211
1333
  // Invalidate when any config file inside a prototype changes
1212
- const protoConfigPattern = /\/(toolbar|commandpalette|widgets|paste)\.config\.json$/
1334
+ const protoConfigPattern = /\/(toolbar|commandpalette|widgets|paste|terminal)\.config\.json$/
1213
1335
  if (protoConfigPattern.test(normalized) && normalized.includes('/prototypes/')) {
1214
1336
  buildResult = null
1215
1337
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
@@ -1368,6 +1490,7 @@ export default function storyboardDataPlugin() {
1368
1490
  'commandpalette.config.json',
1369
1491
  'paste.config.json',
1370
1492
  'widgets.config.json',
1493
+ 'terminal.config.json',
1371
1494
  ].map(f => path.resolve(root, f))
1372
1495
  const watchedConfigPaths = new Set([configPath, ...domainConfigFiles])
1373
1496
  for (const p of domainConfigFiles) watcher.add(p)
@@ -0,0 +1,63 @@
1
+ {
2
+ "$schema": "./terminal.schema.json",
3
+ "$comment": "Defaults for terminal widgets and canvas agents. Users can override at the project root by copying this file from .storyboard/scaffold/terminal.config.json. Leaf-level merge: only the specific keys you set are overridden, everything else inherits the library defaults — so future agent additions or readinessSignal tweaks reach you automatically.",
4
+ "terminal": {
5
+ "resizable": true,
6
+ "defaultWidth": 1000,
7
+ "defaultHeight": 800,
8
+ "fontSize": 14,
9
+ "fontFamily": "'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace",
10
+ "prompt": "❯ ",
11
+ "startupCommand": null,
12
+ "defaultStartupSequence": null
13
+ },
14
+ "agents": {
15
+ "copilot": {
16
+ "label": "Copilot CLI",
17
+ "default": true,
18
+ "icon": "primer/copilot",
19
+ "startupCommand": "copilot --agent terminal-agent",
20
+ "resumeCommand": "copilot --resume={id} --agent terminal-agent",
21
+ "sessionIdEnv": "COPILOT_AGENT_SESSION_ID",
22
+ "postStartup": "/allow-all on",
23
+ "readinessSignal": "Environment loaded:",
24
+ "resizable": true
25
+ },
26
+ "claude": {
27
+ "label": "Claude Code",
28
+ "icon": "claude",
29
+ "startupCommand": "claude --agent terminal-agent --dangerously-skip-permissions",
30
+ "resumeCommand": "claude --resume {id} --agent terminal-agent --dangerously-skip-permissions",
31
+ "sessionIdEnv": "CLAUDE_SESSION_ID",
32
+ "sessionStateGlob": "~/.claude/projects/*/{id}.jsonl",
33
+ "readinessSignal": "bypass permissions",
34
+ "resizable": true
35
+ },
36
+ "codex": {
37
+ "label": "Codex CLI",
38
+ "icon": "codex",
39
+ "startupCommand": "codex --ask-for-approval never",
40
+ "resumeCommand": "codex resume {id}",
41
+ "sessionIdEnv": "CODEX_SESSION_ID",
42
+ "sessionStateGlob": "~/.codex/sessions/**/rollout-*-{id}.jsonl",
43
+ "configFiles": [".codex/config.toml"],
44
+ "resizable": true
45
+ }
46
+ },
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
+ }
63
+ }