@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard",
3
- "version": "0.6.0-beta.1",
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",
@@ -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,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
- 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.
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
- // 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
- "resizable": true,
79
- "readinessSignal": "bypass permissions"
80
- },
81
- "codex": {
82
- "label": "Codex CLI",
83
- "icon": "codex",
84
- "startupCommand": "codex --full-auto",
85
- "resumeCommand": "codex --resume={id} --ask-for-approval never",
86
- "configFiles": [".codex/config.toml"],
87
- "resizable": true
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
- // Set to true to show agent entries in the canvas "+" add menu
92
- // Set to false to only show them in the command palette
93
- "showAgentsInAddMenu": false
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 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
- 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: bashScript, timeoutSec: 5 },
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
- return template.replace('{id}', sessionId)
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. For copilot, this
124
- * means the id is a UUID and `~/.copilot/session-state/<id>/` still exists.
125
- * Other agents can opt out of validation by setting `sessionStateDir` to null.
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 when valid', () => {
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
+ }