@dfosco/storyboard 0.6.0-beta.2 → 0.6.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/storyboard-ui.js +3112 -3098
- package/dist/storyboard-ui.js.map +1 -1
- package/mascot/frame-01-peek-left.txt +4 -0
- package/mascot/frame-02-eyes-open.txt +4 -0
- package/mascot/frame-03-peek-right.txt +4 -0
- package/mascot/frame-04-eyes-open.txt +4 -0
- package/mascot/frame-05-eyes-closed.txt +4 -0
- package/mascot/frame-06-eyes-open.txt +4 -0
- package/mascot.config.json +13 -0
- package/package.json +5 -2
- package/scaffold/AGENTS.md +1 -0
- package/scaffold/gitignore +12 -2
- package/scaffold/skills/design-system-catalog/SKILL.md +98 -0
- package/scaffold/skills/design-system-catalog/extract-components.mjs +441 -0
- package/scaffold/skills/design-system-catalog/generate-catalog.sh +255 -0
- package/scaffold/skills/migrate/SKILL.md +72 -50
- package/scaffold/terminal-agent.agent.md +8 -1
- package/src/core/canvas/agent-session.js +103 -17
- package/src/core/canvas/agent-session.test.js +29 -1
- package/src/core/canvas/collision.js +54 -45
- package/src/core/canvas/collision.test.js +39 -0
- package/src/core/canvas/configReader.js +110 -0
- package/src/core/canvas/hot-pool.js +5 -3
- package/src/core/canvas/server.js +32 -13
- package/src/core/canvas/terminal-server.js +156 -91
- package/src/core/cli/agent.js +86 -33
- package/src/core/cli/dev.js +303 -17
- package/src/core/cli/server.js +1 -1
- package/src/core/cli/setup.js +203 -60
- package/src/core/cli/terminal-welcome.js +5 -6
- package/src/core/cli/userState.js +63 -0
- package/src/core/stores/configSchema.js +1 -0
- package/src/core/stores/themeStore.ts +24 -0
- package/src/core/tools/handlers/devtools.test.js +1 -1
- package/src/core/vite/server-plugin.js +107 -10
- package/src/internals/CommandPalette/CommandPalette.jsx +1 -1
- package/src/internals/Viewfinder.jsx +10 -2
- package/src/internals/canvas/CanvasPage.jsx +30 -9
- package/src/internals/canvas/WebGLContextPool.jsx +6 -7
- package/src/internals/canvas/componentIsolate.jsx +7 -8
- package/src/internals/canvas/componentSetIsolate.jsx +7 -8
- package/src/internals/canvas/widgets/PrototypeEmbed.jsx +3 -1
- package/src/internals/canvas/widgets/StorySetWidget.jsx +19 -7
- package/src/internals/canvas/widgets/StoryWidget.jsx +9 -3
- package/src/internals/canvas/widgets/TerminalWidget.jsx +74 -13
- package/src/internals/canvas/widgets/expandUtils.js +4 -2
- package/src/internals/hooks/usePrototypeReloadGuard.js +9 -5
- package/src/internals/vite/data-plugin.js +126 -3
- package/terminal.config.json +66 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (!
|
|
181
|
-
for
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
692
|
-
// payloads into per-widget
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1247
|
-
//
|
|
1248
|
-
//
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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(
|
|
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
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1343
|
-
|
|
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
|
|
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 (
|
|
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))
|
|
1434
|
+
if (paneContent.includes(readinessSignal)) runPostStartup('signal')
|
|
1369
1435
|
} catch { /* empty */ }
|
|
1370
|
-
},
|
|
1371
|
-
//
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1374
|
-
|
|
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(() => {
|
package/src/core/cli/agent.js
CHANGED
|
@@ -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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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)
|