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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/storyboard-ui.js +3112 -3098
  2. package/dist/storyboard-ui.js.map +1 -1
  3. package/mascot/frame-01-peek-left.txt +4 -0
  4. package/mascot/frame-02-eyes-open.txt +4 -0
  5. package/mascot/frame-03-peek-right.txt +4 -0
  6. package/mascot/frame-04-eyes-open.txt +4 -0
  7. package/mascot/frame-05-eyes-closed.txt +4 -0
  8. package/mascot/frame-06-eyes-open.txt +4 -0
  9. package/mascot.config.json +13 -0
  10. package/package.json +5 -2
  11. package/scaffold/AGENTS.md +1 -0
  12. package/scaffold/gitignore +12 -2
  13. package/scaffold/skills/design-system-catalog/SKILL.md +98 -0
  14. package/scaffold/skills/design-system-catalog/extract-components.mjs +441 -0
  15. package/scaffold/skills/design-system-catalog/generate-catalog.sh +255 -0
  16. package/scaffold/skills/migrate/SKILL.md +72 -50
  17. package/scaffold/terminal-agent.agent.md +8 -1
  18. package/src/core/canvas/agent-session.js +103 -17
  19. package/src/core/canvas/agent-session.test.js +29 -1
  20. package/src/core/canvas/collision.js +54 -45
  21. package/src/core/canvas/collision.test.js +39 -0
  22. package/src/core/canvas/configReader.js +110 -0
  23. package/src/core/canvas/hot-pool.js +5 -3
  24. package/src/core/canvas/server.js +32 -13
  25. package/src/core/canvas/terminal-server.js +156 -91
  26. package/src/core/cli/agent.js +86 -33
  27. package/src/core/cli/dev.js +303 -17
  28. package/src/core/cli/server.js +1 -1
  29. package/src/core/cli/setup.js +203 -60
  30. package/src/core/cli/terminal-welcome.js +5 -6
  31. package/src/core/cli/userState.js +63 -0
  32. package/src/core/stores/configSchema.js +1 -0
  33. package/src/core/stores/themeStore.ts +24 -0
  34. package/src/core/tools/handlers/devtools.test.js +1 -1
  35. package/src/core/vite/server-plugin.js +107 -10
  36. package/src/internals/CommandPalette/CommandPalette.jsx +1 -1
  37. package/src/internals/Viewfinder.jsx +10 -2
  38. package/src/internals/canvas/CanvasPage.jsx +30 -9
  39. package/src/internals/canvas/WebGLContextPool.jsx +6 -7
  40. package/src/internals/canvas/componentIsolate.jsx +7 -8
  41. package/src/internals/canvas/componentSetIsolate.jsx +7 -8
  42. package/src/internals/canvas/widgets/PrototypeEmbed.jsx +3 -1
  43. package/src/internals/canvas/widgets/StorySetWidget.jsx +19 -7
  44. package/src/internals/canvas/widgets/StoryWidget.jsx +9 -3
  45. package/src/internals/canvas/widgets/TerminalWidget.jsx +74 -13
  46. package/src/internals/canvas/widgets/expandUtils.js +4 -2
  47. package/src/internals/hooks/usePrototypeReloadGuard.js +9 -5
  48. package/src/internals/vite/data-plugin.js +126 -3
  49. package/terminal.config.json +66 -0
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  import { execSync } from 'node:child_process'
26
- import { readFileSync, mkdirSync, writeFileSync, renameSync, existsSync, unlinkSync } from 'node:fs'
26
+ import { readFileSync, mkdirSync, writeFileSync, renameSync, existsSync, unlinkSync, rmSync } 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'
@@ -63,6 +63,7 @@ import {
63
63
  captureFilePath,
64
64
  ensureCopilotCaptureHookInstalled,
65
65
  ensureClaudeCaptureHookInstalled,
66
+ ensureCodexCaptureHookInstalled,
66
67
  } from './agent-session.js'
67
68
 
68
69
  let pty
@@ -157,15 +158,11 @@ function cleanEnv() {
157
158
  return filtered
158
159
  }
159
160
 
