@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.
- package/dist/storyboard-ui.js +3112 -3098
- package/dist/storyboard-ui.js.map +1 -1
- package/mascot/frame-01-peek-left.txt +4 -0
- package/mascot/frame-02-eyes-open.txt +4 -0
- package/mascot/frame-03-peek-right.txt +4 -0
- package/mascot/frame-04-eyes-open.txt +4 -0
- package/mascot/frame-05-eyes-closed.txt +4 -0
- package/mascot/frame-06-eyes-open.txt +4 -0
- package/mascot.config.json +13 -0
- package/package.json +5 -2
- package/scaffold/AGENTS.md +1 -0
- package/scaffold/gitignore +12 -2
- package/scaffold/skills/design-system-catalog/SKILL.md +98 -0
- package/scaffold/skills/design-system-catalog/extract-components.mjs +441 -0
- package/scaffold/skills/design-system-catalog/generate-catalog.sh +255 -0
- package/scaffold/skills/migrate/SKILL.md +72 -50
- package/scaffold/terminal-agent.agent.md +8 -1
- package/src/core/canvas/agent-session.js +103 -17
- package/src/core/canvas/agent-session.test.js +29 -1
- package/src/core/canvas/collision.js +54 -45
- package/src/core/canvas/collision.test.js +39 -0
- package/src/core/canvas/configReader.js +110 -0
- package/src/core/canvas/hot-pool.js +5 -3
- package/src/core/canvas/server.js +32 -13
- package/src/core/canvas/terminal-server.js +156 -91
- package/src/core/cli/agent.js +86 -33
- package/src/core/cli/dev.js +303 -17
- package/src/core/cli/server.js +1 -1
- package/src/core/cli/setup.js +203 -60
- package/src/core/cli/terminal-welcome.js +5 -6
- package/src/core/cli/userState.js +63 -0
- package/src/core/stores/configSchema.js +1 -0
- package/src/core/stores/themeStore.ts +24 -0
- package/src/core/tools/handlers/devtools.test.js +1 -1
- package/src/core/vite/server-plugin.js +107 -10
- package/src/internals/CommandPalette/CommandPalette.jsx +1 -1
- package/src/internals/Viewfinder.jsx +10 -2
- package/src/internals/canvas/CanvasPage.jsx +30 -9
- package/src/internals/canvas/WebGLContextPool.jsx +6 -7
- package/src/internals/canvas/componentIsolate.jsx +7 -8
- package/src/internals/canvas/componentSetIsolate.jsx +7 -8
- package/src/internals/canvas/widgets/PrototypeEmbed.jsx +3 -1
- package/src/internals/canvas/widgets/StorySetWidget.jsx +19 -7
- package/src/internals/canvas/widgets/StoryWidget.jsx +9 -3
- package/src/internals/canvas/widgets/TerminalWidget.jsx +74 -13
- package/src/internals/canvas/widgets/expandUtils.js +4 -2
- package/src/internals/hooks/usePrototypeReloadGuard.js +9 -5
- package/src/internals/vite/data-plugin.js +126 -3
- 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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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` —
|
|
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
|
|
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
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
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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.
|
|
215
|
-
*
|
|
216
|
-
*
|
|
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(
|
|
220
|
-
|
|
221
|
-
//
|
|
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
|
|
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')
|