@dfosco/storyboard 0.6.3 → 0.6.5

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.3",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -100,7 +100,7 @@ The default location is in `.agents/plans`, but the user may ask for a specific
100
100
 
101
101
  ```bash
102
102
  npm run setup # First-time: install deps, Caddy proxy, start proxy
103
- storyboard dev # Start dev server (or: npm run dev)
103
+ npm run dev # Start dev server (wraps `storyboard dev`)
104
104
  npm run dev:vite # Start vite directly (bypasses CLI)
105
105
  npm run build # Production build
106
106
  npm run lint # Run ESLint
@@ -108,14 +108,14 @@ npm run lint # Run ESLint
108
108
 
109
109
  ### Storyboard CLI
110
110
 
111
- The `storyboard` CLI (`sb` alias) wraps dev tooling:
111
+ The `storyboard` CLI (`sb` alias) wraps dev tooling. The standard `npm run dev` script in this repo just calls `storyboard dev` — use `npm run dev` unless you need a CLI-only feature (e.g. starting a dev server for a different worktree).
112
112
 
113
113
  | Command | Description |
114
114
  |---------|-------------|
115
- | `storyboard dev` | Start Vite with correct base path + update Caddy proxy |
115
+ | `npm run dev` _(preferred)_ | Start the Vite dev server for the current worktree |
116
+ | `storyboard dev [branch]` | Start a dev server — pass a branch to target a different worktree |
116
117
  | `storyboard code [branch]` | Open current worktree (or specific branch) in VS Code |
117
118
  | `storyboard setup` | Install deps, Caddy, `gh` check, start proxy |
118
- | `storyboard proxy` | Generate Caddyfile + start/reload Caddy |
119
119
  | `storyboard update:version [version]` | Update `@dfosco/storyboard-*` packages to latest (or specific version) |
120
120
  | `storyboard canvas read [name]` | Read canvas widgets with content, URLs, file paths, and bounds |
121
121
  | `storyboard canvas bounds [name]` | Get widget size and positional bounds (spatial queries) |
@@ -159,7 +159,7 @@ For the full canvas CLI reference (positioning, batch ops, collision detection),
159
159
 
160
160
  ### Dev URLs
161
161
 
162
- With Caddy proxy running (`storyboard setup`):
162
+ With Caddy proxy running (set up via `npm run setup`):
163
163
  - Main: `http://storyboard.localhost/storyboard/`
164
164
  - Worktree `fix-bug`: `http://storyboard.localhost/fix-bug/storyboard/`
165
165
 
@@ -169,7 +169,7 @@ Without proxy (fallback with port numbers):
169
169
 
170
170
  ### Dev URL session state
171
171
 
172
- Whenever Copilot starts a dev server (e.g. `storyboard dev`), save the URL as `devURL` in the SQL session database. Read the proxy URL from the dev server's startup output (`[storyboard] proxy URL: <url>`):
172
+ Whenever Copilot starts a dev server (e.g. `npm run dev`), save the URL as `devURL` in the SQL session database. Read the proxy URL from the dev server's startup output (`[storyboard] proxy URL: <url>`):
173
173
 
