@dfosco/storyboard-core 4.2.0-beta.21 → 4.2.0-beta.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-core",
3
- "version": "4.2.0-beta.21",
3
+ "version": "4.2.0-beta.23",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
package/src/cli/setup.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import * as p from '@clack/prompts'
8
- import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'fs'
8
+ import { existsSync, writeFileSync, readFileSync, mkdirSync, readdirSync, symlinkSync } from 'fs'
9
9
  import path from 'path'
10
10
  import { execSync } from 'child_process'
11
11
  import { generateCaddyfile, isCaddyInstalled, isCaddyRunning, startCaddy, reloadCaddy } from './proxy.js'
@@ -256,6 +256,34 @@ if (isInstalled('code')) {
256
256
  }
257
257
  } catch { /* ignore */ }
258
258
  }
259
+
260
+ // Create symlinks in each CLI's expected agent directory.
261
+ // Source of truth: .agents/*.agent.md
262
+ // Copilot CLI reads .github/agents/*.md
263
+ // Claude Code reads .claude/agents/*.md
264
+ // Codex CLI uses .codex/config.toml (no agent files needed)
265
+ try {
266
+ const agentFiles = readdirSync(agentsDir).filter(f => f.endsWith('.agent.md'))
267
+ const targets = [
268
+ { dir: path.join('.github', 'agents'), label: 'Copilot CLI' },
269
+ { dir: path.join('.claude', 'agents'), label: 'Claude Code' },
270
+ ]
271
+ for (const { dir, label } of targets) {
272
+ try { mkdirSync(dir, { recursive: true }) } catch { /* ignore */ }
273
+ let linked = 0
274
+ for (const file of agentFiles) {
275
+ const linkName = file.replace('.agent.md', '.md')
276
+ const linkPath = path.join(dir, linkName)
277
+ const target = path.relative(dir, path.join(agentsDir, file))
278
+ if (!existsSync(linkPath)) {
279
+ try { symlinkSync(target, linkPath); linked++ } catch { /* ignore */ }
280
+ }
281
+ }
282
+ if (linked > 0) {
283
+ p.log.success(`${label} agent symlinks created (${dir}/)`)
284
+ }
285
+ }
286
+ } catch { /* ignore */ }
259
287
  }
260
288
 
261
289
  // 8. Proxy
@@ -24,6 +24,27 @@ import { dim, cyan, bold } from './intro.js'
24
24
  import { takePendingMessages } from '../canvas/terminal-config.js'
25
25
 
26
26
  const blue = (s) => `\x1b[34m${s}\x1b[0m`
