@dfosco/storyboard 0.6.0-beta.1 → 0.6.0-beta.2

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.2",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -74,7 +74,9 @@ Clients on 4.1.x likely have no `canvas` block at all. The full canvas config is
74
74
  "label": "Claude Code",
75
75
  "icon": "claude",
76
76
  "startupCommand": "claude --agent terminal-agent --dangerously-skip-permissions",
77
- "resumeCommand": "claude --resume={id} --agent terminal-agent --dangerously-skip-permissions",
77
+ "resumeCommand": "claude --resume {id} --agent terminal-agent --dangerously-skip-permissions",
78
+ "sessionIdEnv": "CLAUDE_SESSION_ID",
79
+ "sessionStateGlob": "~/.claude/projects/*/{id}.jsonl",
78
80
  "resizable": true,
79
81
  "readinessSignal": "bypass permissions"
80
82
  },
@@ -18,7 +18,7 @@
18
18
  * pre-validate).
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
24
 
@@ -26,6 +26,8 @@ const CAPTURE_DIR = join('.storyboard', 'agent-sessions')
26
26
  const COPILOT_USER_HOOKS_DIR = join(homedir(), '.copilot', 'hooks')
27
27
  const COPILOT_HOOK_FILENAME = 'storyboard-capture.json'
28
28
  const COPILOT_SESSION_STATE_DIR = join(homedir(), '.copilot', 'session-state')
29
+ const CLAUDE_SETTINGS_PATH = join(homedir(), '.claude', 'settings.json')
30
+ const CLAUDE_HOOK_MARKER = 'storyboard-capture'
29
31
 
30
32
  /** Resolve absolute path to the per-widget capture directory under root. */
