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

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.
Files changed (49) hide show
  1. package/dist/storyboard-ui.js +3112 -3098
  2. package/dist/storyboard-ui.js.map +1 -1
  3. package/mascot/frame-01-peek-left.txt +4 -0
  4. package/mascot/frame-02-eyes-open.txt +4 -0
  5. package/mascot/frame-03-peek-right.txt +4 -0
  6. package/mascot/frame-04-eyes-open.txt +4 -0
  7. package/mascot/frame-05-eyes-closed.txt +4 -0
  8. package/mascot/frame-06-eyes-open.txt +4 -0
  9. package/mascot.config.json +13 -0
  10. package/package.json +5 -2
  11. package/scaffold/AGENTS.md +1 -0
  12. package/scaffold/gitignore +12 -2
  13. package/scaffold/skills/design-system-catalog/SKILL.md +98 -0
  14. package/scaffold/skills/design-system-catalog/extract-components.mjs +441 -0
  15. package/scaffold/skills/design-system-catalog/generate-catalog.sh +255 -0
  16. package/scaffold/skills/migrate/SKILL.md +72 -50
  17. package/scaffold/terminal-agent.agent.md +8 -1
  18. package/src/core/canvas/agent-session.js +103 -17
  19. package/src/core/canvas/agent-session.test.js +29 -1
  20. package/src/core/canvas/collision.js +54 -45
  21. package/src/core/canvas/collision.test.js +39 -0
  22. package/src/core/canvas/configReader.js +110 -0
  23. package/src/core/canvas/hot-pool.js +5 -3
  24. package/src/core/canvas/server.js +32 -13
  25. package/src/core/canvas/terminal-server.js +156 -91
  26. package/src/core/cli/agent.js +86 -33
  27. package/src/core/cli/dev.js +303 -17
  28. package/src/core/cli/server.js +1 -1
  29. package/src/core/cli/setup.js +203 -60
  30. package/src/core/cli/terminal-welcome.js +5 -6
  31. package/src/core/cli/userState.js +63 -0
  32. package/src/core/stores/configSchema.js +1 -0
  33. package/src/core/stores/themeStore.ts +24 -0
  34. package/src/core/tools/handlers/devtools.test.js +1 -1
  35. package/src/core/vite/server-plugin.js +107 -10
  36. package/src/internals/CommandPalette/CommandPalette.jsx +1 -1
  37. package/src/internals/Viewfinder.jsx +10 -2
  38. package/src/internals/canvas/CanvasPage.jsx +30 -9
  39. package/src/internals/canvas/WebGLContextPool.jsx +6 -7
  40. package/src/internals/canvas/componentIsolate.jsx +7 -8
  41. package/src/internals/canvas/componentSetIsolate.jsx +7 -8
  42. package/src/internals/canvas/widgets/PrototypeEmbed.jsx +3 -1
  43. package/src/internals/canvas/widgets/StorySetWidget.jsx +19 -7
  44. package/src/internals/canvas/widgets/StoryWidget.jsx +9 -3
  45. package/src/internals/canvas/widgets/TerminalWidget.jsx +74 -13
  46. package/src/internals/canvas/widgets/expandUtils.js +4 -2
  47. package/src/internals/hooks/usePrototypeReloadGuard.js +9 -5
  48. package/src/internals/vite/data-plugin.js +126 -3
  49. package/terminal.config.json +66 -0
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # generate-catalog.sh — Generate a component catalog skill for any design system
4
+ #
5
+ # Usage:
6
+ # .agents/skills/design-system-catalog/generate-catalog.sh <package-name> [display-name]
7
+ #
8
+ # Examples:
9
+ # .agents/skills/design-system-catalog/generate-catalog.sh @primer/react "Primer React"
10
+ # .agents/skills/design-system-catalog/generate-catalog.sh @chakra-ui/react "Chakra UI"
11
+ # .agents/skills/design-system-catalog/generate-catalog.sh antd "Ant Design"
12
+
13
+ set -euo pipefail
14
+
15
+ REPO_ROOT="$(git rev-parse --show-toplevel)"
16
+
17
+ # Resolve skills base directory — priority: .agents → .github → .claude
18
+ SKILLS_BASE=""
19
+ for candidate in .agents/skills .github/skills .claude/skills; do
20
+ if [ -d "$REPO_ROOT/$candidate/design-system-catalog" ]; then
21
+ SKILLS_BASE="$candidate"
22
+ break
23
+ fi
24
+ done
25
+
26
+ if [ -z "$SKILLS_BASE" ]; then
27
+ echo "Error: design-system-catalog skill not found in .agents/skills, .github/skills, or .claude/skills" >&2
28
+ exit 1
29
+ fi
30
+
31
+ SKILL_DIR="$REPO_ROOT/$SKILLS_BASE/design-system-catalog"
32
+ EXTRACTOR="$SKILL_DIR/extract-components.mjs"
33
+
34
+ PACKAGE_NAME="${1:?Usage: generate-catalog.sh <package-name> [display-name]}"
35
+ DISPLAY_NAME="${2:-}"
36
+
37
+ # ── Derive names ─────────────────────────────────────────────
38
+
39
+ # Skill name: strip @, replace / with -, lowercase
40
+ SKILL_NAME="${PACKAGE_NAME#@}"
41
+ SKILL_NAME="${SKILL_NAME////-}"
42
+ SKILL_NAME=$(echo "$SKILL_NAME" | tr '[:upper:]' '[:lower:]')
43
+ CATALOG_SKILL_NAME="${SKILL_NAME}-components-catalog"
44
+
45
+ # Display name: auto-derive if not provided
46
+ if [ -z "$DISPLAY_NAME" ]; then
47
+ # @primer/react → Primer React, antd → Antd, @chakra-ui/react → Chakra Ui React
48
+ DISPLAY_NAME=$(echo "$SKILL_NAME" | sed 's/-/ /g' | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2))}1')
49
+ fi
50
+
51
+ OUTPUT_DIR="$REPO_ROOT/$SKILLS_BASE/$CATALOG_SKILL_NAME"
52
+ OUTPUT_FILE="$OUTPUT_DIR/SKILL.md"
53
+
54
+ echo "Package: $PACKAGE_NAME"
55
+ echo "Display name: $DISPLAY_NAME"
56
+ echo "Skill name: $CATALOG_SKILL_NAME"
57
+ echo "Output dir: $OUTPUT_DIR"
58
+ echo ""
59
+
60
+ # ── Validate ─────────────────────────────────────────────────
61
+
62
+ if [ ! -f "$EXTRACTOR" ]; then
63
+ echo "Error: extractor not found at $EXTRACTOR" >&2
64
+ exit 1
65
+ fi
66
+
67
+ PKG_DIR="$REPO_ROOT/node_modules/${PACKAGE_NAME}"
68
+ if [ ! -d "$PKG_DIR" ]; then
69
+ echo "Error: $PACKAGE_NAME not installed. Run npm install first." >&2
70
+ exit 1
71
+ fi
72
+
73
+ VERSION=$(node -e "console.log(require('$PKG_DIR/package.json').version)" 2>/dev/null || echo "unknown")
74
+
75
+ # ── Extract components ───────────────────────────────────────
76
+
77
+ TMP_DIR=$(mktemp -d)
78
+ trap "rm -rf $TMP_DIR" EXIT
79
+
80
+ echo "Extracting components from $PACKAGE_NAME@$VERSION ..."
81
+ REPO_ROOT="$REPO_ROOT" node "$EXTRACTOR" "$PACKAGE_NAME" > "$TMP_DIR/components.json" 2>"$TMP_DIR/extract.log"
82
+
83
+ cat "$TMP_DIR/extract.log" >&2
84
+
85
+ TOTAL=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$TMP_DIR/components.json','utf-8')).totalComponents)")
86
+
87
+ if [ "$TOTAL" -eq 0 ]; then
88
+ echo "Error: no components found in $PACKAGE_NAME. Check that the package has TypeScript declarations." >&2
89
+ exit 1
90
+ fi
91
+
92
+ echo "Found $TOTAL components."
93
+
94
+ # ── Format catalog markdown ──────────────────────────────────
95
+
96
+ COMPONENTS_JSON="$TMP_DIR/components.json" node --input-type=module << 'FORMATTER' > "$TMP_DIR/catalog.md"
97
+ import { readFileSync } from 'fs';
98
+
99
+ const data = JSON.parse(readFileSync(process.env.COMPONENTS_JSON, 'utf-8'));
100
+ const { packageName, version, components } = data;
101
+
102
+ // Group components by category
103
+ const groups = {};
104
+ for (const comp of components) {
105
+ const cat = comp.category || 'Components';
106
+ if (!groups[cat]) groups[cat] = [];
107
+ groups[cat].push(comp);
108
+ }
109
+
110
+ // Sort categories: known categories first, then alphabetical
111
+ const knownOrder = [
112
+ 'Layout', 'Navigation', 'Actions', 'Actions & Menus', 'Forms',
113
+ 'Data Display', 'Feedback', 'Overlays', 'Content', 'Typography',
114
+ 'Utilities', 'General', 'Components',
115
+ ];
116
+ const allCategories = Object.keys(groups);
117
+ const sorted = [
118
+ ...knownOrder.filter(c => allCategories.includes(c)),
119
+ ...allCategories.filter(c => !knownOrder.includes(c)).sort(),
120
+ ];
121
+
122
+ let md = '';
123
+ md += `## Component Index\n\n`;
124
+
125
+ let totalCount = 0;
126
+
127
+ for (const cat of sorted) {
128
+ const comps = groups[cat];
129
+ if (!comps || comps.length === 0) continue;
130
+
131
+ comps.sort((a, b) => a.name.localeCompare(b.name));
132
+
133
+ md += `### ${cat}\n\n`;
134
+
135
+ for (const comp of comps) {
136
+ totalCount++;
137
+ md += `#### \`${comp.name}\`\n\n`;
138
+ md += `\`\`\`jsx\nimport { ${comp.name} } from '${comp.importPath}'\n\`\`\`\n\n`;
139
+
140
+ if (comp.subComponents.length > 0) {
141
+ md += '**Sub-components:** ';
142
+ md += comp.subComponents.map(s => `\`${comp.name}.${s}\``).join(', ');
143
+ md += '\n\n';
144
+ }
145
+
146
+ if (comp.props.length > 0) {
147
+ md += '**Props:**\n\n';
148
+ md += '| Prop | Type |\n';
149
+ md += '|------|------|\n';
150
+ for (const prop of comp.props) {
151
+ const safeType = prop.type.replace(/\|/g, '\\|').replace(/\n/g, ' ');
152
+ md += `| \`${prop.name}\` | \`${safeType}\` |\n`;
153
+ }
154
+ md += '\n';
155
+ }
156
+ }
157
+ }
158
+
159
+ md += '---\n\n';
160
+ md += `_${totalCount} components total from ${packageName}@${version}._\n`;
161
+
162
+ process.stdout.write(md);
163
+ FORMATTER
164
+
165
+ # ── Create output skill directory ────────────────────────────
166
+
167
+ mkdir -p "$OUTPUT_DIR"
168
+
169
+ # ── Write SKILL.md ───────────────────────────────────────────
170
+
171
+ cat > "$OUTPUT_FILE" << SKILLEOF
172
+ # ${DISPLAY_NAME} Components Catalog
173
+
174
+ > Triggered by: ANY code that imports from \`${PACKAGE_NAME}\`, "what ${DISPLAY_NAME} components are available", "${DISPLAY_NAME,,} component list", "${DISPLAY_NAME,,} components", when building UI with ${DISPLAY_NAME}
175
+
176
+ > Auto-update trigger: When \`package.json\` is modified and \`${PACKAGE_NAME}\` version changes, regenerate this catalog.
177
+
178
+ ## What This Does
179
+
180
+ Provides a complete catalog of all \`${PACKAGE_NAME}\` components available in this codebase. Each entry includes the component name, import path, sub-components (for compound components), and props with types.
181
+
182
+ ## When This Applies
183
+
184
+ - Building any new page or component that uses ${DISPLAY_NAME}
185
+ - Deciding which component to use for a UI pattern
186
+ - Looking up props or sub-components for a specific component
187
+
188
+ ## How to Use
189
+
190
+ 1. **Find the right component** — Browse by category or search alphabetically
191
+ 2. **Check sub-components** — Compound components list their sub-components (e.g. \`Table.Cell\`)
192
+ 3. **Check props** — Each component lists its specific props with types
193
+ 4. **Import** — Use the import path shown (always \`${PACKAGE_NAME}\`)
194
+
195
+ ### Regenerating This Catalog
196
+
197
+ When \`${PACKAGE_NAME}\` is upgraded, regenerate with:
198
+
199
+ \`\`\`bash
200
+ ${SKILLS_BASE}/${CATALOG_SKILL_NAME}/extract-components.sh
201
+ \`\`\`
202
+
203
+ <!-- CATALOG -->
204
+ <!-- Generated from ${PACKAGE_NAME}@${VERSION} — do not edit manually -->
205
+ <!-- Run ${SKILLS_BASE}/${CATALOG_SKILL_NAME}/extract-components.sh to regenerate -->
206
+
207
+ ---
208
+
209
+ SKILLEOF
210
+
211
+ # Append the formatted catalog
212
+ cat "$TMP_DIR/catalog.md" >> "$OUTPUT_FILE"
213
+
214
+ # ── Write regeneration script ────────────────────────────────
215
+
216
+ REGEN_SCRIPT="$OUTPUT_DIR/extract-components.sh"
217
+ cat > "$REGEN_SCRIPT" << 'REGENEOF'
218
+ #!/usr/bin/env bash
219
+ #
220
+ # Regenerate the component catalog for PACKAGE_PLACEHOLDER
221
+ #
222
+ # This is a thin wrapper around the shared design-system-catalog extractor.
223
+ # Run this script when the package is upgraded to refresh the catalog.
224
+
225
+ set -euo pipefail
226
+
227
+ REPO_ROOT="$(git rev-parse --show-toplevel)"
228
+
229
+ GENERATOR=""
230
+ for candidate in .agents/skills .github/skills .claude/skills; do
231
+ if [ -f "$REPO_ROOT/$candidate/design-system-catalog/generate-catalog.sh" ]; then
232
+ GENERATOR="$REPO_ROOT/$candidate/design-system-catalog/generate-catalog.sh"
233
+ break
234
+ fi
235
+ done
236
+
237
+ if [ -z "$GENERATOR" ]; then
238
+ echo "Error: design-system-catalog skill not found in .agents/skills, .github/skills, or .claude/skills" >&2
239
+ echo "The shared extractor is required to regenerate this catalog." >&2
240
+ exit 1
241
+ fi
242
+
243
+ exec "$GENERATOR" "PACKAGE_PLACEHOLDER" "DISPLAY_PLACEHOLDER"
244
+ REGENEOF
245
+
246
+ # Replace placeholders
247
+ sed -i '' "s|PACKAGE_PLACEHOLDER|${PACKAGE_NAME}|g" "$REGEN_SCRIPT"
248
+ sed -i '' "s|DISPLAY_PLACEHOLDER|${DISPLAY_NAME}|g" "$REGEN_SCRIPT"
249
+ chmod +x "$REGEN_SCRIPT"
250
+
251
+ echo ""
252
+ echo "✅ Generated skill: $CATALOG_SKILL_NAME"
253
+ echo " Skill file: $OUTPUT_FILE"
254
+ echo " Regenerate: $REGEN_SCRIPT"
255
+ echo " Components: $TOTAL from ${PACKAGE_NAME}@${VERSION}"
@@ -37,63 +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
- "sessionIdEnv": "CLAUDE_SESSION_ID",
79
- "sessionStateGlob": "~/.claude/projects/*/{id}.jsonl",
80
- "resizable": true,
81
- "readinessSignal": "bypass permissions"
82
- },
83
- "codex": {
84
- "label": "Codex CLI",
85
- "icon": "codex",
86
- "startupCommand": "codex --full-auto",
87
- "resumeCommand": "codex --resume={id} --ask-for-approval never",
88
- "configFiles": [".codex/config.toml"],
89
- "resizable": true
90
- }
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"
91
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
+ },
92
111
 
93
- // Set to true to show agent entries in the canvas "+" add menu
94
- // Set to false to only show them in the command palette
95
- "showAgentsInAddMenu": false
96
- }
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
97
115
  }
98
116
  ```