27
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`
28
+
29
+ /**
30
+ * Drain any pending bytes from stdin to prevent stale mouse escape sequences
31
+ * (or other buffered input) from being consumed by Clack prompts.
32
+ * This is critical after tmux mouse mode was on — mouse events from the
33
+ * browser widget are sent as escape sequences to stdin.
34
+ */
35
+ function drainStdin() {
36
+ if (!process.stdin.readable) return
37
+ const wasPaused = process.stdin.isPaused?.()
38
+ try {
39
+ process.stdin.setRawMode?.(true)
40
+ process.stdin.resume()
41
+ // Read and discard all buffered data
42
+ while (process.stdin.read() !== null) { /* discard */ }
43
+ } catch { /* best effort */ }
44
+ if (wasPaused) {
45
+ try { process.stdin.pause() } catch {}
46
+ }
47
+ }
27
48
 
28
49
  // Prepend .storyboard/terminals/bin/ to PATH so `start`, `copilot`, etc.
29
50
  // are available in child shells. Done once at startup; child shells inherit it.
@@ -32,6 +53,19 @@ if (existsSync(binDir) && !process.env.PATH?.includes(binDir)) {
32
53
  process.env.PATH = `${binDir}:${process.env.PATH || ''}`
33
54
  }
34
55
 
56
+ /**
57
+ * Return a copy of process.env with the .storyboard/terminals/bin/ dir
58
+ * stripped from PATH. Used when spawning agent commands so the real binary
59
+ * is found instead of the wrapper scripts that route back through
60
+ * terminal-welcome (which would cause infinite recursion).
61
+ */
62
+ function agentEnv() {
63
+ const cleanPath = (process.env.PATH || '').split(':')
64
+ .filter(p => !p.endsWith('.storyboard/terminals/bin'))
65
+ .join(':')
66
+ return { ...process.env, PATH: cleanPath }
67
+ }
68
+
35
69
  /**
36
70
  * Read agents config from storyboard.config.json.
37
71
  * Returns an array of { id, label, startupCommand, resumeCommand } entries.
@@ -189,14 +223,21 @@ async function launchAgent(agent, { isInitialStartup = false } = {}) {
189
223
  p.outro(dim(`Starting ${agent.label}...`))
190
224
  setMouse(true)
191
225
 
226
+ let exitCode = null
227
+ const startTime = Date.now()
228
+
192
229
  try {
193
230
  const shell = process.env.SHELL || '/bin/zsh'
194
- const child = spawn(shell, ['-lc', agent.startupCommand], { stdio: 'inherit' })
231
+ const child = spawn(shell, ['-lc', agent.startupCommand], {
232
+ stdio: 'inherit',
233
+ env: agentEnv(),
234
+ })
195
235
 
196
236
  // Context injection — inject identity, postStartup, and pending messages
197
237
  // after the agent reaches readiness. Skip on initial --startup since
198
238
  // terminal-server.js handles that path independently.
199
239
  let pollInterval = null
240
+ let readinessTimeout = null
200
241
  const tmuxName = !isInitialStartup ? getTmuxName() : null
201
242
  const widgetId = process.env.STORYBOARD_WIDGET_ID
202
243
 
@@ -234,7 +275,7 @@ async function launchAgent(agent, { isInitialStartup = false } = {}) {
234
275
  } catch {}
235
276
  }, 2000)
236
277
  // Timeout after 30s — don't wait forever
237
- setTimeout(() => {
278
+ readinessTimeout = setTimeout(() => {
238
279
  if (!contextSent) {
239
280
  contextSent = true
240
281
  if (pollInterval) { clearInterval(pollInterval); pollInterval = null }
@@ -251,30 +292,45 @@ async function launchAgent(agent, { isInitialStartup = false } = {}) {
251
292
  }
252
293
  }
253
294
 
254
- await new Promise((resolve) => {
255
- child.on('close', () => {
295
+ exitCode = await new Promise((resolve) => {
296
+ child.on('close', (code) => {
256
297
  if (pollInterval) {
257
298
  if (typeof pollInterval.clear === 'function') pollInterval.clear()
258
299
  else clearInterval(pollInterval)
259
300
  }
260
- resolve()
301
+ if (readinessTimeout) clearTimeout(readinessTimeout)
302
+ resolve(code)
261
303
  })
262
304
  child.on('error', () => {
263
305
  if (pollInterval) {
264
306
  if (typeof pollInterval.clear === 'function') pollInterval.clear()
265
307
  else clearInterval(pollInterval)
266
308
  }
267
- resolve()
309
+ if (readinessTimeout) clearTimeout(readinessTimeout)
310
+ resolve(1)
268
311
  })
269
312
  })
270
313
  } catch {
271
314
  p.log.error(`Failed to start ${agent.label}. Is it installed?`)
272
315
  await new Promise(r => setTimeout(r, 2000))
316
+ exitCode = 1
317
+ } finally {
318
+ // Always disable mouse and drain stdin before returning to the welcome
319
+ // loop. Without this, tmux mouse escape sequences from the browser widget
320
+ // accumulate in stdin while the agent runs, and Clack's p.select() reads
321
+ // them as keystrokes — auto-selecting menu options in a tight loop.
322
+ setMouse(false)
323
+ await new Promise(r => setTimeout(r, 50))
324
+ drainStdin()
273
325
  }
326
+
327
+ const durationMs = Date.now() - startTime
328
+ return { exitCode, durationMs }
274
329
  }
275
330
 
276
331
  async function welcomeLoop() {
277
332
  let firstIteration = true
333
+ const MAX_STARTUP_RETRIES = 2
278
334
 
279
335
  while (true) {
280
336
  // On first iteration with --startup, auto-launch the command
@@ -294,14 +350,35 @@ async function welcomeLoop() {
294
350
  startupCmd.startsWith(a.startupCommand?.split(' ')[0])
295
351
  )
296
352
  const agent = matchedAgent || { label: startupCmd.split(/\s+/)[0], startupCommand: startupCmd }
297
- await launchAgent(agent, { isInitialStartup: true })
298
- resetTerminal()
299
- continue
353
+
354
+ let succeeded = false
355
+ for (let attempt = 0; attempt < MAX_STARTUP_RETRIES; attempt++) {
356
+ const result = await launchAgent(agent, { isInitialStartup: true })
357
+ resetTerminal()
358
+
359
+ // Normal exit (user quit the agent) — proceed to welcome menu
360
+ if (result.exitCode === 0 || result.exitCode === null) { succeeded = true; break }
361
+
362
+ // Non-zero exit — agent crashed or failed to start
363
+ const isLastAttempt = attempt === MAX_STARTUP_RETRIES - 1
364
+ if (isLastAttempt) {
365
+ p.log.warn(yellow(`${agent.label} failed to start (exit code ${result.exitCode}).`))
366
+ p.log.info(dim('Falling back to the welcome menu. You can retry from there.'))
367
+ await new Promise(r => setTimeout(r, 2000))
368
+ } else {
369
+ p.log.warn(yellow(`${agent.label} exited unexpectedly (exit code ${result.exitCode}). Retrying...`))
370
+ await new Promise(r => setTimeout(r, 3000))
371
+ }
372
+ }
373
+
374
+ if (succeeded) continue
375
+ // Fall through to the interactive welcome menu
300
376
  }
301
377
  firstIteration = false
302
378
 
303
379
  resetTerminal()
304
380
  setMouse(false)
381
+ drainStdin()
305
382
  console.clear()
306
383
  p.intro(`${bold('storyboard terminal')}`)
307
384
 
@@ -310,6 +387,7 @@ async function welcomeLoop() {
310
387
  ? { value: 'agents', label: '✦ Start a new agent session' }
311
388
  : { value: 'copilot', label: `✦ Start a new ${agents[0]?.label || 'Copilot'} session` }
312
389
 
390
+ drainStdin()
313
391
  const action = await p.select({
314
392
  message: 'How would you like to start?',
315
393
  options: [
@@ -326,6 +404,7 @@ async function welcomeLoop() {
326
404
 
327
405
  if (action === 'agents') {
328
406
  // Multi-agent sub-select
407
+ drainStdin()
329
408
  const agentChoice = await p.select({
330
409
  message: 'Which agent?',
331
410
  options: agents.map(a => ({
@@ -378,6 +457,7 @@ async function welcomeLoop() {
378
457
  { value: 'terminal', label: '⊞ Terminal sessions' },
379
458
  ]
380
459
 
460
+ drainStdin()
381
461
  const sessionChoice = await p.select({
382
462
  message: 'Browse sessions',
383
463
  options: sessionOptions,
@@ -409,7 +489,10 @@ async function welcomeLoop() {
409
489
  setMouse(true)
410
490
  try {
411
491
  const shell = process.env.SHELL || '/bin/zsh'
412
- const child = spawn(shell, ['-lc', agent.resumeCommand], { stdio: 'inherit' })
492
+ const child = spawn(shell, ['-lc', agent.resumeCommand], {
493
+ stdio: 'inherit',
494
+ env: agentEnv(),
495
+ })
413
496
  await new Promise((resolve) => {
414
497
  child.on('close', resolve)
415
498
  child.on('error', resolve)
@@ -7,7 +7,7 @@
7
7
  export const id = 'canvas-agents'
8
8
 
9
9
  export async function guard(ctx) {
10
- const { getConfig } = await import('../../configStore.js')
10
+ const { getConfig } = await import('@dfosco/storyboard-core')
11
11
  const canvasConfig = getConfig('canvas')
12
12
  const agents = canvasConfig?.agents
13
13
  return agents && typeof agents === 'object' && Object.keys(agents).length > 0
@@ -5,7 +5,7 @@
5
5
  * handler. The component() export returns the component matching the tool's
6
6
  * render type, resolved at load time via the toolConfig passed to component().
7
7
  */
8
- import { getCanvasZoom } from '../../canvasConfig.js'
8
+ import { getCanvasZoom } from '@dfosco/storyboard-core'
9
9
 
10
10
  export const id = 'canvas-toolbar'
11
11
 
@@ -19,11 +19,11 @@ export async function handler(ctx) {
19
19
  let commentsAuth = null
20
20
  let prodMode = null
21
21
  let ff = null
22
- try { loader = await import('../../loader.js') } catch { /* optional */ }
22
+ try { loader = await import('@dfosco/storyboard-core') } catch { /* optional */ }
23
23
  try { hm = await import('../../hideMode.js') } catch { /* optional */ }
24
24
  try { commentsAuth = await import('../../comments/auth.js') } catch { /* optional */ }
25
25
  try { prodMode = await import('../../prodMode.js') } catch { /* optional */ }
26
- try { ff = await import('../../featureFlags.js') } catch { /* optional */ }
26
+ try { ff = await import('@dfosco/storyboard-core') } catch { /* optional */ }
27
27
 
28
28
  return {
29
29
  getChildren: () => {
@@ -6,7 +6,7 @@
6
6
  export const id = 'feature-flags'
7
7
 
8
8
  export async function handler() {
9
- const ff = await import('../../featureFlags.js')
9
+ const ff = await import('@dfosco/storyboard-core')
10
10
 
11
11
  return {
12
12
  getChildren: () =>
@@ -7,8 +7,8 @@
7
7
  export const id = 'flows'
8
8
 
9
9
  export async function handler(ctx) {
10
- const loader = await import('../../loader.js')
11
- const vf = await import('../../viewfinder.js')
10
+ const loader = await import('@dfosco/storyboard-core')
11
+ const vf = loader
12
12
  const { basePath = '/' } = ctx
13
13
 
14
14
  return {