31
33
  function captureDir(root) {
@@ -62,24 +64,11 @@ export function ensureCopilotCaptureHookInstalled() {
62
64
 
63
65
  const hookPath = join(COPILOT_USER_HOOKS_DIR, COPILOT_HOOK_FILENAME)
64
66
 
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
67
  const hook = {
79
68
  version: 1,
80
69
  hooks: {
81
70
  sessionStart: [
82
- { type: 'command', bash: bashScript, timeoutSec: 5 },
71
+ { type: 'command', bash: buildCaptureBashScript(), timeoutSec: 5 },
83
72
  ],
84
73
  },
85
74
  }
@@ -94,6 +83,80 @@ export function ensureCopilotCaptureHookInstalled() {
94
83
  return hookPath
95
84
  }
96
85
 
86
+ /**
87
+ * Install (idempotently) a SessionStart hook in `~/.claude/settings.json`
88
+ * that captures Claude Code session ids the same way the Copilot hook does.
89
+ *
90
+ * Claude's hook payload uses `session_id` (snake_case) instead of Copilot's
91
+ * `sessionId` — our capture script handles both. Claude reads the hook from
92
+ * `~/.claude/settings.json` and merges it with project / local / managed
93
+ * settings, so we install user-scope and let Claude do the merging.
94
+ *
95
+ * Existing settings are preserved: we read, deep-merge our hook into the
96
+ * SessionStart array (replacing any prior storyboard hook by `command`
97
+ * marker), and write back.
98
+ *
99
+ * Safe to call on every dev-server boot.
100
+ */
101
+ export function ensureClaudeCaptureHookInstalled() {
102
+ let settings = {}
103
+ try {
104
+ settings = JSON.parse(readFileSync(CLAUDE_SETTINGS_PATH, 'utf8'))
105
+ } catch { /* file may not exist or be invalid — start fresh */ }
106
+
107
+ if (typeof settings !== 'object' || settings === null) settings = {}
108
+ if (typeof settings.hooks !== 'object' || settings.hooks === null) settings.hooks = {}
109
+ if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = []
110
+
111
+ const ourHandler = {
112
+ type: 'command',
113
+ command: buildCaptureBashScript(),
114
+ timeout: 5,
115
+ }
116
+ // Claude wraps handlers in a matcher group. Use no matcher (matches all).
117
+ const ourGroup = { hooks: [ourHandler] }
118
+
119
+ // Replace any prior storyboard-capture group; identify by marker substring.
120
+ const next = settings.hooks.SessionStart.filter((g) => {
121
+ const handlers = Array.isArray(g?.hooks) ? g.hooks : []
122
+ return !handlers.some((h) => typeof h?.command === 'string' && h.command.includes(CLAUDE_HOOK_MARKER))
123
+ })
124
+ // Tag our handler command with the marker so we can find/replace it later.
125
+ ourHandler.command = `# ${CLAUDE_HOOK_MARKER}\n${ourHandler.command}`
126
+ next.push(ourGroup)
127
+
128
+ settings.hooks.SessionStart = next
129
+
130
+ try { mkdirSync(join(homedir(), '.claude'), { recursive: true }) } catch { /* empty */ }
131
+ try {
132
+ writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n')
133
+ } catch { /* best-effort */ }
134
+ return CLAUDE_SETTINGS_PATH
135
+ }
136
+
137
+ /**
138
+ * Shared capture bash script. Handles both Copilot (`sessionId` camelCase)
139
+ * and Claude (`session_id` snake_case) payload shapes. Reads
140
+ * STORYBOARD_WIDGET_ID + STORYBOARD_PROJECT_ROOT from env (exported by
141
+ * terminal-server into the agent shell). Silently no-ops if either env
142
+ * var is missing.
143
+ */
144
+ function buildCaptureBashScript() {
145
+ return [
146
+ 'wid="${STORYBOARD_WIDGET_ID}"',
147
+ 'root="${STORYBOARD_PROJECT_ROOT}"',
148
+ '[ -z "$wid" ] && exit 0',
149
+ '[ -z "$root" ] && exit 0',
150
+ 'payload=$(cat)',
151
+ 'id=$(printf %s "$payload" | sed -n \'s/.*"sessionId"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p\' | head -n1)',
152
+ '[ -z "$id" ] && id=$(printf %s "$payload" | sed -n \'s/.*"session_id"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p\' | head -n1)',
153
+ '[ -z "$id" ] && exit 0',
154
+ 'dir="$root/.storyboard/agent-sessions"',
155
+ 'mkdir -p "$dir" 2>/dev/null',
156
+ 'printf %s "$id" > "$dir/$wid.session-id"',
157
+ ].join('; ')
158
+ }
159
+
97
160
  /**
98
161
  * Build a resume-aware startup command for an agent.
99
162
  *
@@ -120,13 +183,25 @@ export function buildResumeStartupCommand({ startupCommand, sessionId, agentCfg
120
183
  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
184
 
122
185
  /**
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.
186
+ * Decide whether a captured sessionId is still resumable.
187
+ *
188
+ * Strategies:
189
+ * - `agentCfg.sessionStateDir` → check if `<dir>/<id>` exists (Copilot)
190
+ * - `agentCfg.sessionStateGlob` → check if any
191
+ * `~/.claude/projects/*‍/<id>.jsonl` exists (Claude)
192
+ * - both null → trust the UUID
193
+ *
194
+ * Defaults to the Copilot session-state dir when nothing is configured.
126
195
  */
127
196
  export function isResumableSessionId(sessionId, agentCfg = {}) {
128
197
  if (!sessionId) return false
129
198
  if (!UUID_RE.test(sessionId)) return false
199
+
200
+ // Explicit glob check (Claude-style: <dir>/*/<id>.jsonl)
201
+ if (agentCfg.sessionStateGlob) {
202
+ return matchesSessionStateGlob(sessionId, agentCfg.sessionStateGlob)
203
+ }
204
+
130
205
  const stateDir = agentCfg.sessionStateDir === undefined
131
206
  ? COPILOT_SESSION_STATE_DIR
132
207
  : agentCfg.sessionStateDir
@@ -134,6 +209,34 @@ export function isResumableSessionId(sessionId, agentCfg = {}) {
134
209
  return existsSync(join(stateDir, sessionId))
135
210
  }
136
211
 
212
+ /**
213
+ * Check if `<root>/<anySubdir>/<id>.jsonl` exists, where `glob` is a
214
+ * shorthand string. Currently only supports the Claude pattern
215
+ * `~/.claude/projects/*‍/{id}.jsonl`. The `*` segment is interpreted as
216
+ * any single directory level.
217
+ */
218
+ function matchesSessionStateGlob(sessionId, glob) {
219
+ const expanded = glob.replace('~', homedir()).replace('{id}', sessionId)
220
+ // Find the `*` segment; everything before it is the root, everything
221
+ // after is the suffix to test inside each subdir.
222
+ const parts = expanded.split('/*/')
223
+ if (parts.length !== 2) {
224
+ // Plain path with no wildcard — direct check.
225
+ return existsSync(expanded)
226
+ }
227
+ const [root, suffix] = parts
228
+ let entries = []
229
+ try { entries = readdirSync(root) } catch { return false }
230
+ for (const name of entries) {
231
+ const full = join(root, name)
232
+ try {
233
+ if (!statSync(full).isDirectory()) continue
234
+ } catch { continue }
235
+ if (existsSync(join(full, suffix))) return true
236
+ }
237
+ return false
238
+ }
239
+
137
240
  /**
138
241
  * Watch `captureFile` for the agent's session id and call `onCapture(id)`
139
242
  * once it appears or is updated. Unlike the previous one-shot version,
@@ -39,6 +39,19 @@ 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
+ })
42
55
  })
43
56
 
44
57
  describe('buildResumeStartupCommand', () => {
@@ -78,8 +91,7 @@ describe('agent-session', () => {
78
91
  })
79
92
  })
80
93
 
81
- describe('captureFilePath / readCapturedSessionId / clearCaptureFile', () => {
82
- it('writes to .storyboard/agent-sessions/<key>.session-id', () => {
94
+ describe('captureFilePath / readCapturedSessionId / clearCaptureFile', () => { it('writes to .storyboard/agent-sessions/<key>.session-id', () => {
83
95
  const cap = captureFilePath(root, 'agent-foo')
84
96
  expect(cap).toBe(join(root, '.storyboard', 'agent-sessions', 'agent-foo.session-id'))
85
97
  })
@@ -62,6 +62,7 @@ import {
62
62
  watchSessionIdFile,
63
63
  captureFilePath,
64
64
  ensureCopilotCaptureHookInstalled,
65
+ ensureClaudeCaptureHookInstalled,
65
66
  } from './agent-session.js'
66
67
 
67
68
  let pty
@@ -687,8 +688,10 @@ export function setupTerminalServer(httpServer, base = '/', branch = 'unknown',
687
688
  initTerminalConfig(root)
688
689
 
689
690
  // Install user-level Copilot CLI hook (~/.copilot/hooks/storyboard-capture.json)
690
- // that captures sessionStart payloads into per-widget files. Idempotent.
691
+ // and Claude Code hook (~/.claude/settings.json) that capture sessionStart
692
+ // payloads into per-widget files. Idempotent.
691
693
  try { ensureCopilotCaptureHookInstalled() } catch { /* best-effort */ }
694
+ try { ensureClaudeCaptureHookInstalled() } catch { /* best-effort */ }
692
695
 
693
696
  // Best-effort: apply shell-config overrides if a tmux server already exists
694
697
  // from a previous dev server run. If no server exists, this fails silently —
@@ -62,7 +62,9 @@
62
62
  * @property {string} [readinessSignal] — tmux pane text that signals the agent is ready (fragile, prefer readinessFile)
63
63
  * @property {string} [resumeCommand] — full command template to resume a session, with `{id}` placeholder (e.g. `"copilot --resume={id} --agent terminal-agent"`). Used both for auto-resume on cold restart (with `{id}` substituted) and for the interactive "Browse existing sessions" flow (binary derived, `--resume` appended).
64
64
  * @property {boolean} [readinessFile] — use a file-based SessionStart hook for readiness (writes --settings with hook, polls for signal file)
65
- * @property {string} [sessionIdEnv] — env var exposed inside 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.
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
+ * @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
+ * @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").
66
68
  * @property {boolean} [resizable] — override terminal resizability for this agent
67
69
  * @property {number} [defaultWidth] — override default width
68
70
  * @property {number} [defaultHeight] — override default height