99
117
 
@@ -118,6 +136,10 @@ Clients on 4.1.x likely have no `canvas` block at all. The full canvas config is
118
136
  | `startupCommand` | yes | Shell command to start the agent |
119
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. |
120
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.
121
143
  | `postStartup` | no | Text sent to the agent's stdin after it starts |
122
144
  | `readinessSignal` | no | Substring to wait for in output before marking agent as ready |
123
145
  | `configFiles` | no | Array of config file paths the agent requires |
@@ -116,11 +116,18 @@ When the user says "your partner", "your buddy", or "connected widget" — they
116
116
  - **sticky-note**: `props.text` — instructions, notes, or requirements
117
117
  - **markdown**: `props.content` — documentation, specs, or prose
118
118
  - **image**: `props.src` — image filename at `assets/canvas/images/{props.src}`
119
- - **story**: `props.storyId` + `props.exportName` — component to work with
119
+ - **story**: `props.storyId` + `props.exportName` — a SINGLE named export of a story file
120
+ - **component-set**: `props.storyId` — ALL exports of a story file in one grid iframe. **`props.selected` is the export name the user clicked on inside the grid** — treat it as "the variant the user is focused on right now" and scope edits/changes to that export when ambiguous.
120
121
  - **link-preview**: `props.url` — external reference
