@dfosco/storyboard 0.6.0-beta.2 → 0.6.0-beta.4
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
|
@@ -84,7 +84,9 @@ Clients on 4.1.x likely have no `canvas` block at all. The full canvas config is
|
|
|
84
84
|
"label": "Codex CLI",
|
|
85
85
|
"icon": "codex",
|
|
86
86
|
"startupCommand": "codex --full-auto",
|
|
87
|
-
"resumeCommand": "codex
|
|
87
|
+
"resumeCommand": "codex resume {id}",
|
|
88
|
+
"sessionIdEnv": "CODEX_SESSION_ID",
|
|
89
|
+
"sessionStateGlob": "~/.codex/sessions/**/rollout-*-{id}.jsonl",
|
|
88
90
|
"configFiles": [".codex/config.toml"],
|
|
89
91
|
"resizable": true
|
|
90
92
|
}
|
|
@@ -118,6 +120,10 @@ Clients on 4.1.x likely have no `canvas` block at all. The full canvas config is
|
|
|
118
120
|
| `startupCommand` | yes | Shell command to start the agent |
|
|
119
121
|
| `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. |
|
|
120
122
|
| `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. |
|
|
123
|
+
| `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). |
|
|
124
|
+
| `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). |
|
|
125
|
+
|
|
126
|
+
**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.
|
|
121
127
|
| `postStartup` | no | Text sent to the agent's stdin after it starts |
|
|
122
128
|
| `readinessSignal` | no | Substring to wait for in output before marking agent as ready |
|
|
123
129
|
| `configFiles` | no | Array of config file paths the agent requires |
|
|
@@ -33,6 +33,39 @@
|
|
|
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
|
+
}
|
|
36
69
|
}
|
|
37
70
|
},
|
|
38
71
|
"customerMode": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
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')
|
|
@@ -28,6 +29,7 @@ const COPILOT_HOOK_FILENAME = 'storyboard-capture.json'
|
|
|
28
29
|
const COPILOT_SESSION_STATE_DIR = join(homedir(), '.copilot', 'session-state')
|
|
29
30
|
const CLAUDE_SETTINGS_PATH = join(homedir(), '.claude', 'settings.json')
|
|
30
31
|
const CLAUDE_HOOK_MARKER = 'storyboard-capture'
|
|
32
|
+
const CODEX_HOOKS_PATH = join(homedir(), '.codex', 'hooks.json')
|
|
31
33
|
|
|
32
34
|
/** Resolve absolute path to the per-widget capture directory under root. */
|
|
33
35
|
function captureDir(root) {
|
|
@@ -134,6 +136,47 @@ export function ensureClaudeCaptureHookInstalled() {
|
|
|
134
136
|
return CLAUDE_SETTINGS_PATH
|
|
135
137
|
}
|
|
136
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
|
+
|
|
137
180
|
/**
|
|
138
181
|
* Shared capture bash script. Handles both Copilot (`sessionId` camelCase)
|
|
139
182
|
* and Claude (`session_id` snake_case) payload shapes. Reads
|
|
@@ -211,14 +254,28 @@ export function isResumableSessionId(sessionId, agentCfg = {}) {
|
|
|
211
254
|
|
|
212
255
|
/**
|
|
213
256
|
* Check if `<root>/<anySubdir>/<id>.jsonl` exists, where `glob` is a
|
|
214
|
-
* shorthand string.
|
|
215
|
-
*
|
|
216
|
-
*
|
|
257
|
+
* shorthand string. Supports two forms:
|
|
258
|
+
* - `<root>/*` + `/{id}.jsonl` — exactly one subdir level (Claude pattern)
|
|
259
|
+
* - `<root>/**` + `/<name-with-{id}>` — recursive find (Codex pattern, where
|
|
260
|
+
* sessions are nested under year/month/day and the id is embedded in
|
|
261
|
+
* a longer filename like `rollout-<ts>-<id>.jsonl`)
|
|
217
262
|
*/
|
|
218
263
|
function matchesSessionStateGlob(sessionId, glob) {
|
|
219
|
-
const expanded = glob.replace('~', homedir()).replace(
|
|
220
|
-
|
|
221
|
-
//
|
|
264
|
+
const expanded = glob.replace('~', homedir()).replace(/\{id\}/g, sessionId)
|
|
265
|
+
|
|
266
|
+
// Recursive form: root/**/name
|
|
267
|
+
if (expanded.includes('/**/')) {
|
|
268
|
+
const [root, namePattern] = expanded.split('/**/')
|
|
269
|
+
if (!existsSync(root)) return false
|
|
270
|
+
try {
|
|
271
|
+
const out = execFileSync('find', [root, '-name', namePattern, '-print', '-quit'], {
|
|
272
|
+
encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
273
|
+
})
|
|
274
|
+
return out.trim().length > 0
|
|
275
|
+
} catch { return false }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Single-level form: root/*/suffix
|
|
222
279
|
const parts = expanded.split('/*/')
|
|
223
280
|
if (parts.length !== 2) {
|
|
224
281
|
// Plain path with no wildcard — direct check.
|
|
@@ -52,6 +52,19 @@ describe('agent-session', () => {
|
|
|
52
52
|
{ sessionStateGlob: `${projectsDir}/*/{id}.jsonl` },
|
|
53
53
|
)).toBe(false)
|
|
54
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
|
+
})
|
|
55
68
|
})
|
|
56
69
|
|
|
57
70
|
describe('buildResumeStartupCommand', () => {
|
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
captureFilePath,
|
|
64
64
|
ensureCopilotCaptureHookInstalled,
|
|
65
65
|
ensureClaudeCaptureHookInstalled,
|
|
66
|
+
ensureCodexCaptureHookInstalled,
|
|
66
67
|
} from './agent-session.js'
|
|
67
68
|
|
|
68
69
|
let pty
|
|
@@ -687,11 +688,13 @@ export function setupTerminalServer(httpServer, base = '/', branch = 'unknown',
|
|
|
687
688
|
initRegistry(root, { gracePeriod: termCfg.orphanGracePeriod })
|
|
688
689
|
initTerminalConfig(root)
|
|
689
690
|
|
|
690
|
-
// Install user-level Copilot CLI hook (~/.copilot/hooks/storyboard-capture.json)
|
|
691
|
-
//
|
|
692
|
-
// payloads into per-widget
|
|
691
|
+
// Install user-level Copilot CLI hook (~/.copilot/hooks/storyboard-capture.json),
|
|
692
|
+
// Claude Code hook (~/.claude/settings.json), and Codex CLI hook
|
|
693
|
+
// (~/.codex/hooks.json) that capture sessionStart payloads into per-widget
|
|
694
|
+
// files. All idempotent.
|
|
693
695
|
try { ensureCopilotCaptureHookInstalled() } catch { /* best-effort */ }
|
|
694
696
|
try { ensureClaudeCaptureHookInstalled() } catch { /* best-effort */ }
|
|
697
|
+
try { ensureCodexCaptureHookInstalled() } catch { /* best-effort */ }
|
|
695
698
|
|
|
696
699
|
// Best-effort: apply shell-config overrides if a tmux server already exists
|
|
697
700
|
// from a previous dev server run. If no server exists, this fails silently —
|