@dfosco/storyboard 0.6.0-beta.1 → 0.6.0-beta.10
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 +72 -48
- package/src/core/canvas/agent-session.js +197 -24
- package/src/core/canvas/agent-session.test.js +43 -3
- package/src/core/canvas/configReader.js +110 -0
- package/src/core/canvas/hot-pool.js +4 -2
- package/src/core/canvas/server.js +10 -11
- package/src/core/canvas/terminal-server.js +74 -42
- package/src/core/cli/dev.js +54 -3
- package/src/core/cli/setup.js +198 -46
- package/src/core/cli/terminal-welcome.js +5 -6
- package/src/core/cli/userState.js +63 -0
- package/src/core/stores/configSchema.js +4 -1
- package/src/core/vite/server-plugin.js +3 -2
- package/src/internals/vite/data-plugin.js +126 -3
- package/terminal.config.json +61 -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.10",
|
|
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,61 +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.
|
|
59
|
+
|
|
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):
|
|
58
61
|
|
|
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
|
-
|
|
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
|
|
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"
|
|
89
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
|
+
},
|
|
90
111
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
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
|
|
95
115
|
}
|
|
96
116
|
```
|
|
97
117
|
|
|
@@ -116,6 +136,10 @@ Clients on 4.1.x likely have no `canvas` block at all. The full canvas config is
|
|
|
116
136
|
| `startupCommand` | yes | Shell command to start the agent |
|
|
117
137
|
| `resumeCommand` | no | Full launch template to resume a session, with `{id}` placeholder (e.g. `copilot --resume={id} --agent terminal-agent`). Used both for auto-resume on cold restart and for the interactive "Browse existing sessions" flow. |
|
|
118
138
|
| `sessionIdEnv` | no | Env var exposed in the agent SessionStart hook payload that holds its session id (e.g. `COPILOT_AGENT_SESSION_ID`). When set, widget cold restarts auto-resume the previous session. |
|
|
139
|
+
| `sessionStateDir` | no | 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). |
|
|
140
|
+
| `sessionStateGlob` | no | Glob to validate session existence for agents that store sessions in nested subdirs. Supports `<root>/*/{id}.jsonl` (Claude) and `<root>/**/<name-with-{id}>` (Codex). |
|
|
141
|
+
|
|
142
|
+
**Codex CLI: one-time hook trust.** Codex requires explicit user trust for non-managed hooks (`Non-managed command hooks must be reviewed and trusted before they run`). After the first dev-server boot, run `codex` interactively in any directory and enter `/hooks`, navigate to SessionStart, and enable the `storyboard-capture` hook. Trust persists in `~/.codex/state_*.sqlite`. Until trusted, Codex agent widgets will launch fresh on restart instead of resuming.
|
|
119
143
|
| `postStartup` | no | Text sent to the agent's stdin after it starts |
|
|
120
144
|
| `readinessSignal` | no | Substring to wait for in output before marking agent as ready |
|
|
121
145
|
| `configFiles` | no | Array of config file paths the agent requires |
|
|
@@ -11,21 +11,25 @@
|
|
|
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
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, watch as fsWatch } from 'node:fs'
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, watch as fsWatch, readdirSync, statSync } from 'node:fs'
|
|
22
22
|
import { join } from 'node:path'
|
|
23
23
|
import { homedir } from 'node:os'
|
|
24
|
+
import { execFileSync } from 'node:child_process'
|
|
24
25
|
|
|
25
26
|
const CAPTURE_DIR = join('.storyboard', 'agent-sessions')
|
|
26
27
|
const COPILOT_USER_HOOKS_DIR = join(homedir(), '.copilot', 'hooks')
|
|
27
28
|
const COPILOT_HOOK_FILENAME = 'storyboard-capture.json'
|
|
28
29
|
const COPILOT_SESSION_STATE_DIR = join(homedir(), '.copilot', 'session-state')
|
|
30
|
+
const CLAUDE_SETTINGS_PATH = join(homedir(), '.claude', 'settings.json')
|
|
31
|
+
const CLAUDE_HOOK_MARKER = 'storyboard-capture'
|
|
32
|
+
const CODEX_HOOKS_PATH = join(homedir(), '.codex', 'hooks.json')
|
|
29
33
|
|
|
30
34
|
/** Resolve absolute path to the per-widget capture directory under root. */
|
|
31
35
|
function captureDir(root) {
|
|
@@ -62,24 +66,11 @@ export function ensureCopilotCaptureHookInstalled() {
|
|
|
62
66
|
|
|
63
67
|
const hookPath = join(COPILOT_USER_HOOKS_DIR, COPILOT_HOOK_FILENAME)
|
|
64
68
|
|
|
65
|
-
// Single-line bash script. Reads stdin JSON, extracts sessionId via sed
|
|
66
|
-
// (no jq dependency), writes it to the widget's per-project capture file.
|
|
67
|
-
const bashScript =
|
|
68
|
-
'wid="${STORYBOARD_WIDGET_ID}"; ' +
|
|
69
|
-
'root="${STORYBOARD_PROJECT_ROOT}"; ' +
|
|
70
|
-
'[ -z "$wid" ] && exit 0; ' +
|
|
71
|
-
'[ -z "$root" ] && exit 0; ' +
|
|
72
|
-
'id=$(cat | sed -n \'s/.*"sessionId"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p\' | head -n1); ' +
|
|
73
|
-
'[ -z "$id" ] && exit 0; ' +
|
|
74
|
-
'dir="$root/.storyboard/agent-sessions"; ' +
|
|
75
|
-
'mkdir -p "$dir" 2>/dev/null; ' +
|
|
76
|
-
'printf %s "$id" > "$dir/$wid.session-id"'
|
|
77
|
-
|
|
78
69
|
const hook = {
|
|
79
70
|
version: 1,
|
|
80
71
|
hooks: {
|
|
81
72
|
sessionStart: [
|
|
82
|
-
{ type: 'command', bash:
|
|
73
|
+
{ type: 'command', bash: buildCaptureBashScript(), timeoutSec: 5 },
|
|
83
74
|
],
|
|
84
75
|
},
|
|
85
76
|
}
|
|
@@ -94,6 +85,125 @@ export function ensureCopilotCaptureHookInstalled() {
|
|
|
94
85
|
return hookPath
|
|
95
86
|
}
|
|
96
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Install (idempotently) a SessionStart hook in `~/.claude/settings.json`
|
|
90
|
+
* that captures Claude Code session ids the same way the Copilot hook does.
|
|
91
|
+
*
|
|
92
|
+
* Claude's hook payload uses `session_id` (snake_case) instead of Copilot's
|
|
93
|
+
* `sessionId` — our capture script handles both. Claude reads the hook from
|
|
94
|
+
* `~/.claude/settings.json` and merges it with project / local / managed
|
|
95
|
+
* settings, so we install user-scope and let Claude do the merging.
|
|
96
|
+
*
|
|
97
|
+
* Existing settings are preserved: we read, deep-merge our hook into the
|
|
98
|
+
* SessionStart array (replacing any prior storyboard hook by `command`
|
|
99
|
+
* marker), and write back.
|
|
100
|
+
*
|
|
101
|
+
* Safe to call on every dev-server boot.
|
|
102
|
+
*/
|
|
103
|
+
export function ensureClaudeCaptureHookInstalled() {
|
|
104
|
+
let settings = {}
|
|
105
|
+
try {
|
|
106
|
+
settings = JSON.parse(readFileSync(CLAUDE_SETTINGS_PATH, 'utf8'))
|
|
107
|
+
} catch { /* file may not exist or be invalid — start fresh */ }
|
|
108
|
+
|
|
109
|
+
if (typeof settings !== 'object' || settings === null) settings = {}
|
|
110
|
+
if (typeof settings.hooks !== 'object' || settings.hooks === null) settings.hooks = {}
|
|
111
|
+
if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = []
|
|
112
|
+
|
|
113
|
+
const ourHandler = {
|
|
114
|
+
type: 'command',
|
|
115
|
+
command: buildCaptureBashScript(),
|
|
116
|
+
timeout: 5,
|
|
117
|
+
}
|
|
118
|
+
// Claude wraps handlers in a matcher group. Use no matcher (matches all).
|
|
119
|
+
const ourGroup = { hooks: [ourHandler] }
|
|
120
|
+
|
|
121
|
+
// Replace any prior storyboard-capture group; identify by marker substring.
|
|
122
|
+
const next = settings.hooks.SessionStart.filter((g) => {
|
|
123
|
+
const handlers = Array.isArray(g?.hooks) ? g.hooks : []
|
|
124
|
+
return !handlers.some((h) => typeof h?.command === 'string' && h.command.includes(CLAUDE_HOOK_MARKER))
|
|
125
|
+
})
|
|
126
|
+
// Tag our handler command with the marker so we can find/replace it later.
|
|
127
|
+
ourHandler.command = `# ${CLAUDE_HOOK_MARKER}\n${ourHandler.command}`
|
|
128
|
+
next.push(ourGroup)
|
|
129
|
+
|
|
130
|
+
settings.hooks.SessionStart = next
|
|
131
|
+
|
|
132
|
+
try { mkdirSync(join(homedir(), '.claude'), { recursive: true }) } catch { /* empty */ }
|
|
133
|
+
try {
|
|
134
|
+
writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n')
|
|
135
|
+
} catch { /* best-effort */ }
|
|
136
|
+
return CLAUDE_SETTINGS_PATH
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Install (idempotently) a SessionStart hook for Codex CLI at
|
|
141
|
+
* `~/.codex/hooks.json` using the same shared capture script.
|
|
142
|
+
*
|
|
143
|
+
* Codex's hook format is JSON with PascalCase event names like Claude's
|
|
144
|
+
* (and uses `session_id` snake_case in the payload, also like Claude).
|
|
145
|
+
* We own this file end-to-end (Codex merges multiple hook sources, so
|
|
146
|
+
* other hooks the user has via config.toml or repo-level files keep
|
|
147
|
+
* working). The marker comment identifies our handler for replace.
|
|
148
|
+
*/
|
|
149
|
+
export function ensureCodexCaptureHookInstalled() {
|
|
150
|
+
let hooks = { hooks: { SessionStart: [] } }
|
|
151
|
+
try {
|
|
152
|
+
const existing = JSON.parse(readFileSync(CODEX_HOOKS_PATH, 'utf8'))
|
|
153
|
+
if (existing && typeof existing === 'object') hooks = existing
|
|
154
|
+
} catch { /* file may not exist — start fresh */ }
|
|
155
|
+
|
|
156
|
+
if (typeof hooks.hooks !== 'object' || hooks.hooks === null) hooks.hooks = {}
|
|
157
|
+
if (!Array.isArray(hooks.hooks.SessionStart)) hooks.hooks.SessionStart = []
|
|
158
|
+
|
|
159
|
+
const ourHandler = {
|
|
160
|
+
type: 'command',
|
|
161
|
+
command: `# ${CLAUDE_HOOK_MARKER}\n${buildCaptureBashScript()}`,
|
|
162
|
+
timeout: 5,
|
|
163
|
+
}
|
|
164
|
+
// Codex matchers for SessionStart: "startup", "resume", "clear" — match all
|
|
165
|
+
const ourGroup = { matcher: '*', hooks: [ourHandler] }
|
|
166
|
+
|
|
167
|
+
hooks.hooks.SessionStart = hooks.hooks.SessionStart.filter((g) => {
|
|
168
|
+
const handlers = Array.isArray(g?.hooks) ? g.hooks : []
|
|
169
|
+
return !handlers.some((h) => typeof h?.command === 'string' && h.command.includes(CLAUDE_HOOK_MARKER))
|
|
170
|
+
})
|
|
171
|
+
hooks.hooks.SessionStart.push(ourGroup)
|
|
172
|
+
|
|
173
|
+
try { mkdirSync(join(homedir(), '.codex'), { recursive: true }) } catch { /* empty */ }
|
|
174
|
+
try {
|
|
175
|
+
writeFileSync(CODEX_HOOKS_PATH, JSON.stringify(hooks, null, 2) + '\n')
|
|
176
|
+
} catch { /* best-effort */ }
|
|
177
|
+
return CODEX_HOOKS_PATH
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Shared capture bash script. Handles both Copilot (`sessionId` camelCase)
|
|
182
|
+
* and Claude (`session_id` snake_case) payload shapes. Reads
|
|
183
|
+
* STORYBOARD_WIDGET_ID + STORYBOARD_PROJECT_ROOT from env (exported by
|
|
184
|
+
* terminal-server into the agent shell). Silently no-ops if either env
|
|
185
|
+
* var is missing.
|
|
186
|
+
*/
|
|
187
|
+
function buildCaptureBashScript() {
|
|
188
|
+
return [
|
|
189
|
+
'wid="${STORYBOARD_WIDGET_ID}"',
|
|
190
|
+
'root="${STORYBOARD_PROJECT_ROOT}"',
|
|
191
|
+
'[ -z "$wid" ] && exit 0',
|
|
192
|
+
'[ -z "$root" ] && exit 0',
|
|
193
|
+
'payload=$(cat)',
|
|
194
|
+
'id=$(printf %s "$payload" | sed -n \'s/.*"sessionId"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p\' | head -n1)',
|
|
195
|
+
'[ -z "$id" ] && id=$(printf %s "$payload" | sed -n \'s/.*"session_id"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p\' | head -n1)',
|
|
196
|
+
'dir="$root/.storyboard/agent-sessions"',
|
|
197
|
+
'mkdir -p "$dir" 2>/dev/null',
|
|
198
|
+
// Always touch the readiness marker — sessionStart fires only once
|
|
199
|
+
// the agent is fully loaded and the prompt input is interactive, so
|
|
200
|
+
// this is a much more reliable signal than the pre-agent shell echo.
|
|
201
|
+
'touch "$root/.storyboard/terminals/$wid.ready" 2>/dev/null',
|
|
202
|
+
'[ -z "$id" ] && exit 0',
|
|
203
|
+
'printf %s "$id" > "$dir/$wid.session-id"',
|
|
204
|
+
].join('; ')
|
|
205
|
+
}
|
|
206
|
+
|
|
97
207
|
/**
|
|
98
208
|
* Build a resume-aware startup command for an agent.
|
|
99
209
|
*
|
|
@@ -114,19 +224,40 @@ export function buildResumeStartupCommand({ startupCommand, sessionId, agentCfg
|
|
|
114
224
|
const template = agentCfg?.resumeCommand
|
|
115
225
|
if (!template || !template.includes('{id}')) return startupCommand
|
|
116
226
|
|
|
117
|
-
|
|
227
|
+
const resumeCmd = template.replace('{id}', sessionId)
|
|
228
|
+
|
|
229
|
+
// Graceful fallback: if the resume command exits non-zero (e.g. the agent
|
|
230
|
+
// CLI rejected the id, the on-disk session is corrupt, or the binary
|
|
231
|
+
// doesn't actually support resume the way we expect), fall through to a
|
|
232
|
+
// fresh session instead of leaving the widget with a dead terminal.
|
|
233
|
+
// A clean exit (user `/exit`s) returns 0 and skips the fallback.
|
|
234
|
+
if (agentCfg?.resumeFallback === false) return resumeCmd
|
|
235
|
+
const notice = `printf '\\n\\033[33m[storyboard] resume failed; starting fresh session...\\033[0m\\n'`
|
|
236
|
+
return `${resumeCmd} || { ${notice}; ${startupCommand}; }`
|
|
118
237
|
}
|
|
119
238
|
|
|
120
239
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
121
240
|
|
|
122
241
|
/**
|
|
123
|
-
* Decide whether a captured sessionId is still resumable.
|
|
124
|
-
*
|
|
125
|
-
*
|
|
242
|
+
* Decide whether a captured sessionId is still resumable.
|
|
243
|
+
*
|
|
244
|
+
* Strategies:
|
|
245
|
+
* - `agentCfg.sessionStateDir` → check if `<dir>/<id>` exists (Copilot)
|
|
246
|
+
* - `agentCfg.sessionStateGlob` → check if any
|
|
247
|
+
* `~/.claude/projects/*/<id>.jsonl` exists (Claude)
|
|
248
|
+
* - both null → trust the UUID
|
|
249
|
+
*
|
|
250
|
+
* Defaults to the Copilot session-state dir when nothing is configured.
|
|
126
251
|
*/
|
|
127
252
|
export function isResumableSessionId(sessionId, agentCfg = {}) {
|
|
128
253
|
if (!sessionId) return false
|
|
129
254
|
if (!UUID_RE.test(sessionId)) return false
|
|
255
|
+
|
|
256
|
+
// Explicit glob check (Claude-style: <dir>/*/<id>.jsonl)
|
|
257
|
+
if (agentCfg.sessionStateGlob) {
|
|
258
|
+
return matchesSessionStateGlob(sessionId, agentCfg.sessionStateGlob)
|
|
259
|
+
}
|
|
260
|
+
|
|
130
261
|
const stateDir = agentCfg.sessionStateDir === undefined
|
|
131
262
|
? COPILOT_SESSION_STATE_DIR
|
|
132
263
|
: agentCfg.sessionStateDir
|
|
@@ -134,6 +265,48 @@ export function isResumableSessionId(sessionId, agentCfg = {}) {
|
|
|
134
265
|
return existsSync(join(stateDir, sessionId))
|
|
135
266
|
}
|
|
136
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Check if `<root>/<anySubdir>/<id>.jsonl` exists, where `glob` is a
|
|
270
|
+
* shorthand string. Supports two forms:
|
|
271
|
+
* - `<root>/*` + `/{id}.jsonl` — exactly one subdir level (Claude pattern)
|
|
272
|
+
* - `<root>/**` + `/<name-with-{id}>` — recursive find (Codex pattern, where
|
|
273
|
+
* sessions are nested under year/month/day and the id is embedded in
|
|
274
|
+
* a longer filename like `rollout-<ts>-<id>.jsonl`)
|
|
275
|
+
*/
|
|
276
|
+
function matchesSessionStateGlob(sessionId, glob) {
|
|
277
|
+
const expanded = glob.replace('~', homedir()).replace(/\{id\}/g, sessionId)
|
|
278
|
+
|
|
279
|
+
// Recursive form: root/**/name
|
|
280
|
+
if (expanded.includes('/**/')) {
|
|
281
|
+
const [root, namePattern] = expanded.split('/**/')
|
|
282
|
+
if (!existsSync(root)) return false
|
|
283
|
+
try {
|
|
284
|
+
const out = execFileSync('find', [root, '-name', namePattern, '-print', '-quit'], {
|
|
285
|
+
encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
286
|
+
})
|
|
287
|
+
return out.trim().length > 0
|
|
288
|
+
} catch { return false }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Single-level form: root/*/suffix
|
|
292
|
+
const parts = expanded.split('/*/')
|
|
293
|
+
if (parts.length !== 2) {
|
|
294
|
+
// Plain path with no wildcard — direct check.
|
|
295
|
+
return existsSync(expanded)
|
|
296
|
+
}
|
|
297
|
+
const [root, suffix] = parts
|
|
298
|
+
let entries = []
|
|
299
|
+
try { entries = readdirSync(root) } catch { return false }
|
|
300
|
+
for (const name of entries) {
|
|
301
|
+
const full = join(root, name)
|
|
302
|
+
try {
|
|
303
|
+
if (!statSync(full).isDirectory()) continue
|
|
304
|
+
} catch { continue }
|
|
305
|
+
if (existsSync(join(full, suffix))) return true
|
|
306
|
+
}
|
|
307
|
+
return false
|
|
308
|
+
}
|
|
309
|
+
|
|
137
310
|
/**
|
|
138
311
|
* Watch `captureFile` for the agent's session id and call `onCapture(id)`
|
|
139
312
|
* once it appears or is updated. Unlike the previous one-shot version,
|
|
@@ -39,6 +39,32 @@ describe('agent-session', () => {
|
|
|
39
39
|
mkdirSync(join(stateDir, id), { recursive: true })
|
|
40
40
|
expect(isResumableSessionId(id, { sessionStateDir: stateDir })).toBe(true)
|
|
41
41
|
})
|
|
42
|
+
|
|
43
|
+
it('uses sessionStateGlob to validate per-project session files (Claude shape)', () => {
|
|
44
|
+
const id = '11111111-2222-4333-8444-555555555555'
|
|
45
|
+
const { mkdirSync, writeFileSync } = require('node:fs')
|
|
46
|
+
const projectsDir = join(root, 'projects')
|
|
47
|
+
mkdirSync(join(projectsDir, '-Users-foo-some-project'), { recursive: true })
|
|
48
|
+
writeFileSync(join(projectsDir, '-Users-foo-some-project', `${id}.jsonl`), '')
|
|
49
|
+
expect(isResumableSessionId(id, { sessionStateGlob: `${projectsDir}/*/{id}.jsonl` })).toBe(true)
|
|
50
|
+
expect(isResumableSessionId(
|
|
51
|
+
'99999999-2222-4333-8444-555555555555',
|
|
52
|
+
{ sessionStateGlob: `${projectsDir}/*/{id}.jsonl` },
|
|
53
|
+
)).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('uses recursive ** in sessionStateGlob to validate nested session files (Codex shape)', () => {
|
|
57
|
+
const id = '22222222-2222-4333-8444-555555555555'
|
|
58
|
+
const { mkdirSync, writeFileSync } = require('node:fs')
|
|
59
|
+
const sessionsRoot = join(root, 'sessions')
|
|
60
|
+
mkdirSync(join(sessionsRoot, '2026', '05', '15'), { recursive: true })
|
|
61
|
+
writeFileSync(join(sessionsRoot, '2026', '05', '15', `rollout-2026-05-15T10-00-00-${id}.jsonl`), '')
|
|
62
|
+
expect(isResumableSessionId(id, { sessionStateGlob: `${sessionsRoot}/**/rollout-*-{id}.jsonl` })).toBe(true)
|
|
63
|
+
expect(isResumableSessionId(
|
|
64
|
+
'99999999-2222-4333-8444-555555555555',
|
|
65
|
+
{ sessionStateGlob: `${sessionsRoot}/**/rollout-*-{id}.jsonl` },
|
|
66
|
+
)).toBe(false)
|
|
67
|
+
})
|
|
42
68
|
})
|
|
43
69
|
|
|
44
70
|
describe('buildResumeStartupCommand', () => {
|
|
@@ -56,13 +82,28 @@ describe('agent-session', () => {
|
|
|
56
82
|
expect(out).toBe('copilot --agent terminal-agent')
|
|
57
83
|
})
|
|
58
84
|
|
|
59
|
-
it('substitutes {id} into resumeCommand
|
|
85
|
+
it('substitutes {id} into resumeCommand and chains a fresh-session fallback', () => {
|
|
86
|
+
const out = buildResumeStartupCommand({
|
|
87
|
+
startupCommand: 'copilot --agent terminal-agent',
|
|
88
|
+
sessionId: '11111111-2222-4333-8444-555555555555',
|
|
89
|
+
agentCfg: {
|
|
90
|
+
sessionStateDir: null,
|
|
91
|
+
resumeCommand: 'copilot --resume={id} --agent terminal-agent',
|
|
92
|
+
},
|
|
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', () => {
|
|
60
100
|
const out = buildResumeStartupCommand({
|
|
61
101
|
startupCommand: 'copilot --agent terminal-agent',
|
|
62
102
|
sessionId: '11111111-2222-4333-8444-555555555555',
|
|
63
103
|
agentCfg: {
|
|
64
104
|
sessionStateDir: null,
|
|
65
105
|
resumeCommand: 'copilot --resume={id} --agent terminal-agent',
|
|
106
|
+
resumeFallback: false,
|
|
66
107
|
},
|
|
67
108
|
})
|
|
68
109
|
expect(out).toBe('copilot --resume=11111111-2222-4333-8444-555555555555 --agent terminal-agent')
|
|
@@ -78,8 +119,7 @@ describe('agent-session', () => {
|
|
|
78
119
|
})
|
|
79
120
|
})
|
|
80
121
|
|
|
81
|
-
describe('captureFilePath / readCapturedSessionId / clearCaptureFile', () => {
|
|
82
|
-
it('writes to .storyboard/agent-sessions/<key>.session-id', () => {
|
|
122
|
+
describe('captureFilePath / readCapturedSessionId / clearCaptureFile', () => { it('writes to .storyboard/agent-sessions/<key>.session-id', () => {
|
|
83
123
|
const cap = captureFilePath(root, 'agent-foo')
|
|
84
124
|
expect(cap).toBe(join(root, '.storyboard', 'agent-sessions', 'agent-foo.session-id'))
|
|
85
125
|
})
|
|
@@ -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
|
+
}
|