121
122
  - **prototype**: `props.src` — prototype path
122
123
  - **terminal** / **agent** / **prompt**: another terminal, agent, or prompt you can message (see Step 7)
123
124
 
125
+ > **Creating widgets:** Never invent a `type` string. The authoritative list lives in `.agents/data/widget-types.json` (or invoke the **canvas** skill). When the user asks for "variants", "a component set", "a showcase", or anything implying multiple exports of the same story file, use a SINGLE `component-set` widget — NOT N `story` widgets.
126
+
127
+ > **Scoping inside a `component-set`:** if a connected `component-set` widget has a non-empty `props.selected`, the user has explicitly chosen a variant inside that set. When they say "this variant", "the selected one", "this chart", etc., interpret it as the export named by `props.selected` and modify only that export's code path unless they say otherwise.
128
+
129
+
130
+
124
131
  Interpret the user's prompt in light of these connected widgets.
125
132
 
126
133
  ### Resolving widget references across the connection graph
@@ -11,16 +11,17 @@
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
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
@@ -150,9 +193,13 @@ function buildCaptureBashScript() {
150
193
  'payload=$(cat)',
151
194
  'id=$(printf %s "$payload" | sed -n \'s/.*"sessionId"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p\' | head -n1)',
152
195
  '[ -z "$id" ] && id=$(printf %s "$payload" | sed -n \'s/.*"session_id"[[:space:]]*:[[:space:]]*"\\([^"]*\\)".*/\\1/p\' | head -n1)',
153
- '[ -z "$id" ] && exit 0',
154
196
  'dir="$root/.storyboard/agent-sessions"',
155
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',
156
203
  'printf %s "$id" > "$dir/$wid.session-id"',
157
204
  ].join('; ')
