@dfosco/storyboard 0.6.4 → 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 +1 -1
- package/scaffold/AGENTS.md +7 -7
- package/scaffold/skills/canvas/SKILL.md +1 -1
- package/scaffold/skills/migrate-0.5.0/SKILL.md +1 -1
- package/scaffold/skills/ship/SKILL.md +2 -2
- package/scaffold/skills/worktree/SKILL.md +1 -1
- package/src/core/autosync/server.test.js +1 -1
- package/src/core/canvas/hot-pool.js +10 -11
- package/src/core/canvas/terminal-server.js +144 -57
- package/src/core/cli/dev.js +17 -3
- package/src/core/cli/setup.js +4 -6
- package/src/core/cli/terminal-welcome.js +12 -6
- package/src/core/data/viewfinder.js +47 -7
- package/src/core/rename-watcher/watcher.js +10 -3
- package/src/internals/Viewfinder.jsx +50 -7
- package/src/internals/Viewfinder.module.css +52 -1
- package/src/internals/canvas/PageSelector.jsx +15 -1
- package/src/internals/canvas/PageSelector.module.css +14 -0
- package/src/internals/canvas/widgets/FrozenTerminalOverlay.jsx +30 -21
- package/src/internals/canvas/widgets/PrototypeEmbed.jsx +31 -3
- package/src/internals/canvas/widgets/StorySetWidget.jsx +16 -4
- package/src/internals/canvas/widgets/TerminalReadWidget.jsx +13 -15
- package/src/internals/canvas/widgets/WidgetChrome.module.css +15 -0
- package/src/internals/context.jsx +2 -1
- package/src/internals/vite/data-plugin.js +102 -74
- package/src/internals/vite/data-plugin.test.js +11 -11
- package/terminal.config.json +1 -1
package/package.json
CHANGED
package/scaffold/AGENTS.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
| `
|
|
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 (`
|
|
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. `
|
|
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 `
|
|
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 (`
|
|
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
|
|
@@ -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
|
-
|
|
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 (`
|
|
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/
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
509
|
-
if (
|
|
510
|
-
|
|
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
|
|
531
|
-
|
|
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
|
-
|
|
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
|
-
|
|
587
|
-
renameSync(snapshotTxtTmpPath, snapshotTxtPath)
|
|
662
|
+
txtIndex.agents[widgetId] = snpTxt
|
|
588
663
|
} catch (err) {
|
|
589
|
-
devLog().logEvent('error', 'Failed to
|
|
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
|
-
*
|
|
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
|
|
1773
|
-
const
|
|
1857
|
+
// New: consolidated index at agents.snapshot.json
|
|
1858
|
+
const indexPath = join(publicSnapshotDir(), SNAPSHOT_INDEX_JSON)
|
|
1774
1859
|
try {
|
|
1775
|
-
if (existsSync(
|
|
1776
|
-
|
|
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
|
|
package/src/core/cli/dev.js
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/core/cli/setup.js
CHANGED
|
@@ -411,12 +411,10 @@ if (isInstalled('code')) {
|
|
|
411
411
|
'assets/canvas/images/~*',
|
|
412
412
|
'assets/canvas/snapshots/~*',
|
|
413
413
|
'assets/.storyboard-public/terminal-snapshots/~*',
|
|
414
|
-
//
|
|
415
|
-
// excluded from `npm run build
|
|
416
|
-
'src/canvas
|
|
417
|
-
'src/
|
|
418
|
-
'src/prototypes/~*/',
|
|
419
|
-
'src/prototypes/**/~*.{flow,object,record,prototype,folder}.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/',
|
|
420
418
|
]
|
|
421
419
|
if (existsSync(gitignorePath)) {
|
|
422
420
|
try {
|
|
@@ -273,14 +273,20 @@ async function launchAgent(agent, { isInitialStartup = false } = {}) {
|
|
|
273
273
|
clearInterval(pollInterval)
|
|
274
274
|
pollInterval = null
|
|
275
275
|
setTimeout(() => {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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 */ }
|
|
@@ -133,6 +133,8 @@ export function buildPrototypeIndex(knownRoutes = []) {
|
|
|
133
133
|
const raw = getPrototypeMetadata(name)
|
|
134
134
|
const meta = raw?.meta || raw || {}
|
|
135
135
|
const isExternal = Boolean(raw?.url)
|
|
136
|
+
const folder = raw?.folder || null
|
|
137
|
+
const isPrivate = raw?._isPrivate || false
|
|
136
138
|
protoMap[name] = {
|
|
137
139
|
name: meta.title || name,
|
|
138
140
|
dirName: name,
|
|
@@ -144,9 +146,10 @@ export function buildPrototypeIndex(knownRoutes = []) {
|
|
|
144
146
|
team: meta.team || null,
|
|
145
147
|
tags: meta.tags || null,
|
|
146
148
|
hideFlows: meta.hideFlows ?? raw?.hideFlows ?? true,
|
|
147
|
-
folder
|
|
149
|
+
folder,
|
|
148
150
|
isExternal,
|
|
149
151
|
externalUrl: isExternal ? raw.url : null,
|
|
152
|
+
isPrivate,
|
|
150
153
|
flows: [],
|
|
151
154
|
}
|
|
152
155
|
}
|
|
@@ -202,6 +205,7 @@ export function buildPrototypeIndex(knownRoutes = []) {
|
|
|
202
205
|
dirName: folderName,
|
|
203
206
|
description: meta.description || null,
|
|
204
207
|
icon: meta.icon || null,
|
|
208
|
+
isPrivate: !!raw?._isPrivate,
|
|
205
209
|
prototypes: [],
|
|
206
210
|
}
|
|
207
211
|
}
|
|
@@ -218,6 +222,7 @@ export function buildPrototypeIndex(knownRoutes = []) {
|
|
|
218
222
|
dirName: proto.folder,
|
|
219
223
|
description: null,
|
|
220
224
|
icon: null,
|
|
225
|
+
isPrivate: false,
|
|
221
226
|
prototypes: [proto],
|
|
222
227
|
}
|
|
223
228
|
} else {
|
|
@@ -243,25 +248,56 @@ export function buildPrototypeIndex(knownRoutes = []) {
|
|
|
243
248
|
const pageName = data.title || toTitleCase(fileSegment)
|
|
244
249
|
|
|
245
250
|
const pageEntry = {
|
|
251
|
+
id: canvasId,
|
|
246
252
|
name: pageName,
|
|
247
253
|
route: data._route || `/canvas/${canvasId}`,
|
|
254
|
+
isPrivate: !!data._isPrivate,
|
|
248
255
|
}
|
|
249
256
|
|
|
250
257
|
// If this canvas belongs to a group we've already seen, add as a page
|
|
251
258
|
if (group && seenGroups.has(group)) {
|
|
252
259
|
const idx = seenGroups.get(group)
|
|
253
|
-
|
|
260
|
+
const existing = canvasEntries[idx]
|
|
261
|
+
if (!existing.pages) {
|
|
254
262
|
// Retroactively add the first page
|
|
255
|
-
const firstId =
|
|
263
|
+
const firstId = existing.dirName
|
|
256
264
|
const firstData = getCanvasData(firstId)
|
|
257
265
|
const firstSegment = firstId.split('/').pop()
|
|
258
266
|
const firstName = firstData?.title || toTitleCase(firstSegment)
|
|
259
|
-
|
|
267
|
+
existing.pages = [{
|
|
268
|
+
id: firstId,
|
|
269
|
+
name: firstName,
|
|
270
|
+
route: existing.route,
|
|
271
|
+
isPrivate: !!firstData?._isPrivate,
|
|
272
|
+
}]
|
|
260
273
|
}
|
|
261
|
-
|
|
274
|
+
// If the existing card is private but this incoming page is public,
|
|
275
|
+
// promote the public page to be the card's public face. The private
|
|
276
|
+
// sibling stays as one of the group's pages (with its eye-off icon
|
|
277
|
+
// in the dropdown). Without this swap, the workspace card would
|
|
278
|
+
// inherit the title + privacy of whichever page happened to come
|
|
279
|
+
// first in iteration order.
|
|
280
|
+
const incomingIsPrivate = !!data._isPrivate || canvasId.split('/').includes('drafts')
|
|
281
|
+
if (existing.isPrivate && !incomingIsPrivate) {
|
|
282
|
+
existing.name = meta?.title || data.title || canvasId
|
|
283
|
+
existing.dirName = canvasId
|
|
284
|
+
existing.description = meta?.description || data.description || null
|
|
285
|
+
existing.route = data._route || `/canvas/${canvasId}`
|
|
286
|
+
existing.folder = data._folder || existing.folder
|
|
287
|
+
existing.isPrivate = false
|
|
288
|
+
existing.author = meta?.author || data.author || existing.author
|
|
289
|
+
existing.gitAuthor = data.gitAuthor || existing.gitAuthor
|
|
290
|
+
existing._canvasMeta = meta || existing._canvasMeta
|
|
291
|
+
}
|
|
292
|
+
existing.pages.push(pageEntry)
|
|
262
293
|
continue
|
|
263
294
|
}
|
|
264
295
|
|
|
296
|
+
// Canvas is "private" when its parsed data carries _isPrivate (from the
|
|
297
|
+
// data plugin) or — fallback — when its canonical ID contains a `drafts`
|
|
298
|
+
// path segment.
|
|
299
|
+
const isPrivate = data._isPrivate
|
|
300
|
+
|| canvasId.split('/').includes('drafts')
|
|
265
301
|
const entry = {
|
|
266
302
|
name: meta?.title || data.title || canvasId,
|
|
267
303
|
dirName: canvasId,
|
|
@@ -269,6 +305,7 @@ export function buildPrototypeIndex(knownRoutes = []) {
|
|
|
269
305
|
route: data._route || `/canvas/${canvasId}`,
|
|
270
306
|
folder: data._folder || null,
|
|
271
307
|
isCanvas: true,
|
|
308
|
+
isPrivate,
|
|
272
309
|
author: meta?.author || data.author || null,
|
|
273
310
|
gitAuthor: data.gitAuthor || null,
|
|
274
311
|
_canvasMeta: meta || null,
|
|
@@ -287,8 +324,10 @@ export function buildPrototypeIndex(knownRoutes = []) {
|
|
|
287
324
|
if (key) orderMap.set(key, idx)
|
|
288
325
|
})
|
|
289
326
|
entry.pages.sort((a, b) => {
|
|
290
|
-
const
|
|
291
|
-
const
|
|
327
|
+
const aKey = a.id ?? a.name
|
|
328
|
+
const bKey = b.id ?? b.name
|
|
329
|
+
const aIdx = orderMap.has(aKey) ? orderMap.get(aKey) : Infinity
|
|
330
|
+
const bIdx = orderMap.has(bKey) ? orderMap.get(bKey) : Infinity
|
|
292
331
|
return aIdx - bIdx
|
|
293
332
|
})
|
|
294
333
|
}
|
|
@@ -305,6 +344,7 @@ export function buildPrototypeIndex(knownRoutes = []) {
|
|
|
305
344
|
dirName: canvas.folder,
|
|
306
345
|
description: null,
|
|
307
346
|
icon: null,
|
|
347
|
+
isPrivate: false,
|
|
308
348
|
prototypes: [],
|
|
309
349
|
canvases: [canvas],
|
|
310
350
|
}
|