@dfosco/storyboard 0.5.0-beta.36 → 0.5.0-beta.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard",
3
- "version": "0.5.0-beta.36",
3
+ "version": "0.5.0-beta.37",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -237,7 +237,10 @@ async function launchAgent(agent, { isInitialStartup = false } = {}) {
237
237
 
238
238
  try {
239
239
  const shell = process.env.SHELL || '/bin/zsh'
240
- const child = spawn(shell, ['-lc', agent.startupCommand], {
240
+ // -ilc: interactive + login so both .zprofile and .zshrc are sourced.
241
+ // Many users install agent CLIs (claude, copilot, etc.) via nvm/volta/asdf
242
+ // shims that only register PATH in .zshrc. Without -i the binary is not found.
243
+ const child = spawn(shell, ['-ilc', agent.startupCommand], {
241
244
  stdio: 'inherit',
242
245
  env: agentEnv(),
243
246
  })
@@ -498,7 +501,7 @@ async function welcomeLoop() {
498
501
  setMouse(true)
499
502
  try {
500
503
  const shell = process.env.SHELL || '/bin/zsh'
501
- const child = spawn(shell, ['-lc', agent.resumeCommand], {
504
+ const child = spawn(shell, ['-ilc', agent.resumeCommand], {
502
505
  stdio: 'inherit',
503
506
  env: agentEnv(),
504
507
  })
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { dirname, join } from 'node:path'
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url))
7
+ const SOURCE = readFileSync(join(__dirname, 'terminal-welcome.js'), 'utf8')
8
+
9
+ // Regression guard for the "command not found: claude" bug.
10
+ //
11
+ // Background: agents spawn via the user's $SHELL with -lc (login shell). On
12
+ // zsh that sources /etc/zprofile + ~/.zprofile but NOT ~/.zshrc. Many agent
13
+ // CLIs (claude, copilot, nvm/volta/asdf shims) only register PATH in
14
+ // ~/.zshrc, so a login-only shell cannot find them. Always use -ilc
15
+ // (interactive + login) so both rc files are sourced.
16
+ //
17
+ // This test scans the source rather than importing the module because
18
+ // terminal-welcome.js is a CLI entrypoint with top-level side effects and
19
+ // no exports. If you later refactor to export the spawn args, replace this
20
+ // with a stub-spawn unit test.
21
+ describe('terminal-welcome: agent spawn shell flags', () => {
22
+ it('uses -ilc (not -lc) for every spawn(shell, ...) call', () => {
23
+ const spawnCalls = SOURCE.match(/spawn\(\s*shell\s*,\s*\[[^\]]*\]/g) || []
24
+ expect(spawnCalls.length).toBeGreaterThan(0)
25
+
26
+ for (const call of spawnCalls) {
27
+ // Bare interactive shell (no -c flag, no command) is allowed —
28
+ // that's the fallback shell session. Anything passing a command
29
+ // string MUST source .zshrc.
30
+ const passesCommand = /-[il]+c['"]/.test(call)
31
+ if (!passesCommand) continue
32
+
33
+ expect(call, `spawn call missing -i flag: ${call}`).toMatch(/['"]-i?l?i?l?c['"]/)
34
+ expect(call, `spawn call must include -i: ${call}`).toMatch(/['"]-(i[l]?c|li?c|il?c)['"]/)
35
+ expect(call, `spawn call must not use bare -lc: ${call}`).not.toMatch(/['"]-lc['"]/)
36
+ }
37
+ })
38
+ })
@@ -379,6 +379,27 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
379
379
  // Dynamically adjust features based on widget state
380
380
  const features = useMemo(() => {
381
381
  const isGitHub = !!widget.props?.github
382
+ const isTerminalOrAgent = widget.type === 'terminal' || widget.type === 'agent'
383
+
384
+ // Detect connected terminal/agent peers — used to gate hub-related features.
385
+ // Hub role and broadcast are meaningless without a peer to coordinate with.
386
+ let hasHubPeers = false
387
+ let allBroadcastActive = true
388
+ const broadcastConnectorIds = []
389
+ if (isTerminalOrAgent) {
390
+ const widgetConnectors = connectorCount || []
391
+ const widgetList = allWidgets || []
392
+ for (const conn of widgetConnectors) {
393
+ const peerId = conn.start?.widgetId === widget.id ? conn.end?.widgetId : conn.start?.widgetId
394
+ const peer = widgetList.find((w) => w.id === peerId)
395
+ if (peer && (peer.type === 'terminal' || peer.type === 'agent')) {
396
+ hasHubPeers = true
397
+ broadcastConnectorIds.push(conn.id)
398
+ if (conn.meta?.messagingMode !== 'two-way') allBroadcastActive = false
399
+ }
400
+ }
401
+ }
402
+
382
403
  const adjusted = rawFeatures.map((f) => {
383
404
  // Toggle collapse label and hide when content is short (no github = no collapse)
384
405
  if (f.action === 'toggle-collapse') {
@@ -391,6 +412,9 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
391
412
  }
392
413
  // Hide refresh-github for non-GitHub link previews
393
414
  if (f.action === 'refresh-github' && !isGitHub) return null
415
+ // Hide hub-role selector when terminal/agent has no connected peers —
416
+ // a hub of one is not a hub.
417
+ if (f.type === 'role-selector' && isTerminalOrAgent && !hasHubPeers) return null
394
418
  return f
395
419
  }).filter(Boolean)
396
420
 
@@ -420,37 +444,19 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
420
444
  }
421
445
 
422
446
  // Add dynamic "Broadcast" toggle for terminal/agent widgets with connected peers
423
- if (widget.type === 'terminal' || widget.type === 'agent') {
424
- const widgetConnectors = connectorCount || []
425
- const widgetList = allWidgets || []
426
- let hasBroadcastPeers = false
427
- let allBroadcastActive = true
428
- const broadcastConnectorIds = []
429
-
430
- for (const conn of widgetConnectors) {
431
- const peerId = conn.start?.widgetId === widget.id ? conn.end?.widgetId : conn.start?.widgetId
432
- const peer = widgetList.find((w) => w.id === peerId)
433
- if (peer && (peer.type === 'terminal' || peer.type === 'agent')) {
434
- hasBroadcastPeers = true
435
- broadcastConnectorIds.push(conn.id)
436
- if (conn.meta?.messagingMode !== 'two-way') allBroadcastActive = false
437
- }
438
- }
439
-
440
- if (hasBroadcastPeers) {
441
- const isActive = allBroadcastActive
442
- const insertIdx = adjusted.findIndex((f) => f.menu)
443
- const broadcastFeature = {
444
- id: 'broadcast',
445
- type: 'action',
446
- action: `broadcast-toggle:${broadcastConnectorIds.join(',')}:${isActive ? 'off' : 'on'}`,
447
- label: isActive ? 'Broadcast On' : 'Broadcast',
448
- icon: 'broadcast',
449
- active: isActive,
450
- }
451
- if (insertIdx >= 0) adjusted.splice(insertIdx, 0, broadcastFeature)
452
- else adjusted.push(broadcastFeature)
453
- }
447
+ if (isTerminalOrAgent && hasHubPeers) {
448
+ const isActive = allBroadcastActive
449
+ const insertIdx = adjusted.findIndex((f) => f.menu)
450
+ const broadcastFeature = {
451
+ id: 'broadcast',
452
+ type: 'action',
453
+ action: `broadcast-toggle:${broadcastConnectorIds.join(',')}:${isActive ? 'off' : 'on'}`,
454
+ label: isActive ? 'Broadcast On' : 'Broadcast',
455
+ icon: 'broadcast',
456
+ active: isActive,
457
+ }
458
+ if (insertIdx >= 0) adjusted.splice(insertIdx, 0, broadcastFeature)
459
+ else adjusted.push(broadcastFeature)
454
460
  }
455
461
 
456
462
  return adjusted