160
- /** Read terminal config from storyboard.config.json */
161
+ import { readTerminalSettings, readAgentsConfig } from './configReader.js'
162
+
163
+ /** Read terminal-widget settings (merged from lib defaults + storyboard.config.json + terminal.config.json). */
161
164
  function readTerminalConfig() {
162
- try {
163
- const raw = readFileSync(resolve(process.cwd(), 'storyboard.config.json'), 'utf8')
164
- const config = JSON.parse(raw)
165
- return config?.canvas?.terminal ?? {}
166
- } catch {
167
- return {}
168
- }
165
+ return readTerminalSettings()
169
166
  }
170
167
 
171
168
  /**
@@ -174,22 +171,19 @@ function readTerminalConfig() {
174
171
  */
175
172
  function resolveAgentConfig(startupCommand) {
176
173
  if (!startupCommand || startupCommand === 'shell') return null
177
- try {
178
- const raw = readFileSync(resolve(process.cwd(), 'storyboard.config.json'), 'utf8')
179
- const agentsConfig = JSON.parse(raw)?.canvas?.agents
180
- if (!agentsConfig || typeof agentsConfig !== 'object') return null
181
- for (const [id, cfg] of Object.entries(agentsConfig)) {
182
- if (!cfg?.startupCommand) continue
183
- // Prefer exact/prefix match for deterministic routing; keep binary fallback for backwards compat.
184
- if (
185
- startupCommand === cfg.startupCommand
186
- || startupCommand.startsWith(`${cfg.startupCommand} `)
187
- || startupCommand.startsWith(cfg.startupCommand.split(' ')[0])
188
- ) {
189
- return { id, cfg }
190
- }
174
+ const agentsConfig = readAgentsConfig()
175
+ if (!agentsConfig || typeof agentsConfig !== 'object') return null
176
+ for (const [id, cfg] of Object.entries(agentsConfig)) {
177
+ if (!cfg?.startupCommand) continue
178
+ // Prefer exact/prefix match for deterministic routing; keep binary fallback for backwards compat.
179
+ if (
180
+ startupCommand === cfg.startupCommand
181
+ || startupCommand.startsWith(`${cfg.startupCommand} `)
182
+ || startupCommand.startsWith(cfg.startupCommand.split(' ')[0])
183
+ ) {
184
+ return { id, cfg }
191
185
  }
192
- } catch { /* empty */ }
186
+ }
193
187
  return null
194
188
  }
195
189
 
@@ -314,6 +308,46 @@ function stripAnsi(str) {
314
308
  return str.replace(/\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?(\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[>=<]|\x1b\[[?]?[0-9;]*[hlsur]/g, '')
315
309
  }
316
310
 
311
+ /**
312
+ * Send text into a tmux pane and confirm it submitted.
313
+ *
314
+ * The naive `send-keys -l <text>; send-keys Enter` pattern is racy against
315
+ * TUI agents (Copilot, Claude) that are still initializing their input
316
+ * widget when we fire — the text lands in the prompt box but the Enter
317
+ * is eaten before submit. We work around it by capturing the pane after
318
+ * each send and re-sending Enter (up to N times) until the unique tail
319
+ * of our text is no longer visible (= input was cleared = submitted).
320
+ *
321
+ * Synchronous on purpose (matches existing send-keys helpers).
322
+ */
323
+ function tmuxSubmit(tmuxName, text, { retries = 5, delayMs = 250 } = {}) {
324
+ if (!hasTmux || !text) return
325
+ // Pick a needle that's unique enough to detect in the pane but short
326
+ // enough to survive wrapping. Last 40 chars of the message works well
327
+ // and avoids matching the original injected echo from earlier sends.
328
+ const needle = text.slice(-40)
329
+ const sleep = (ms) => {
330
+ try { execSync(`sleep ${(ms / 1000).toFixed(3)}`, { stdio: 'ignore' }) } catch { /* empty */ }
331
+ }
332
+ try {
333
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(text)}`, { stdio: 'ignore' })
334
+ } catch { return }
335
+ for (let attempt = 0; attempt <= retries; attempt++) {
336
+ sleep(delayMs)
337
+ try {
338
+ execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
339
+ } catch { return }
340
+ sleep(delayMs)
341
+ let pane = ''
342
+ try {
343
+ pane = execSync(`tmux capture-pane -t "${tmuxName}" -p -J`, { encoding: 'utf8', timeout: 1000 })
344
+ } catch { return }
345
+ // If the tail of our text is no longer in the visible pane, the input
346
+ // was consumed (submitted). Done.
347
+ if (!pane.includes(needle)) return
348
+ }
349
+ }
350
+
317
351
  /**
318
352
  * Inject a [System] identity message into a running agent's stdin via tmux send-keys.
319
353
  * Called from BOTH hot and cold paths after the tmux session is bound and config is written.
@@ -327,19 +361,13 @@ function injectIdentityMessage(tmuxName, { widgetId, displayName, canvasId, bran
327
361
  if (!hasTmux) return
328
362
  const configFile = `.storyboard/terminals/${widgetId}.json`
329
363
  const msg = `[System] Your terminal identity has been set. widgetId=${widgetId} displayName=${displayName} canvasId=${canvasId} configFile=${configFile} serverUrl=${serverUrl} — this is a configuration step, no response needed.`
330
- try {
331
- execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(msg)}`, { stdio: 'ignore' })
332
- execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
333
- } catch { /* best effort */ }
364
+ tmuxSubmit(tmuxName, msg)
334
365
  }
335
366
 
336
367
  function injectRoleMessage(tmuxName, role) {
337
368
  if (!hasTmux || !role) return
338
369
  const msg = `Your role in the hub is ${role}, read .agents/roles/${role}.role.md to follow additional instructions`
339
- try {
340
- execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(msg)}`, { stdio: 'ignore' })
341
- execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
342
- } catch { /* best effort */ }
370
+ tmuxSubmit(tmuxName, msg)
343
371
  }
344
372
 
345
373
  function injectHubTokenMessage(tmuxName, hasToken) {
@@ -347,10 +375,7 @@ function injectHubTokenMessage(tmuxName, hasToken) {
347
375
  const msg = hasToken
348
376
  ? 'You hold the hub token (speaking rights). Read .agents/roles/cluster-token.md for instructions.'
349
377
  : 'You do not hold the hub token. Wait for a message token before acting in the hub.'
350
- try {
351
- execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(msg)}`, { stdio: 'ignore' })
352
- execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
353
- } catch { /* best effort */ }
378
+ tmuxSubmit(tmuxName, msg)
354
379
  }
