@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 +3 -2
- package/scaffold/gitignore +12 -2
- package/scaffold/skills/migrate/SKILL.md +68 -52
- package/scaffold/storyboard.config.json +0 -33
- package/src/core/canvas/agent-session.js +15 -6
- package/src/core/canvas/agent-session.test.js +16 -1
- package/src/core/canvas/configReader.js +110 -0
- package/src/core/canvas/hot-pool.js +1 -1
- package/src/core/canvas/server.js +4 -9
- package/src/core/canvas/terminal-server.js +32 -35
- package/src/core/cli/setup.js +126 -49
- package/src/core/cli/terminal-welcome.js +5 -6
- package/src/core/stores/configSchema.js +1 -0
- package/src/core/vite/server-plugin.js +3 -2
- package/src/internals/vite/data-plugin.js +126 -3
- package/terminal.config.json +63 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard",
|
|
3
|
-
"version": "0.6.0-beta.
|
|
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",
|
package/scaffold/gitignore
CHANGED
|
@@ -52,8 +52,18 @@ packages/core/dist/storyboard-ui.*
|
|
|
52
52
|
# Agent Browser
|
|
53
53
|
agent-browser.json
|
|
54
54
|
|
|
55
|
-
#
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
"
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"startupCommand":
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
"
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (!
|
|
182
|
-
for
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
|
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
|
-
//
|
|
1250
|
-
//
|
|
1251
|
-
//
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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(
|
|
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)
|
package/src/core/cli/setup.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
//
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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/`.
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
} catch {
|
|
151
|
-
|
|
152
|
-
p.log.
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
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
|
|
357
|
-
|
|
358
|
-
'Dependencies installed',
|
|
359
|
-
() => { run('npm install', { stdio: 'ignore' }) }
|
|
360
|
-
)
|
|
435
|
+
await runAsync('npm', ['install'])
|
|
436
|
+
installSpin.stop('Dependencies installed')
|
|
361
437
|
} catch {
|
|
362
|
-
|
|
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 {
|
|
21
|
-
import {
|
|
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
|
|
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
|
|
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 =
|
|
283
|
-
const agentsConfig =
|
|
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
|
+
}
|