158
205
  }
@@ -171,13 +218,38 @@ function buildCaptureBashScript() {
171
218
  * keeping it in one field avoids confusion about which args go where.
172
219
  */
173
220
  export function buildResumeStartupCommand({ startupCommand, sessionId, agentCfg }) {
174
- if (!startupCommand || !sessionId) return startupCommand
175
- if (!isResumableSessionId(sessionId, agentCfg)) return startupCommand
221
+ if (!startupCommand) return startupCommand
222
+
223
+ const notice = `printf '\\n\\033[33m[storyboard] resume failed; starting fresh session...\\033[0m\\n'`
224
+ const lastCmd = agentCfg?.resumeLastCommand
176
225
 
177
- const template = agentCfg?.resumeCommand
178
- if (!template || !template.includes('{id}')) return startupCommand
226
+ // Chain: stored-id resume → resumeLastCommand (if any) → fresh startupCommand.
227
+ // Each step's non-zero exit cascades to the next via shell `||`. Note: if a
228
+ // resume hangs (vs. exits non-zero), the chain won't progress — users
229
+ // should use the widget's restart button. We deliberately don't wrap in a
230
+ // timeout to avoid killing slow-starting agents on flaky networks.
231
+ const wrapFallback = (cmd) => {
232
+ if (agentCfg?.resumeFallback === false) return cmd
233
+ const last = lastCmd ? `${lastCmd} || { ${notice}; ${startupCommand}; }` : `${notice}; ${startupCommand}`
234
+ return `${cmd} || { ${last}; }`
235
+ }
179
236
 
180
- return template.replace('{id}', sessionId)
237
+ // Primary: per-widget captured sessionId → `resumeCommand` with {id}.
238
+ if (sessionId && UUID_RE.test(sessionId)) {
239
+ const template = agentCfg?.resumeCommand
240
+ if (template && template.includes('{id}')) {
241
+ return wrapFallback(template.replace('{id}', sessionId))
242
+ }
243
+ }
244
+
245
+ // No id stored — try resumeLastCommand (e.g. `--continue` / `resume --last`),
246
+ // falling through to fresh startupCommand if it exits non-zero.
247
+ if (lastCmd) {
248
+ if (agentCfg?.resumeFallback === false) return lastCmd
249
+ return `${lastCmd} || { ${notice}; ${startupCommand}; }`
250
+ }
251
+
252
+ return startupCommand
181
253
  }
182
254
 
183
255
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -211,14 +283,28 @@ export function isResumableSessionId(sessionId, agentCfg = {}) {
211
283
 
212
284
  /**
213
285
  * 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.
286
+ * shorthand string. Supports two forms:
287
+ * - `<root>/*` + `/{id}.jsonl` exactly one subdir level (Claude pattern)
288
+ * - `<root>/**` + `/<name-with-{id}>` — recursive find (Codex pattern, where
289
+ * sessions are nested under year/month/day and the id is embedded in
290
+ * a longer filename like `rollout-<ts>-<id>.jsonl`)
217
291
  */
218
292
  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.
293
+ const expanded = glob.replace('~', homedir()).replace(/\{id\}/g, sessionId)
294
+
295
+ // Recursive form: root/**/name
296
+ if (expanded.includes('/**/')) {
297
+ const [root, namePattern] = expanded.split('/**/')
298
+ if (!existsSync(root)) return false
299
+ try {
300
+ const out = execFileSync('find', [root, '-name', namePattern, '-print', '-quit'], {
301
+ encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'],
302
+ })
303
+ return out.trim().length > 0
304
+ } catch { return false }
305
+ }
306
+
307
+ // Single-level form: root/*/suffix
222
308
  const parts = expanded.split('/*/')
223
309
  if (parts.length !== 2) {
224
310
  // 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', () => {
@@ -69,13 +82,28 @@ describe('agent-session', () => {
69
82
  expect(out).toBe('copilot --agent terminal-agent')
70
83
  })
71
84
 
72
- 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', () => {
73
100
  const out = buildResumeStartupCommand({
74
101
  startupCommand: 'copilot --agent terminal-agent',
75
102
  sessionId: '11111111-2222-4333-8444-555555555555',
76
103
  agentCfg: {
77
104
  sessionStateDir: null,
78
105
  resumeCommand: 'copilot --resume={id} --agent terminal-agent',
106
+ resumeFallback: false,
79
107
  },
80
108
  })
81
109
  expect(out).toBe('copilot --resume=11111111-2222-4333-8444-555555555555 --agent terminal-agent')