355
380
 
356
381
  function injectRoleMessageForWidget(tmuxName, widgetId) {
@@ -687,11 +712,13 @@ export function setupTerminalServer(httpServer, base = '/', branch = 'unknown',
687
712
  initRegistry(root, { gracePeriod: termCfg.orphanGracePeriod })
688
713
  initTerminalConfig(root)
689
714
 
690
- // Install user-level Copilot CLI hook (~/.copilot/hooks/storyboard-capture.json)
691
- // and Claude Code hook (~/.claude/settings.json) that capture sessionStart
692
- // payloads into per-widget files. Idempotent.
715
+ // Install user-level Copilot CLI hook (~/.copilot/hooks/storyboard-capture.json),
716
+ // Claude Code hook (~/.claude/settings.json), and Codex CLI hook
717
+ // (~/.codex/hooks.json) that capture sessionStart payloads into per-widget
718
+ // files. All idempotent.
693
719
  try { ensureCopilotCaptureHookInstalled() } catch { /* best-effort */ }
694
720
  try { ensureClaudeCaptureHookInstalled() } catch { /* best-effort */ }
721
+ try { ensureCodexCaptureHookInstalled() } catch { /* best-effort */ }
695
722
 
696
723
  // Best-effort: apply shell-config overrides if a tmux server already exists
697
724
  // from a previous dev server run. If no server exists, this fails silently —
@@ -1104,8 +1131,7 @@ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupComma
1104
1131
 
1105
1132
  // Agent shorthand scripts (copilot, claude, codex, etc.)
1106
1133
  try {
1107
- const raw = readFileSync(resolve(process.cwd(), 'storyboard.config.json'), 'utf8')
1108
- const agentsConfig = JSON.parse(raw)?.canvas?.agents
1134
+ const agentsConfig = readAgentsConfig()
1109
1135
  if (agentsConfig && typeof agentsConfig === 'object') {
1110
1136
  for (const [id, cfg] of Object.entries(agentsConfig)) {
1111
1137
  if (!cfg.startupCommand) continue
@@ -1135,8 +1161,7 @@ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupComma
1135
1161
  `start() { if [ $# -eq 0 ]; then ${welcomeBase}; else ${welcomeBase} --startup "$*"; fi; }`,
1136
1162
  ]
1137
1163
  try {
1138
- const raw = readFileSync(resolve(process.cwd(), 'storyboard.config.json'), 'utf8')
1139
- const agentsConfig = JSON.parse(raw)?.canvas?.agents
1164
+ const agentsConfig = readAgentsConfig()
1140
1165
  if (agentsConfig && typeof agentsConfig === 'object') {
1141
1166
  for (const [id, cfg] of Object.entries(agentsConfig)) {
1142
1167
  if (!cfg.startupCommand) continue
@@ -1170,17 +1195,22 @@ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupComma
1170
1195
  // pool-keyed for the life of the warm process, so the user-level
1171
1196
  // hook always writes there, not to the widget-keyed file.)
1172
1197
  const postStartup = resolvedAgentCfg?.postStartup || null
1173
- const readinessSignal = resolvedAgentCfg?.readinessSignal || null
1198
+ // ── H2 fix: skip readiness re-poll on warm handoff.
1199
+ // The hot pool already verified readiness when it warmed this session.
1200
+ // Re-polling for a one-shot signal like Copilot's "Environment loaded:"
1201
+ // against an already-running TUI always misses (the echo has long
1202
+ // since scrolled off the visible pane, and `tmux capture-pane -p`
1203
+ // returns only the visible region). Falling through to the 30s
1204
+ // timeout fallback was delaying /allow-all, identity, role, and
1205
+ // bindWidget by ~30s for every warm Copilot widget.
1206
+ const readinessSignal = null
1174
1207
  setTimeout(() => {
1175
1208
  let completed = false
1176
1209
  const finalize = () => {
1177
1210
  if (completed) return
1178
1211
  completed = true
1179
1212
  if (postStartup) {
1180
- try {
1181
- execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(postStartup)}`, { stdio: 'ignore' })
1182
- execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
1183
- } catch { /* empty */ }
1213
+ tmuxSubmit(tmuxName, postStartup)
1184
1214
  }
1185
1215
  injectIdentityMessage(tmuxName, { widgetId, displayName: prettyName, canvasId, branch, serverUrl })
1186
1216
  injectRoleMessageForWidget(tmuxName, widgetId)
@@ -1243,17 +1273,31 @@ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupComma
1243
1273
  const binDir = join(cwd, '.storyboard', 'terminals', 'bin')
1244
1274
  envParts.push(`export PATH="${binDir}:$PATH"`)
1245
1275
 
1246
- // Chain clear before exports so the typed env soup is wiped from
1247
- // view as soon as Enter executes, then chain clear again after so
1248
- // the agent starts on a clean screen.
1249
- let envExports = envParts.join(' && ')
1250
- if (startupCommand) {
1251
- envExports = `clear && ${envExports} && clear`
1252
- }
1276
+ // Write env exports to a per-widget shell script and source it via a
1277
+ // short send-keys. Avoids tmux send-keys -l truncation when the env
1278
+ // soup (especially PATH expansion) gets large — observed: a 4 KB+
1279
+ // chain stops mid-line on macOS, the agent command never runs.
1280
+ const envScriptDir = join(cwd, '.storyboard', 'terminals')
1281
+ try { mkdirSync(envScriptDir, { recursive: true }) } catch { /* empty */ }
1282
+ const envScriptPath = join(envScriptDir, `${widgetId}.env.sh`)
1283
+ // The Copilot/Claude/Codex sessionStart hooks touch this file once
1284
+ // the agent is fully loaded with an interactive prompt — much more
1285
+ // reliable than scanning the pane for a transient shell echo.
1286
+ // See agent-session.js:buildCaptureBashScript.
1287
+ const readyFilePath = join(envScriptDir, `${widgetId}.ready`)
1288
+ try { rmSync(readyFilePath, { force: true }) } catch { /* empty */ }
1289
+ try {
1290
+ writeFileSync(envScriptPath, envParts.join('\n') + '\n')
1291
+ } catch { /* empty */ }
1292
+ // Source env script; the trailing readiness echo MUST remain on
1293
+ // the pane so the post-startup poller can match it. Don't append
1294
+ // a `clear` here — the welcomeCmd that runs next clears the pane
1295
+ // itself before launching the agent.
1296
+ const envSourceCmd = `source ${JSON.stringify(envScriptPath)}`
1253
1297
 
1254
1298
  setTimeout(() => {
1255
1299
  try {
1256
- execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(envExports)}`, { stdio: 'ignore' })
1300
+ execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(envSourceCmd)}`, { stdio: 'ignore' })
1257
1301
  execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
1258
1302
  } catch { /* empty */ }
1259
1303
  }, 300)
@@ -1279,6 +1323,12 @@ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupComma
1279
1323
  let cmd = agentCfg?.startupCommand || startupCommand
1280
1324
  const postStartup = agentCfg?.postStartup || null
1281
1325
  const readinessSignal = agentCfg?.readinessSignal || null
1326
+ // Agents with a sessionStart hook (Copilot/Claude/Codex) touch
1327
+ // the .ready marker once the agent is fully loaded with an
1328
+ // interactive prompt. Wait for the marker even when no
1329
+ // readinessSignal is configured — much more accurate than
1330
+ // pane scanning.
1331
+ const useReadyFile = !!agentCfg?.sessionIdEnv
1282
1332
 
1283
1333
  // ── Resume: if we previously captured a session id for this
1284
1334
  // widget and it's still resumable (UUID + session-state dir
@@ -1325,54 +1375,69 @@ function handleConnection(ws, widgetId, canvasId, prettyName, widgetStartupComma
1325
1375
  execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
1326
1376
  } catch { /* empty */ }
1327
1377
 
1328
- if (readinessSignal) {
1329
- // Poll for readiness, then send postStartup command and deliver messages
1330
- let sent = false
1331
- const finalize = (reason) => {
1332
- if (sent) return
1333
- sent = true
1378
+ if (readinessSignal || useReadyFile) {
1379
+ // Poll for readiness, then send postStartup command and deliver messages.
1380
+ // The .ready marker is the accurate signal (Copilot/Claude/Codex
1381
+ // sessionStart hook touches it once the TUI is interactive). On
1382
+ // consumer repos with cold caches + resume mode, that can easily
1383
+ // take 30–60s — well past any reasonable hard timeout. So we:
1384
+ // 1. Keep polling indefinitely at 3s intervals for .ready.
1385
+ // 2. At 30s, do a "soft commit": inject identity and bind to
1386
+ // the messaging bus so the widget is at least addressable
1387
+ // for hub broadcast and live messages.
1388
+ // 3. When .ready finally appears, run the real postStartup.
1389
+ let softBound = false
1390
+ let postStartupSent = false
1391
+ const softBind = () => {
1392
+ if (softBound) return
1393
+ softBound = true
1394
+ injectIdentityMessage(tmuxName, { widgetId, displayName: prettyName, canvasId, branch, serverUrl })
1395
+ injectRoleMessageForWidget(tmuxName, widgetId)
1396
+ setTimeout(() => {
1397
+ migratePendingMessages(widgetId, branch, canvasId).then(() => {
1398
+ bindWidget({ widgetId, tmuxName, branch, canvasId, displayName: prettyName }).catch(() => {})
1399
+ }).catch(() => {
1400
+ bindWidget({ widgetId, tmuxName, branch, canvasId, displayName: prettyName }).catch(() => {})
1401
+ })
1402
+ joinPresence({ widgetId, senderName: prettyName || widgetId, branch, canvasId }).catch(() => {})
1403
+ }, 2000)
1404
+ }
1405
+ const runPostStartup = (reason) => {
1406
+ if (postStartupSent) return
1407
+ postStartupSent = true
1334
1408
  clearInterval(pollInterval)
1409
+ try { rmSync(readyFilePath, { force: true }) } catch { /* empty */ }
1335
1410
  setTimeout(() => {
1336
1411
  if (postStartup) {
1337
- try {
1338
- execSync(`tmux send-keys -t "${tmuxName}" -l ${JSON.stringify(postStartup)}`, { stdio: 'ignore' })
1339
- execSync(`tmux send-keys -t "${tmuxName}" Enter`, { stdio: 'ignore' })
1340
- } catch { /* empty */ }
1412
+ tmuxSubmit(tmuxName, postStartup)
1341
1413
  }
1342
- // Inject identity, then bind to messaging bus. This restores
1343
- // hub/role/broadcast context after a tmux restart — the
1344
- // widget's terminal-config preserves hubs/role/connectedWidgets,
1345
- // and bindWidget reattaches live message delivery + backfill.
1346
- injectIdentityMessage(tmuxName, { widgetId, displayName: prettyName, canvasId, branch, serverUrl })
1347
- injectRoleMessageForWidget(tmuxName, widgetId)
1348
- setTimeout(() => {
1349
- migratePendingMessages(widgetId, branch, canvasId).then(() => {
1350
- bindWidget({ widgetId, tmuxName, branch, canvasId, displayName: prettyName }).catch(() => {})
1351
- }).catch(() => {
1352
- bindWidget({ widgetId, tmuxName, branch, canvasId, displayName: prettyName }).catch(() => {})
1353
- })
1354
- joinPresence({ widgetId, senderName: prettyName || widgetId, branch, canvasId }).catch(() => {})
1355
- }, 2000)
1414
+ // Ensure identity/bind happened (no-op if already soft-bound)
1415
+ softBind()
1356
1416
  }, 500)
1357
- if (reason === 'timeout') {
1358
- devLog(`[terminal-server] readiness timeout for ${widgetId} (${tmuxName}); proceeding with bind anyway (likely resume mode)`)
1417
+ if (reason === 'soft-bind-timeout') {
1418
+ devLog(`[terminal-server] readiness slow for ${widgetId} (${tmuxName}); soft-binding now, will run postStartup when .ready arrives`)
1359
1419
  }
1360
1420
  }
1361
1421
  const pollInterval = setInterval(() => {
1362
- if (sent) { clearInterval(pollInterval); return }
1422
+ if (postStartupSent) { clearInterval(pollInterval); return }
1423
+ // Primary: sessionStart hook touches the marker once the
1424
+ // agent is fully loaded and interactive.
1425
+ if (useReadyFile && existsSync(readyFilePath)) { runPostStartup('file'); return }
1426
+ if (!readinessSignal) return
1363
1427
  try {
1428
+ // Fallback for agents without a hook: scan pane + 200
1429
+ // lines of scrollback (so the signal survives a TUI repaint).
1364
1430
  const paneContent = execSync(
1365
- `tmux capture-pane -t "${tmuxName}" -p`,
1431
+ `tmux capture-pane -t "${tmuxName}" -p -S -200`,
1366
1432
  { encoding: 'utf8', timeout: 1000 }
1367
1433
  )
1368
- if (paneContent.includes(readinessSignal)) finalize('signal')
1434
+ if (paneContent.includes(readinessSignal)) runPostStartup('signal')
1369
1435
  } catch { /* empty */ }
1370
- }, 2000)
1371
- // Fallback: if readiness signal never matches (e.g. resume mode
1372
- // doesn't print "Environment loaded:", or the agent shows a
1373
- // prompt we can't detect), bind anyway after 30s so the widget
1374
- // is at least addressable for live messages and hub broadcast.
1375
- setTimeout(() => finalize('timeout'), 30000)
1436
+ }, 500)
1437
+ // Soft-bind at 30s so the widget is addressable for hub/live
1438
+ // messages even if Copilot's TUI is still warming up. The poller
1439
+ // keeps running and will fire postStartup once .ready appears.
1440
+ setTimeout(softBind, 30000)
1376
1441
  } else {
1377
1442
  // No readiness signal — inject identity and bind to bus after a delay
1378
1443
  setTimeout(() => {
@@ -14,6 +14,71 @@
14
14
 
15
15
  import { parseFlags } from './flags.js'
16
16
  import { dim, bold, cyan, yellow } from './intro.js'
17
+ import { getServerUrl } from './serverUrl.js'
18
+
19
+ /**
20
+ * Resolve a list of candidate server URLs to try, in priority order:
21
+ * 1. Live server registry / worktree port (getServerUrl)
22
+ * 2. STORYBOARD_SERVER_URL env (set when terminal was spawned — may be stale)
23
+ * 3. Terminal config file (`.storyboard/terminals/<widgetId>.json`)
24
+ *
25
+ * Registry-first because the env var can be stale if the dev server was
26
+ * restarted on a new port since the agent was spawned.
27
+ */
28
+ async function resolveServerUrlCandidates(widgetId) {
29
+ const urls = []
30
+ const add = (u) => {
31
+ if (!u) return
32
+ const norm = String(u).replace(/\/$/, '')
33
+ if (!urls.includes(norm)) urls.push(norm)
34
+ }
35
+ try { add(getServerUrl()) } catch { /* ignore */ }
36
+ add(process.env.STORYBOARD_SERVER_URL)
37
+ if (widgetId) {
38
+ try {
39
+ const { readFileSync, existsSync } = await import('node:fs')
40
+ const path = await import('node:path')
41
+ const cfgPath = path.join(process.cwd(), '.storyboard', 'terminals', `${widgetId}.json`)
42
+ if (existsSync(cfgPath)) {
43
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'))
44
+ add(cfg.serverUrl)
45
+ }
46
+ } catch { /* ignore */ }
47
+ }
48
+ return urls
49
+ }
50
+
51
+ /**
52
+ * POST to the first reachable candidate. Returns { url, res } on a server
53
+ * response (caller checks res.ok). Returns null if every candidate failed
54
+ * at the network layer.
55
+ */
56
+ async function postWithFallback(candidates, pathSuffix, body, headers) {
57
+ let lastErr = null
58
+ for (const base of candidates) {
59
+ const url = `${base}${pathSuffix}`
60
+ for (let attempt = 0; attempt < 2; attempt++) {
61
+ try {
62
+ const res = await fetch(url, {
63
+ method: 'POST',
64
+ headers,
65
+ body,
66
+ signal: AbortSignal.timeout(5000),
67
+ })
68
+ if (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 404)) {
69
+ return { url, res }
70
+ }
71
+ if (attempt === 1) break
72
+ await new Promise((r) => setTimeout(r, 200))
73
+ } catch (err) {
74
+ lastErr = err
75
+ break
76
+ }
77
+ }
78
+ }
79
+ if (lastErr) throw lastErr
80
+ return null
81
+ }
17
82
 
18
83
  const subcommand = process.argv[3]
19
84
 
@@ -31,7 +96,6 @@ if (subcommand === 'signal') {
31
96
  const canvasId = flags.canvas || process.env.STORYBOARD_CANVAS_ID
32
97
  const status = flags.status
33
98
  const message = flags.message || null
34
- const serverUrl = process.env.STORYBOARD_SERVER_URL || 'http://localhost:1234'
35
99
  const branch = process.env.STORYBOARD_BRANCH || 'unknown'
36
100
 
37
101
  if (!widgetId || !canvasId || !status) {
@@ -46,37 +110,26 @@ if (subcommand === 'signal') {
46
110
  process.exit(1)
47
111
  }
48
112
 
49
- try {
50
- const url = `${serverUrl}/_storyboard/canvas/agent/signal`
51
- const body = JSON.stringify({ widgetId, canvasId, branch, status, message })
52
- const headers = { 'Content-Type': 'application/json' }
113
+ const candidates = await resolveServerUrlCandidates(widgetId)
114
+ const body = JSON.stringify({ widgetId, canvasId, branch, status, message })
115
+ const headers = { 'Content-Type': 'application/json' }
53
116
 
54
- // Retry transient failures (404/5xx) once with a short backoff — the dev
55
- // proxy can briefly route to a stale port during reloads.
56
- let res = null
57
- let lastErr = null
58
- for (let attempt = 0; attempt < 3; attempt++) {
59
- try {
60
- res = await fetch(url, { method: 'POST', headers, body })
61
- if (res.ok) break
62
- if (res.status >= 400 && res.status < 500 && res.status !== 404) break
63
- } catch (err) {
64
- lastErr = err
65
- }
66
- if (attempt < 2) await new Promise((r) => setTimeout(r, 250 * (attempt + 1)))
67
- }
68
-
69
- if (res && res.ok) {
70
- console.log(`${cyan('✓')} Agent status: ${bold(status)}${message ? ` — ${message}` : ''}`)
71
- } else if (res) {
72
- const data = await res.json().catch(() => ({}))
73
- console.error(`${yellow('⚠')} Server returned ${res.status}: ${data.error || 'unknown error'}`)
117
+ try {
118
+ const result = candidates.length > 0
119
+ ? await postWithFallback(candidates, '/_storyboard/canvas/agent/signal', body, headers)
120
+ : null
121
+ if (result && result.res.ok) {
122
+ console.log(`${cyan('✓')} Agent status: ${bold(status)}${message ? ` — ${message}` : ''} ${dim(`(${result.url})`)}`)
123
+ } else if (result) {
124
+ const data = await result.res.json().catch(() => ({}))
125
+ console.error(`${yellow('⚠')} Server returned ${result.res.status}: ${data.error || 'unknown error'} ${dim(`(${result.url})`)}`)
74
126
  await fallbackWrite({ branch, canvasId, widgetId, status, message })
75
127
  } else {
76
- throw lastErr || new Error('signal request failed')
128
+ console.error(`${yellow('⚠')} No reachable server. Tried: ${candidates.join(', ') || '(none resolved)'}`)
129
+ await fallbackWrite({ branch, canvasId, widgetId, status, message })
77
130
  }
78
- } catch {
79
- // Server not reachable write directly to terminal config file
131
+ } catch (err) {
132
+ console.error(`${yellow('⚠')} Signal failed (${err.message}). Tried: ${candidates.join(', ')}`)
80
133
  await fallbackWrite({ branch, canvasId, widgetId, status, message })
81
134
  }
82
135
  } else if (subcommand === 'spawn') {
@@ -96,7 +149,7 @@ if (subcommand === 'signal') {
96
149
  const agentId = spawnFlags['agent-id'] || undefined
97
150
  const autopilot = spawnFlags.autopilot !== false
98
151
  const branchOverride = spawnFlags.branch || undefined
99
- const serverUrl = process.env.STORYBOARD_SERVER_URL || 'http://localhost:1234'
152
+ const serverUrl = getServerUrl()
100
153
 
101
154
  if (!canvasId || !widgetId || !prompt) {
102
155
  console.error(`${bold('Usage:')} npx storyboard agent spawn --prompt "task description"`)
@@ -133,7 +186,7 @@ if (subcommand === 'signal') {
133
186
  const widgetId = statusFlags.widget || process.env.STORYBOARD_WIDGET_ID
134
187
  const canvasId = statusFlags.canvas || process.env.STORYBOARD_CANVAS_ID || 'unknown'
135
188
  const branch = statusFlags.branch || process.env.STORYBOARD_BRANCH || 'unknown'
136
- const serverUrl = process.env.STORYBOARD_SERVER_URL || 'http://localhost:1234'
189
+ const serverUrl = getServerUrl()
137
190
 
138
191
  if (!widgetId) {
139
192
  console.error(`${bold('Usage:')} npx storyboard agent status --widget <id>`)
@@ -165,7 +218,7 @@ if (subcommand === 'signal') {
165
218
 
166
219
  const widgetId = peekFlags.widget || process.env.STORYBOARD_WIDGET_ID
167
220
  const canvasId = peekFlags.canvas || process.env.STORYBOARD_CANVAS_ID
168
- const serverUrl = process.env.STORYBOARD_SERVER_URL || 'http://localhost:1234'
221
+ const serverUrl = getServerUrl()
169
222
 
170
223
  if (!widgetId) {
171
224
  console.error(`${bold('Usage:')} npx storyboard agent peek --widget <id>`)
@@ -200,7 +253,7 @@ if (subcommand === 'signal') {
200
253
 
201
254
  const canvasId = listFlags.canvas || process.env.STORYBOARD_CANVAS_ID
202
255
  const branch = listFlags.branch || process.env.STORYBOARD_BRANCH || null
203
- const serverUrl = process.env.STORYBOARD_SERVER_URL || 'http://localhost:1234'
256
+ const serverUrl = getServerUrl()
204
257
 
205
258
  if (!canvasId) {
206
259
  console.error(`${bold('Usage:')} npx storyboard agent list --canvas <id> [--branch <name>] [--json]`)
@@ -251,7 +304,7 @@ async function fallbackWrite({ branch, canvasId, widgetId, status, message }) {
251
304
  const { updateAgentStatus, initTerminalConfig } = await import('../canvas/terminal-config.js')
252
305
  initTerminalConfig(process.cwd())
253
306
  updateAgentStatus({ branch, canvasId, widgetId, status, message })
254
- console.log(`${cyan('')} Agent status written to config file (server offline): ${bold(status)}`)
307
+ console.error(`${yellow('')} Agent status persisted to config file only — UI will NOT update until server reconnects: ${bold(status)}`)
255
308
  } catch (err) {
256
309
  console.error(`Failed to write agent status: ${err.message}`)
257
310
  process.exit(1)