174
174
  ```sql
175
175
  INSERT OR REPLACE INTO session_state (key, value) VALUES ('devURL', 'http://storyboard.localhost/storyboard/');
@@ -180,7 +180,7 @@ If the proxy is not running, fall back to the direct URL from the output (`[stor
180
180
  This `devURL` is used as the default target by the **agent-browser** skill when the user says "inspect the browser", "check the page", etc. — no URL argument needed.
181
181
 
182
182
  **How `devURL` gets set:**
183
- - **Automatically** — when Copilot runs `storyboard dev` or `npm run dev`, persist the proxy URL (or direct URL if no proxy) to `devURL`.
183
+ - **Automatically** — when Copilot runs `npm run dev` (or directly `storyboard dev`), persist the proxy URL (or direct URL if no proxy) to `devURL`.
184
184
  - **From user input** — if the user says "the dev server is at http://localhost:3000", save that as `devURL`.
185
185
  - **Implicitly from inspection** — if no `devURL` is set and the user says "inspect http://storyboard.localhost/storyboard/", that URL becomes the `devURL` for the rest of the session.
186
186
 
@@ -23,7 +23,7 @@ Reads, manipulates, and arranges widgets on a Storyboard canvas. Supports absolu
23
23
 
24
24
  ## Prerequisites
25
25
 
26
- - The dev server **must** be running (`storyboard dev` or `npm run dev`)
26
+ - The dev server **must** be running (`npm run dev`, or `storyboard dev` from the CLI directly)
27
27
  - The target canvas must already exist
28
28
 
29
29
  ## Critical: Never Parse JSONL Directly
@@ -264,7 +264,7 @@ npx storyboard setup
264
264
  npm run build
265
265
 
266
266
  # Start dev server to verify everything works
267
- npx storyboard dev
267
+ npm run dev
268
268
  ```
269
269
 
270
270
  ---
@@ -200,7 +200,7 @@ If clips was skipped in Step 3, skip this step too.
200
200
  Run the dev server in the worktree so the user can immediately preview changes:
201
201
 
202
202
  ```bash
203
- npx storyboard dev
203
+ npm run dev
204
204
  ```
205
205
 
206
206
  This is the **only** place the dev server starts during a ship workflow — the worktree skill skips its own dev server step when called from ship.
@@ -234,4 +234,4 @@ User says: "ship a feature to add canvas grid snapping"
234
234
  8. Updates architecture docs, README, DOCS as needed, commits
235
235
  9. Pushes `1.2.0--canvas-grid-snapping` to origin
236
236
  10. Marks clips tasks as closed
237
- 11. Starts dev server (`npx storyboard dev`) in the worktree
237
+ 11. Starts dev server (`npm run dev`) in the worktree
@@ -19,7 +19,7 @@ When the user asks for a worktree named `<branch-name>`:
19
19
 
20
20
  ### Preferred: Use `npx storyboard dev <branch>`
21
21
 
22
- The `storyboard` CLI handles the full worktree lifecycle in a single command:
22
+ The `storyboard` CLI handles the full worktree lifecycle in a single command. (Plain `npm run dev` only starts a server for the current worktree — to target a different worktree from anywhere, use the CLI directly.)
23
23
 
24
24
  ```bash
25
25
  npx storyboard dev <branch-name>
@@ -21,7 +21,7 @@ describe('autosync scope helpers', () => {
21
21
  expect(matchesAutosyncScope('canvas', 'src/canvas/notes.txt')).toBe(true)
22
22
  expect(matchesAutosyncScope('canvas', 'assets/canvas/images/photo.png')).toBe(true)
23
23
  expect(matchesAutosyncScope('canvas', 'assets/canvas/snapshots/snapshot-widget--latest.webp')).toBe(true)
24
- expect(matchesAutosyncScope('canvas', 'assets/.storyboard-public/terminal-snapshots/agent-abc.snapshot.json')).toBe(true)
24
+ expect(matchesAutosyncScope('canvas', 'assets/.storyboard-public/terminal-snapshots/agents.snapshot.json')).toBe(true)
25
25
  expect(matchesAutosyncScope('canvas', 'src/prototypes/demo/default.flow.json')).toBe(false)
26
26
  })
27
27
 
@@ -551,8 +551,8 @@ export class HotPool {
551
551
  const paneContent = execSync(
552
552
  // H1: include scrollback so the readiness echo can still be
553
553
  // matched after the agent's TUI repaints over it.
554
- `tmux capture-pane -t "${tmuxName}" -p -S -200`,
555
- { encoding: 'utf8', timeout: 1000 }
554
+ `tmux capture-pane -t "${tmuxName}" -p -S -200 2>/dev/null`,
555
+ { encoding: 'utf8', timeout: 1000, stdio: ['ignore', 'pipe', 'ignore'] }
556
556
  )
557
557
  // Strip ANSI escape sequences — agent CLIs use heavy formatting
558
558
  // eslint-disable-next-line no-control-regex
@@ -580,14 +580,13 @@ export class HotPool {
580
580
  return this.#tmuxSessionExists(tmuxName)
581
581
  }
582
582
 
583
- // Send postStartup command (e.g. "/allow-all on")
583
+ // postStartup is NOT sent here intentionally. It's deferred to widget
584
+ // handoff (see terminal-server.js warm path) so it runs *after* the
585
+ // identity/role injection — mode-toggling slash commands like
586
+ // `/autopilot` must come last so canvas context lands in conversation
587
+ // history before the agent enters autopilot mode.
584
588
  if (postStartup) {
585
- try {
586
- await new Promise(r => setTimeout(r, 500))
587
- execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(postStartup)}`, { stdio: 'ignore' })
588
- execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
589
- this.#log(`⊕ AGENT ${sessionId} postStartup sent: ${postStartup}`)
590
- } catch { /* empty */ }
589
+ this.#log(`⊕ AGENT ${sessionId} postStartup "${postStartup}" deferred to widget handoff`)
591
590
  }
592
591
 
593
592
  this.#log(`⊕ AGENT ${sessionId} ready (${signalFilePath ? 'file' : 'signal'}: "${readinessSignal || 'file'}")`)
@@ -615,8 +614,8 @@ export class HotPool {
615
614
  if (this.#agentConfig?.startupCommand) {
616
615
  try {
617
616
  const cmd = execSync(
618
- `tmux display-message -t "${session.tmuxName}" -p "#{pane_current_command}"`,
619
- { encoding: 'utf8', timeout: 1000 }
617
+ `tmux display-message -t "${session.tmuxName}" -p "#{pane_current_command}" 2>/dev/null`,
618
+ { encoding: 'utf8', timeout: 1000, stdio: ['ignore', 'pipe', 'ignore'] }
620
619
  ).trim()
621
620
  // Agent exited if the pane is back to a shell
622
621
  const shells = ['zsh', 'bash', 'sh', 'fish']
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  import { execSync } from 'node:child_process'
26
- import { readFileSync, mkdirSync, writeFileSync, renameSync, existsSync, unlinkSync, rmSync } from 'node:fs'
26
+ import { readFileSync, mkdirSync, writeFileSync, renameSync, existsSync, unlinkSync, rmSync, readdirSync } from 'node:fs'
27
27
  import { resolve, join, dirname } from 'node:path'
28
28
  import { fileURLToPath } from 'node:url'
29
29
  import { tmpdir } from 'node:os'
@@ -409,6 +409,110 @@ function publicSnapshotDir() {
409
409
  return join(process.cwd(), 'assets', '.storyboard-public', 'terminal-snapshots')
410
410
  }
411
411
 
412
+ /** Public snapshot index filenames (single consolidated files for all agents). */
413
+ const SNAPSHOT_INDEX_JSON = 'agents.snapshot.json'
414
+ const SNAPSHOT_INDEX_TXT = 'agents-txt.snapshot.json'
415
+
416
+ /** Max entries kept in the snapshot indexes before GC drops the oldest by timestamp. */
417
+ const SNAPSHOT_INDEX_MAX_ENTRIES = 50
418
+
419
+ /** In-memory cache of the snapshot indexes to avoid re-reading on every capture. */
420
+ let snapshotIndexCache = null
421
+ let snapshotTxtIndexCache = null
422
+ let snapshotLegacyFilesCleaned = false
423
+
424
+ function emptyIndex() {
425
+ return { version: 1, updatedAt: null, agents: {} }
426
+ }
427
+
428
+ function readIndexFile(filePath) {
429
+ try {
430
+ if (!existsSync(filePath)) return emptyIndex()
431
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8'))
432
+ if (parsed && typeof parsed === 'object' && parsed.agents && typeof parsed.agents === 'object') {
433
+ return { version: parsed.version || 1, updatedAt: parsed.updatedAt || null, agents: parsed.agents }
434
+ }
435
+ } catch { /* fall through */ }
436
+ return emptyIndex()
437
+ }
438
+
439
+ function loadSnapshotIndexes() {
440
+ if (!snapshotIndexCache) {
441
+ snapshotIndexCache = readIndexFile(join(publicSnapshotDir(), SNAPSHOT_INDEX_JSON))
442
+ }
443
+ if (!snapshotTxtIndexCache) {
444
+ snapshotTxtIndexCache = readIndexFile(join(publicSnapshotDir(), SNAPSHOT_INDEX_TXT))
445
+ }
446
+ return { jsonIndex: snapshotIndexCache, txtIndex: snapshotTxtIndexCache }
447
+ }
448
+
449
+ /**
450
+ * Garbage-collect index entries to keep size manageable.
451
+ * Drops oldest entries by timestamp until under the cap. Operates on both
452
+ * indexes together so they stay in sync.
453
+ */
454
+ function gcSnapshotIndexes(jsonIndex, txtIndex) {
455
+ const keys = Object.keys(jsonIndex.agents)
456
+ if (keys.length <= SNAPSHOT_INDEX_MAX_ENTRIES) return
457
+ const sorted = keys
458
+ .map((k) => ({ k, t: jsonIndex.agents[k]?.timestamp || '' }))
459
+ .sort((a, b) => (a.t < b.t ? -1 : a.t > b.t ? 1 : 0))
460
+ const dropCount = keys.length - SNAPSHOT_INDEX_MAX_ENTRIES
461
+ for (let i = 0; i < dropCount; i++) {
462
+ const id = sorted[i].k
463
+ delete jsonIndex.agents[id]
464
+ delete txtIndex.agents[id]
465
+ }
466
+ }
467
+
468
+ function atomicWriteJson(filePath, data) {
469
+ const tmpPath = filePath + '.tmp'
470
+ try {
471
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8')
472
+ renameSync(tmpPath, filePath)
473
+ } catch (err) {
474
+ try { if (existsSync(tmpPath)) unlinkSync(tmpPath) } catch {} // eslint-disable-line no-empty
475
+ throw err
476
+ }
477
+ }
478
+
479
+ function writeSnapshotIndexes(jsonIndex, txtIndex) {
480
+ const sDir = publicSnapshotDir()
481
+ try { mkdirSync(sDir, { recursive: true }) } catch {} // eslint-disable-line no-empty
482
+ const now = new Date().toISOString()
483
+ jsonIndex.updatedAt = now
484
+ txtIndex.updatedAt = now
485
+ try {
486
+ atomicWriteJson(join(sDir, SNAPSHOT_INDEX_JSON), jsonIndex)
487
+ } catch (err) {
488
+ devLog().logEvent('error', 'Failed to write agents.snapshot.json', { error: err.message })
489
+ }
490
+ try {
491
+ atomicWriteJson(join(sDir, SNAPSHOT_INDEX_TXT), txtIndex)
492
+ } catch (err) {
493
+ devLog().logEvent('error', 'Failed to write agents-txt.snapshot.json', { error: err.message })
494
+ }
495
+ }
496
+
497
+ /**
498
+ * One-time cleanup: remove legacy per-widget snapshot files left in the
499
+ * public dir from before the consolidated-index format. Runs on first call.
500
+ */
501
+ function cleanupLegacySnapshotFiles() {
502
+ if (snapshotLegacyFilesCleaned) return
503
+ snapshotLegacyFilesCleaned = true
504
+ const sDir = publicSnapshotDir()
505
+ if (!existsSync(sDir)) return
506
+ try {
507
+ for (const file of readdirSync(sDir)) {
508
+ if (file === SNAPSHOT_INDEX_JSON || file === SNAPSHOT_INDEX_TXT) continue
509
+ if (file.endsWith('.snapshot.json') || file.endsWith('.snapshot.txt')) {
510
+ try { unlinkSync(join(sDir, file)) } catch {} // eslint-disable-line no-empty
511
+ }
512
+ }
513
+ } catch { /* empty */ }
514
+ }
515
+
412
516
  /**
413
517
  * Read the `private` prop for a widget from the terminal config.
414
518
  * Returns true if the widget has props.private === true.
@@ -496,39 +600,28 @@ function captureSnapshot({ tmuxName, widgetId, canvasId, prettyName, cols, rows,
496
600
  try { if (existsSync(txtTmpPath)) unlinkSync(txtTmpPath) } catch {} // eslint-disable-line no-empty
497
601
  }
498
602
 
499
- // ── Public snapshot (assets/.storyboard-public/terminal-snapshots/) ──
603
+ // ── Public snapshot index (assets/.storyboard-public/terminal-snapshots/) ──
604
+ // We maintain two consolidated index files keyed by widgetId:
605
+ // - agents.snapshot.json → structured data
606
+ // - agents-txt.snapshot.json → human-readable text payloads
607
+ cleanupLegacySnapshotFiles()
500
608
  const isPrivate = isWidgetPrivate(widgetId, canvasId)
501
- const sDir = publicSnapshotDir()
502
- const snapshotPath = join(sDir, `${widgetId}.snapshot.json`)
503
- const snapshotTxtPath = join(sDir, `${widgetId}.snapshot.txt`)
504
- const tildeSnapshotPath = join(sDir, `~${widgetId}.snapshot.json`)
505
- const tildeSnapshotTxtPath = join(sDir, `~${widgetId}.snapshot.txt`)
609
+ const { jsonIndex, txtIndex } = loadSnapshotIndexes()
506
610
 
507
611
  if (isPrivate) {
508
- // Rename existing public snapshots to tilde-prefixed (gitignored) versions
509
- if (existsSync(snapshotPath)) {
510
- try { renameSync(snapshotPath, tildeSnapshotPath) } catch {} // eslint-disable-line no-empty
511
- }
512
- if (existsSync(snapshotTxtPath)) {
513
- try { renameSync(snapshotTxtPath, tildeSnapshotTxtPath) } catch {} // eslint-disable-line no-empty
514
- }
612
+ let changed = false
613
+ if (jsonIndex.agents[widgetId]) { delete jsonIndex.agents[widgetId]; changed = true }
614
+ if (txtIndex.agents[widgetId]) { delete txtIndex.agents[widgetId]; changed = true }
615
+ if (changed) writeSnapshotIndexes(jsonIndex, txtIndex)
515
616
  return
516
617
  }
517
618
 
518
- // If un-privated, restore from tilde if the public files don't exist yet
519
- if (existsSync(tildeSnapshotPath) && !existsSync(snapshotPath)) {
520
- try { renameSync(tildeSnapshotPath, snapshotPath) } catch {} // eslint-disable-line no-empty
521
- }
522
- if (existsSync(tildeSnapshotTxtPath) && !existsSync(snapshotTxtPath)) {
523
- try { renameSync(tildeSnapshotTxtPath, snapshotTxtPath) } catch {} // eslint-disable-line no-empty
524
- }
525
-
526
619
  const snapshotScrollback = getRollingBufferContent(tmuxName, SNAPSHOT_MAX_AGE_MS)
527
620
  const strippedPane = stripAnsi(paneContent)
528
621
  const strippedScrollback = stripAnsi(snapshotScrollback)
529
622
 
530
- // ── JSON snapshot ──
531
- const snapshotData = {
623
+ // ── JSON entry ──
624
+ jsonIndex.agents[widgetId] = {
532
625
  widgetId,
533
626
  canvasId,
534
627
  prettyName: prettyName || null,
@@ -539,24 +632,7 @@ function captureSnapshot({ tmuxName, widgetId, canvasId, prettyName, cols, rows,
539
632
  scrollback: strippedScrollback,
540
633
  }
541
634
 
542
- try {
543
- mkdirSync(sDir, { recursive: true })
544
- } catch (err) {
545
- devLog().logEvent('error', 'Failed to create public snapshot dir', { dir: sDir, error: err.message })
546
- return
547
- }
548
-
549
- const snapshotTmpPath = snapshotPath + '.tmp'
550
- try {
551
- writeFileSync(snapshotTmpPath, JSON.stringify(snapshotData, null, 2), 'utf8')
552
- renameSync(snapshotTmpPath, snapshotPath)
553
- } catch (err) {
554
- devLog().logEvent('error', 'Failed to write public snapshot JSON', { widgetId, error: err.message, path: snapshotPath })
555
- try { if (existsSync(snapshotTmpPath)) unlinkSync(snapshotTmpPath) } catch {} // eslint-disable-line no-empty
556
- }
557
-
558
- // ── Human-readable text snapshot ──
559
- const snapshotTxtTmpPath = snapshotTxtPath + '.tmp'
635
+ // ── Text entry ──
560
636
  try {
561
637
  const screenText = strippedPane.replace(/\r\n/g, '\n').replace(/\n+$/, '')
562
638
  const scrollText = strippedScrollback.replace(/\r\n/g, '\n').replace(/\n+$/, '')
@@ -583,12 +659,13 @@ function captureSnapshot({ tmuxName, widgetId, canvasId, prettyName, cols, rows,
583
659
  snpTxt += scrollText + '\n'
584
660
  }
585
661
 
586
- writeFileSync(snapshotTxtTmpPath, snpTxt, 'utf8')
587
- renameSync(snapshotTxtTmpPath, snapshotTxtPath)
662
+ txtIndex.agents[widgetId] = snpTxt
588
663
  } catch (err) {
589
- devLog().logEvent('error', 'Failed to write public snapshot txt', { widgetId, error: err.message })
590
- try { if (existsSync(snapshotTxtTmpPath)) unlinkSync(snapshotTxtTmpPath) } catch {} // eslint-disable-line no-empty
664
+ devLog().logEvent('error', 'Failed to build snapshot text entry', { widgetId, error: err.message })
591
665
  }
666
+
667
+ gcSnapshotIndexes(jsonIndex, txtIndex)
668
+ writeSnapshotIndexes(jsonIndex, txtIndex)
592
669
  }
593
670
 
594
671
  /** Start periodic snapshot capture for a session */
@@ -1209,11 +1286,15 @@ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupComma
1209
1286
  const finalize = () => {
1210
1287
  if (completed) return
1211
1288
  completed = true
1289
+ // Identity/role MUST be injected before postStartup. Commands like
1290
+ // `/autopilot` flip the agent into a mode where the next message is
1291
+ // consumed as a task — so we want canvas context in conversation
1292
+ // history *first*, then the mode-toggling slash command.
1293
+ injectIdentityMessage(tmuxName, { widgetId, displayName: prettyName, canvasId, branch, serverUrl })
1294
+ injectRoleMessageForWidget(tmuxName, widgetId)
1212
1295
  if (postStartup) {
1213
1296
  tmuxSubmit(tmuxName, postStartup)
1214
1297
  }
1215
- injectIdentityMessage(tmuxName, { widgetId, displayName: prettyName, canvasId, branch, serverUrl })
1216
- injectRoleMessageForWidget(tmuxName, widgetId)
1217
1298
  // Bind to messaging bus for live delivery + backfill missed messages
1218
1299
  setTimeout(() => {
1219
1300
  migratePendingMessages(widgetId, branch, canvasId).then(() => {
@@ -1408,11 +1489,15 @@ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupComma
1408
1489
  clearInterval(pollInterval)
1409
1490
  try { rmSync(readyFilePath, { force: true }) } catch { /* empty */ }
1410
1491
  setTimeout(() => {
1492
+ // Identity/role/messaging-bind MUST happen BEFORE postStartup.
1493
+ // Slash commands like `/autopilot` flip the agent into a mode
1494
+ // where the next message is consumed as a task — so we want
1495
+ // canvas context (widgetId, configFile, role) in conversation
1496
+ // history first, then the mode-toggling slash command last.
1497
+ softBind()
1411
1498
  if (postStartup) {
1412
- tmuxSubmit(tmuxName, postStartup)
1499
+ setTimeout(() => tmuxSubmit(tmuxName, postStartup), 1500)
1413
1500
  }
1414
- // Ensure identity/bind happened (no-op if already soft-bound)
1415
- softBind()
1416
1501
  }, 500)
1417
1502
  if (reason === 'soft-bind-timeout') {
1418
1503
  devLog(`[terminal-server] readiness slow for ${widgetId} (${tmuxName}); soft-binding now, will run postStartup when .ready arrives`)
@@ -1765,15 +1850,17 @@ export function readTerminalBuffer(widgetId, { maxLength } = {}) {
1765
1850
  }
1766
1851
 
1767
1852
  /**
1768
- * Read a terminal public snapshot by widget ID.
1769
- * Checks new path first, falls back to legacy path.
1853
+ * Read a terminal public snapshot by widget ID from the consolidated index.
1854
+ * Falls back to legacy nested path if not present.
1770
1855
  */
1771
1856
  export function readTerminalSnapshot(widgetId, canvasId) {
1772
- // New path: assets/.storyboard-public/terminal-snapshots/<widgetId>.snapshot.json
1773
- const newPath = join(publicSnapshotDir(), `${widgetId}.snapshot.json`)
1857
+ // New: consolidated index at agents.snapshot.json
1858
+ const indexPath = join(publicSnapshotDir(), SNAPSHOT_INDEX_JSON)
1774
1859
  try {
1775
- if (existsSync(newPath)) {
1776
- return JSON.parse(readFileSync(newPath, 'utf8'))
1860
+ if (existsSync(indexPath)) {
1861
+ const parsed = JSON.parse(readFileSync(indexPath, 'utf8'))
1862
+ const entry = parsed?.agents?.[widgetId]
1863
+ if (entry) return entry
1777
1864
  }
1778
1865
  } catch { /* empty */ }
1779
1866
 
@@ -188,8 +188,13 @@ async function main() {
188
188
  // Port resolution priority:
189
189
  // 1. --port CLI flag (always wins, never strict)
190
190
  // 2. config.port from storyboard.config.json (strict — fail if taken)
191
+ // — but ONLY for the main worktree. Worktrees ignore the pinned
192
+ // port so they can run concurrently with main on auto-assigned
193
+ // ports (otherwise every worktree fails with "port already in use"
194
+ // whenever main is up).
191
195
  // 3. auto-assigned per-worktree port (non-strict — Vite picks next free)
192
- const configuredPort = readConfiguredPort(targetCwd)
196
+ const rawConfiguredPort = readConfiguredPort(targetCwd)
197
+ const configuredPort = worktreeName === 'main' ? rawConfiguredPort : null
193
198
  const strictPort = flags.port == null && configuredPort != null
194
199
  const port = flags.port || configuredPort || getPort(worktreeName)
195
200
  const basePath = resolveBasePath(targetCwd)
@@ -322,6 +327,12 @@ async function main() {
322
327
 
323
328
  const makeFilter = (sink) => {
324
329
  let buf = ''
330
+ // stderr must always pass through, even before "ready in" — otherwise
331
+ // pre-ready failures (e.g. "Port already in use" when --strictPort is
332
+ // set) get swallowed and the CLI appears to exit silently. We still
333
+ // route through the queue once the mascot starts animating so the
334
+ // redraw isn't shifted mid-frame.
335
+ const isErr = sink === process.stderr
325
336
  return (chunk) => {
326
337
  buf += chunk.toString()
327
338
  const lines = buf.split('\n')
@@ -338,12 +349,15 @@ async function main() {
338
349
  continue
339
350
  }
340
351
  // Pre-ready: only let the "ready in" line through, then start
341
- // the mascot animation.
352
+ // the mascot animation. Stderr is always passed through so
353
+ // startup errors aren't silently dropped.
342
354
  if (/ready in \d/.test(line)) {
343
355
  sink.write(line + '\n')
344
356
  renderOnce()
357
+ } else if (isErr) {
358
+ sink.write(line + '\n')
345
359
  }
346
- // else: swallow pre-ready chatter
360
+ // else: swallow pre-ready stdout chatter
347
361
  }
348
362
  }
349
363
  }
@@ -398,32 +398,33 @@ if (isInstalled('code')) {
398
398
  p.log.success('Canvas asset directories ready')
399
399
  }
400
400
 
401
- // 7a. Scaffold .gitignore entries for private canvas images
401
+ // 7a. Scaffold .gitignore entries for storyboard runtime + private files
402
402
  {
403
403
  const gitignorePath = '.gitignore'
404
404
  const privatePatterns = [
405
+ // Storyboard runtime state — entirely per-machine, never committed.
406
+ // Files that NEED to be public must be written to .storyboard-public/
407
+ // (or assets/.storyboard-public/) instead.
408
+ '.storyboard/',
409
+ // Private canvas images / snapshots (tilde prefix = not committed)
405
410
  'src/canvas/images/~*',
406
411
  'assets/canvas/images/~*',
407
412
  'assets/canvas/snapshots/~*',
408
413
  'assets/.storyboard-public/terminal-snapshots/~*',
409
- // Local-only canvases & prototypes (loaded by `npx storyboard dev`,
410
- // excluded from `npm run build`)
411
- 'src/canvas/**/~*.canvas.jsonl',
412
- 'src/canvas/~*/',
413
- 'src/prototypes/~*/',
414
- 'src/prototypes/**/~*.{flow,object,record,prototype,folder}.json',
415
- // Per-user local state (setup version marker, agent prefs, future onboarding state)
416
- '.storyboard/.user.json',
414
+ // Drafts canvases & prototypes anything inside a `drafts/` dir is
415
+ // loaded by `npx storyboard dev` but excluded from `npm run build`.
416
+ 'src/canvas/**/drafts/',
417
+ 'src/prototypes/**/drafts/',
417
418
  ]
418
419
  if (existsSync(gitignorePath)) {
419
420
  try {
420
421
  let content = readFileSync(gitignorePath, 'utf-8')
421
422
  const missing = privatePatterns.filter(p => !content.includes(p))
422
423
  if (missing.length > 0) {
423
- const block = '\n# Private canvas images (tilde prefix = not committed)\n' + missing.join('\n') + '\n'
424
+ const block = '\n# Storyboard: runtime state (gitignored) + private tilde-prefixed files\n' + missing.join('\n') + '\n'
424
425
  content = content.trimEnd() + '\n' + block
425
426
  writeFileSync(gitignorePath, content, 'utf-8')
426
- p.log.success('Added private image patterns to .gitignore')
427
+ p.log.success('Added storyboard patterns to .gitignore')
427
428
  }
428
429
  } catch { /* ignore */ }
429
430
  }
@@ -273,14 +273,20 @@ async function launchAgent(agent, { isInitialStartup = false } = {}) {
273
273
  clearInterval(pollInterval)
274
274
  pollInterval = null
275
275
  setTimeout(() => {
276
- if (agent.postStartup) {
277
- try {
278
- execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(agent.postStartup)}`, { stdio: 'ignore' })
279
- execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
280
- } catch { /* empty */ }
281
- }
276
+ // Identity MUST be injected BEFORE postStartup. Slash commands
277
+ // like `/autopilot` flip the agent into a mode where the next
278
+ // message is consumed as a task so canvas context needs to be
279
+ // in conversation history before the mode-toggling command.
282
280
  injectIdentityMessage(tmuxName)
283
281
  setTimeout(() => deliverPendingMessages(tmuxName), 2000)
282
+ if (agent.postStartup) {
283
+ setTimeout(() => {
284
+ try {
285
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(agent.postStartup)}`, { stdio: 'ignore' })
286
+ execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
287
+ } catch { /* empty */ }
288
+ }, 3500)
289
+ }
284
290
  }, 500)
285
291
  }
286
292
  } catch { /* empty */ }