@aion0/forge 0.5.7 → 0.5.8

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.
@@ -13,6 +13,7 @@ import '@xyflow/react/dist/style.css';
13
13
  interface AgentConfig {
14
14
  id: string; label: string; icon: string; role: string;
15
15
  type?: 'agent' | 'input';
16
+ primary?: boolean;
16
17
  content?: string;
17
18
  entries?: { content: string; timestamp: number }[];
18
19
  backend: 'api' | 'cli';
@@ -22,12 +23,14 @@ interface AgentConfig {
22
23
  outputs: string[];
23
24
  steps: { id: string; label: string; prompt: string }[];
24
25
  requiresApproval?: boolean;
26
+ persistentSession?: boolean;
27
+ skipPermissions?: boolean;
28
+ boundSessionId?: string;
25
29
  watch?: { enabled: boolean; interval: number; targets: any[]; action?: 'log' | 'analyze' | 'approve' | 'send_message'; prompt?: string; sendTo?: string };
26
30
  }
27
31
 
28
32
  interface AgentState {
29
33
  smithStatus: 'down' | 'active';
30
- mode: 'auto' | 'manual';
31
34
  taskStatus: 'idle' | 'running' | 'done' | 'failed';
32
35
  currentStep?: number;
33
36
  tmuxSession?: string;
@@ -237,7 +240,6 @@ function useWorkspaceStream(workspaceId: string | null, onEvent?: (event: any) =
237
240
  [event.agentId]: {
238
241
  ...prev[event.agentId],
239
242
  smithStatus: event.smithStatus,
240
- mode: event.mode,
241
243
  },
242
244
  }));
243
245
  }
@@ -282,6 +284,10 @@ function useWorkspaceStream(workspaceId: string | null, onEvent?: (event: any) =
282
284
  ));
283
285
  }
284
286
 
287
+ if (event.type === 'bus_log_updated') {
288
+ setBusLog(event.log || []);
289
+ }
290
+
285
291
  // Server pushed updated agents list + states (after add/remove/update/reset)
286
292
  if (event.type === 'agents_changed') {
287
293
  const newAgents = event.agents || [];
@@ -332,22 +338,24 @@ function SessionTargetSelector({ target, agents, projectPath, onChange }: {
332
338
  }) {
333
339
  const [sessions, setSessions] = useState<{ id: string; modified: string; label: string }[]>([]);
334
340
 
335
- // Load sessions when agent changes
341
+ // Load sessions and mark fixed session
336
342
  useEffect(() => {
337
343
  if (!projectPath) return;
338
344
  const pName = (projectPath || '').replace(/\/+$/, '').split('/').pop() || '';
339
- fetch(`/api/claude-sessions/${encodeURIComponent(pName)}`)
340
- .then(r => r.json())
341
- .then(data => {
342
- if (Array.isArray(data)) {
343
- setSessions(data.map((s: any, i: number) => ({
344
- id: s.sessionId || s.id || '',
345
- modified: s.modified || '',
346
- label: i === 0 ? `${(s.sessionId || '').slice(0, 8)} (latest)` : (s.sessionId || '').slice(0, 8),
347
- })));
348
- }
349
- })
350
- .catch(() => {});
345
+ Promise.all([
346
+ fetch(`/api/claude-sessions/${encodeURIComponent(pName)}`).then(r => r.json()).catch(() => []),
347
+ fetch(`/api/project-sessions?projectPath=${encodeURIComponent(projectPath)}`).then(r => r.json()).catch(() => ({})),
348
+ ]).then(([data, psData]) => {
349
+ const fixedId = psData?.fixedSessionId || '';
350
+ if (Array.isArray(data)) {
351
+ setSessions(data.map((s: any, i: number) => {
352
+ const sid = s.sessionId || s.id || '';
353
+ const isBound = sid === fixedId;
354
+ const label = isBound ? `${sid.slice(0, 8)} (fixed)` : i === 0 ? `${sid.slice(0, 8)} (latest)` : sid.slice(0, 8);
355
+ return { id: sid, modified: s.modified || '', label };
356
+ }));
357
+ }
358
+ });
351
359
  }, [projectPath]);
352
360
 
353
361
  return (
@@ -371,6 +379,58 @@ function SessionTargetSelector({ target, agents, projectPath, onChange }: {
371
379
  );
372
380
  }
373
381
 
382
+ // ─── Fixed Session Picker ────────────────────────────────
383
+
384
+ function FixedSessionPicker({ projectPath, value, onChange }: { projectPath?: string; value: string; onChange: (v: string) => void }) {
385
+ const [sessions, setSessions] = useState<{ id: string; modified: string; size: number }[]>([]);
386
+ const [copied, setCopied] = useState(false);
387
+
388
+ useEffect(() => {
389
+ if (!projectPath) return;
390
+ const pName = projectPath.replace(/\/+$/, '').split('/').pop() || '';
391
+ fetch(`/api/claude-sessions/${encodeURIComponent(pName)}`)
392
+ .then(r => r.json())
393
+ .then(data => { if (Array.isArray(data)) setSessions(data.map((s: any) => ({ id: s.sessionId || s.id || '', modified: s.modified || '', size: s.size || 0 }))); })
394
+ .catch(() => {});
395
+ }, [projectPath]);
396
+
397
+ const formatTime = (iso: string) => {
398
+ if (!iso) return '';
399
+ const diff = Date.now() - new Date(iso).getTime();
400
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
401
+ if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
402
+ return new Date(iso).toLocaleDateString();
403
+ };
404
+ const formatSize = (b: number) => b < 1024 ? `${b}B` : b < 1048576 ? `${(b / 1024).toFixed(0)}KB` : `${(b / 1048576).toFixed(1)}MB`;
405
+
406
+ const copyId = () => {
407
+ if (!value) return;
408
+ navigator.clipboard.writeText(value).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); });
409
+ };
410
+
411
+ return (
412
+ <div className="flex flex-col gap-0.5">
413
+ <label className="text-[9px] text-gray-500">Bound Session {value ? '' : '(auto-detect on first start)'}</label>
414
+ <select value={value} onChange={e => onChange(e.target.value)}
415
+ className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-gray-400 font-mono focus:outline-none focus:border-[#58a6ff]">
416
+ <option value="">Auto-detect (latest session)</option>
417
+ {sessions.map(s => (
418
+ <option key={s.id} value={s.id}>
419
+ {s.id.slice(0, 8)} · {formatTime(s.modified)} · {formatSize(s.size)}
420
+ </option>
421
+ ))}
422
+ </select>
423
+ {value && (
424
+ <div className="flex items-center gap-1 mt-0.5">
425
+ <code className="text-[8px] text-gray-500 font-mono bg-[#0d1117] px-1.5 py-0.5 rounded border border-[#21262d] flex-1 overflow-hidden text-ellipsis select-all">{value}</code>
426
+ <button onClick={copyId} className="text-[8px] px-1.5 py-0.5 rounded bg-[#30363d] text-gray-400 hover:text-white shrink-0">{copied ? '✓' : 'Copy'}</button>
427
+ <button onClick={() => onChange('')} className="text-[8px] px-1.5 py-0.5 rounded text-gray-600 hover:text-red-400 shrink-0">Clear</button>
428
+ </div>
429
+ )}
430
+ </div>
431
+ );
432
+ }
433
+
374
434
  // ─── Agent Config Modal ──────────────────────────────────
375
435
 
376
436
  function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfirm, onCancel }: {
@@ -405,6 +465,10 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
405
465
  (initial.steps || []).map(s => `${s.label}: ${s.prompt}`).join('\n') || ''
406
466
  );
407
467
  const [requiresApproval, setRequiresApproval] = useState(initial.requiresApproval || false);
468
+ const [isPrimary, setIsPrimary] = useState(initial.primary || false);
469
+ const hasPrimaryAlready = existingAgents.some(a => a.primary && a.id !== initial.id);
470
+ const [persistentSession, setPersistentSession] = useState(initial.persistentSession || initial.primary || false);
471
+ const [skipPermissions, setSkipPermissions] = useState(initial.skipPermissions !== false);
408
472
  const [watchEnabled, setWatchEnabled] = useState(initial.watch?.enabled || false);
409
473
  const [watchInterval, setWatchInterval] = useState(String(initial.watch?.interval || 60));
410
474
  const [watchAction, setWatchAction] = useState<'log' | 'analyze' | 'approve' | 'send_message'>(initial.watch?.action || 'log');
@@ -580,8 +644,9 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
580
644
  <div className="flex gap-2">
581
645
  <div className="flex flex-col gap-1 w-28">
582
646
  <label className="text-[9px] text-gray-500 uppercase">Work Dir</label>
583
- <input value={workDirVal} onChange={e => setWorkDirVal(e.target.value)} placeholder={label ? `${label.toLowerCase().replace(/\s+/g, '-')}/` : 'engineer/'}
584
- className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff]" />
647
+ <input value={isPrimary ? './' : workDirVal} onChange={e => !isPrimary && setWorkDirVal(e.target.value)} placeholder={label ? `${label.toLowerCase().replace(/\s+/g, '-')}/` : 'engineer/'}
648
+ disabled={isPrimary}
649
+ className={`text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] ${isPrimary ? 'opacity-50 cursor-not-allowed' : ''}`} />
585
650
  <div className="text-[8px] text-gray-600 mt-0.5">
586
651
  → {'{project}/'}{(workDirVal || (label ? `${label.toLowerCase().replace(/\s+/g, '-')}/` : '')).replace(/^\.?\//, '')}
587
652
  </div>
@@ -593,6 +658,22 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
593
658
  </div>
594
659
  </div>
595
660
 
661
+ {/* Primary Agent */}
662
+ <div className="flex items-center gap-2">
663
+ <input type="checkbox" id="primaryAgent" checked={isPrimary}
664
+ onChange={e => {
665
+ const v = e.target.checked;
666
+ setIsPrimary(v);
667
+ if (v) { setPersistentSession(true); setWorkDirVal('./'); }
668
+ }}
669
+ disabled={hasPrimaryAlready && !isPrimary}
670
+ className={`accent-[#f0883e] ${hasPrimaryAlready && !isPrimary ? 'opacity-50 cursor-not-allowed' : ''}`} />
671
+ <label htmlFor="primaryAgent" className={`text-[9px] ${isPrimary ? 'text-[#f0883e] font-medium' : 'text-gray-400'}`}>
672
+ Primary agent (terminal-only, root directory, fixed session)
673
+ {hasPrimaryAlready && !isPrimary && <span className="text-gray-600 ml-1">— already set on another agent</span>}
674
+ </label>
675
+ </div>
676
+
596
677
  {/* Requires Approval */}
597
678
  <div className="flex items-center gap-2">
598
679
  <input type="checkbox" id="requiresApproval" checked={requiresApproval} onChange={e => setRequiresApproval(e.target.checked)}
@@ -600,6 +681,25 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
600
681
  <label htmlFor="requiresApproval" className="text-[9px] text-gray-400">Require approval before processing inbox messages</label>
601
682
  </div>
602
683
 
684
+ {/* Persistent Session */}
685
+ <div className="flex items-center gap-2">
686
+ <input type="checkbox" id="persistentSession" checked={persistentSession} onChange={e => !isPrimary && setPersistentSession(e.target.checked)}
687
+ disabled={isPrimary}
688
+ className={`accent-[#3fb950] ${isPrimary ? 'opacity-50 cursor-not-allowed' : ''}`} />
689
+ <label htmlFor="persistentSession" className={`text-[9px] text-gray-400 ${isPrimary ? 'opacity-50' : ''}`}>
690
+ Terminal mode {isPrimary ? '(required for primary)' : '— run in terminal instead of headless (claude -p)'}
691
+ </label>
692
+ </div>
693
+ {persistentSession && (
694
+ <div className="flex flex-col gap-1.5 ml-4">
695
+ <div className="flex items-center gap-2">
696
+ <input type="checkbox" id="skipPermissions" checked={skipPermissions} onChange={e => setSkipPermissions(e.target.checked)}
697
+ className="accent-[#f0883e]" />
698
+ <label htmlFor="skipPermissions" className="text-[9px] text-gray-400">Skip permissions (auto-approve all tool calls)</label>
699
+ </div>
700
+ </div>
701
+ )}
702
+
603
703
  {/* Steps */}
604
704
  <div className="flex flex-col gap-1">
605
705
  <label className="text-[9px] text-gray-500 uppercase">Steps (one per line — Label: Prompt)</label>
@@ -760,10 +860,13 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
760
860
  onConfirm({
761
861
  label: label.trim(), icon: icon.trim() || '🤖', role: role.trim(),
762
862
  backend, agentId, dependsOn: Array.from(selectedDeps),
763
- workDir: workDirVal.trim() || label.trim().toLowerCase().replace(/\s+/g, '-') + '/',
863
+ workDir: isPrimary ? './' : (workDirVal.trim() || label.trim().toLowerCase().replace(/\s+/g, '-') + '/'),
764
864
  outputs: outputs.split(',').map(s => s.trim()).filter(Boolean),
765
865
  steps: parseSteps(),
866
+ primary: isPrimary || undefined,
766
867
  requiresApproval: requiresApproval || undefined,
868
+ persistentSession: isPrimary ? true : (persistentSession || undefined),
869
+ skipPermissions: persistentSession ? (skipPermissions ? undefined : false) : undefined,
767
870
  watch: watchEnabled && watchTargets.length > 0 ? {
768
871
  enabled: true,
769
872
  interval: Math.max(10, parseInt(watchInterval) || 60),
@@ -1334,6 +1437,48 @@ function BusPanel({ busLog, agents, onClose }: {
1334
1437
 
1335
1438
  // ─── Terminal Launch Dialog ───────────────────────────────
1336
1439
 
1440
+ function SessionItem({ session, formatTime, formatSize, onSelect }: {
1441
+ session: { id: string; modified: string; size: number };
1442
+ formatTime: (iso: string) => string;
1443
+ formatSize: (bytes: number) => string;
1444
+ onSelect: () => void;
1445
+ }) {
1446
+ const [expanded, setExpanded] = useState(false);
1447
+ const [copied, setCopied] = useState(false);
1448
+
1449
+ const copyId = (e: React.MouseEvent) => {
1450
+ e.stopPropagation();
1451
+ navigator.clipboard.writeText(session.id).then(() => {
1452
+ setCopied(true);
1453
+ setTimeout(() => setCopied(false), 1500);
1454
+ });
1455
+ };
1456
+
1457
+ return (
1458
+ <div className="rounded border border-[#21262d] hover:border-[#30363d] hover:bg-[#161b22] transition-colors">
1459
+ <div className="flex items-center gap-2 px-3 py-1.5 cursor-pointer" onClick={() => setExpanded(!expanded)}>
1460
+ <span className="text-[8px] text-gray-600">{expanded ? '▼' : '▶'}</span>
1461
+ <span className="text-[9px] text-gray-400 font-mono">{session.id.slice(0, 8)}</span>
1462
+ <span className="text-[8px] text-gray-600">{formatTime(session.modified)}</span>
1463
+ <span className="text-[8px] text-gray-600">{formatSize(session.size)}</span>
1464
+ <button onClick={(e) => { e.stopPropagation(); onSelect(); }}
1465
+ className="ml-auto text-[8px] px-1.5 py-0.5 rounded bg-[#238636]/20 text-[#3fb950] hover:bg-[#238636]/40">Resume</button>
1466
+ </div>
1467
+ {expanded && (
1468
+ <div className="px-3 pb-2 flex items-center gap-1.5">
1469
+ <code className="text-[8px] text-gray-500 font-mono bg-[#161b22] px-1.5 py-0.5 rounded border border-[#21262d] select-all flex-1 overflow-hidden text-ellipsis">
1470
+ {session.id}
1471
+ </code>
1472
+ <button onClick={copyId}
1473
+ className="text-[8px] px-1.5 py-0.5 rounded bg-[#30363d] text-gray-400 hover:text-white hover:bg-[#484f58] shrink-0">
1474
+ {copied ? '✓' : 'Copy'}
1475
+ </button>
1476
+ </div>
1477
+ )}
1478
+ </div>
1479
+ );
1480
+ }
1481
+
1337
1482
  function TerminalLaunchDialog({ agent, workDir, sessName, projectPath, workspaceId, supportsSession, onLaunch, onCancel }: {
1338
1483
  agent: AgentConfig; workDir?: string; sessName: string; projectPath: string; workspaceId: string;
1339
1484
  supportsSession?: boolean;
@@ -1349,7 +1494,7 @@ function TerminalLaunchDialog({ agent, workDir, sessName, projectPath, workspace
1349
1494
  if (!isClaude) return;
1350
1495
  fetch(`/api/workspace/${workspaceId}/smith`, {
1351
1496
  method: 'POST', headers: { 'Content-Type': 'application/json' },
1352
- body: JSON.stringify({ action: 'sessions' }),
1497
+ body: JSON.stringify({ action: 'sessions', agentId: agent.id }),
1353
1498
  }).then(r => r.json()).then(d => {
1354
1499
  if (d.sessions?.length) setSessions(d.sessions);
1355
1500
  }).catch(() => {});
@@ -1396,19 +1541,13 @@ function TerminalLaunchDialog({ agent, workDir, sessName, projectPath, workspace
1396
1541
  {isClaude && sessions.length > 1 && (
1397
1542
  <button onClick={() => setShowSessions(!showSessions)}
1398
1543
  className="w-full text-[9px] text-gray-500 hover:text-white py-1">
1399
- {showSessions ? '▼' : '▶'} More sessions ({sessions.length - 1})
1544
+ {showSessions ? '▼' : '▶'} All sessions ({sessions.length})
1400
1545
  </button>
1401
1546
  )}
1402
1547
 
1403
- {showSessions && sessions.slice(1).map(s => (
1404
- <button key={s.id} onClick={() => onLaunch(true, s.id)}
1405
- className="w-full text-left px-3 py-1.5 rounded border border-[#21262d] hover:border-[#30363d] hover:bg-[#161b22] transition-colors">
1406
- <div className="flex items-center gap-2">
1407
- <span className="text-[9px] text-gray-400 font-mono">{s.id.slice(0, 8)}</span>
1408
- <span className="text-[8px] text-gray-600">{formatTime(s.modified)}</span>
1409
- <span className="text-[8px] text-gray-600">{formatSize(s.size)}</span>
1410
- </div>
1411
- </button>
1548
+ {showSessions && sessions.map(s => (
1549
+ <SessionItem key={s.id} session={s} formatTime={formatTime} formatSize={formatSize}
1550
+ onSelect={() => onLaunch(true, s.id)} />
1412
1551
  ))}
1413
1552
  </div>
1414
1553
 
@@ -1430,7 +1569,217 @@ function getWsUrl() {
1430
1569
  return `${p}//${h}:${port + 1}`;
1431
1570
  }
1432
1571
 
1433
- function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliCmd: cliCmdProp, cliType, workDir, preferredSessionName, existingSession, resumeMode, resumeSessionId, profileEnv, onSessionReady, onClose }: {
1572
+ // ─── Terminal Dock (right side panel with tabs) ──────────
1573
+ type TerminalEntry = { agentId: string; label: string; icon: string; cliId: string; cliCmd?: string; cliType?: string; workDir?: string; tmuxSession?: string; sessionName: string; resumeMode?: boolean; resumeSessionId?: string; profileEnv?: Record<string, string> };
1574
+
1575
+ function TerminalDock({ terminals, projectPath, workspaceId, onSessionReady, onClose }: {
1576
+ terminals: TerminalEntry[];
1577
+ projectPath: string;
1578
+ workspaceId: string | null;
1579
+ onSessionReady: (agentId: string, name: string) => void;
1580
+ onClose: (agentId: string) => void;
1581
+ }) {
1582
+ const [activeTab, setActiveTab] = useState(terminals[0]?.agentId || '');
1583
+ const [width, setWidth] = useState(520);
1584
+ const dragRef = useRef<{ startX: number; origW: number } | null>(null);
1585
+
1586
+ // Auto-select new tab when added
1587
+ useEffect(() => {
1588
+ if (terminals.length > 0 && !terminals.find(t => t.agentId === activeTab)) {
1589
+ setActiveTab(terminals[terminals.length - 1].agentId);
1590
+ }
1591
+ }, [terminals, activeTab]);
1592
+
1593
+ const active = terminals.find(t => t.agentId === activeTab);
1594
+
1595
+ return (
1596
+ <div className="flex shrink-0" style={{ width }}>
1597
+ {/* Resize handle */}
1598
+ <div
1599
+ className="w-1 cursor-col-resize hover:bg-[#58a6ff]/30 active:bg-[#58a6ff]/50 transition-colors"
1600
+ style={{ background: '#21262d' }}
1601
+ onMouseDown={(e) => {
1602
+ e.preventDefault();
1603
+ dragRef.current = { startX: e.clientX, origW: width };
1604
+ const onMove = (ev: MouseEvent) => {
1605
+ if (!dragRef.current) return;
1606
+ const newW = dragRef.current.origW - (ev.clientX - dragRef.current.startX);
1607
+ setWidth(Math.max(300, Math.min(1200, newW)));
1608
+ };
1609
+ const onUp = () => { dragRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
1610
+ window.addEventListener('mousemove', onMove);
1611
+ window.addEventListener('mouseup', onUp);
1612
+ }}
1613
+ />
1614
+ <div className="flex-1 flex flex-col min-w-0 bg-[#0d1117] border-l border-[#30363d]">
1615
+ {/* Tabs */}
1616
+ <div className="flex items-center bg-[#161b22] border-b border-[#30363d] overflow-x-auto shrink-0">
1617
+ {terminals.map(t => (
1618
+ <div
1619
+ key={t.agentId}
1620
+ onClick={() => setActiveTab(t.agentId)}
1621
+ className={`flex items-center gap-1.5 px-3 py-1.5 text-[10px] border-r border-[#30363d] shrink-0 cursor-pointer ${
1622
+ t.agentId === activeTab
1623
+ ? 'bg-[#0d1117] text-white border-b-2 border-b-[#58a6ff]'
1624
+ : 'text-gray-500 hover:text-gray-300 hover:bg-[#1c2128]'
1625
+ }`}
1626
+ >
1627
+ <span>{t.icon}</span>
1628
+ <span className="font-medium">{t.label}</span>
1629
+ <span
1630
+ onClick={(e) => { e.stopPropagation(); onClose(t.agentId); }}
1631
+ className="ml-1 text-gray-600 hover:text-red-400 text-[8px] cursor-pointer"
1632
+ >✕</span>
1633
+ </div>
1634
+ ))}
1635
+ </div>
1636
+ {/* Active terminal */}
1637
+ {active && (
1638
+ <div className="flex-1 min-h-0" key={active.agentId}>
1639
+ <FloatingTerminalInline
1640
+ agentLabel={active.label}
1641
+ agentIcon={active.icon}
1642
+ projectPath={projectPath}
1643
+ agentCliId={active.cliId}
1644
+ cliCmd={active.cliCmd}
1645
+ cliType={active.cliType}
1646
+ workDir={active.workDir}
1647
+ preferredSessionName={active.sessionName}
1648
+ existingSession={active.tmuxSession}
1649
+ resumeMode={active.resumeMode}
1650
+ resumeSessionId={active.resumeSessionId}
1651
+ profileEnv={active.profileEnv}
1652
+ onSessionReady={(name) => onSessionReady(active.agentId, name)}
1653
+ />
1654
+ </div>
1655
+ )}
1656
+ </div>
1657
+ </div>
1658
+ );
1659
+ }
1660
+
1661
+ // ─── Inline Terminal (no drag/resize, fills parent) ──────
1662
+ function FloatingTerminalInline({ agentLabel, agentIcon, projectPath, agentCliId, cliCmd: cliCmdProp, cliType, workDir, preferredSessionName, existingSession, resumeMode, resumeSessionId, profileEnv, isPrimary, skipPermissions, boundSessionId, onSessionReady }: {
1663
+ agentLabel: string;
1664
+ agentIcon: string;
1665
+ projectPath: string;
1666
+ agentCliId: string;
1667
+ cliCmd?: string;
1668
+ cliType?: string;
1669
+ workDir?: string;
1670
+ preferredSessionName?: string;
1671
+ existingSession?: string;
1672
+ resumeMode?: boolean;
1673
+ resumeSessionId?: string;
1674
+ profileEnv?: Record<string, string>;
1675
+ isPrimary?: boolean;
1676
+ skipPermissions?: boolean;
1677
+ boundSessionId?: string;
1678
+ onSessionReady?: (name: string) => void;
1679
+ }) {
1680
+ const containerRef = useRef<HTMLDivElement>(null);
1681
+
1682
+ useEffect(() => {
1683
+ const el = containerRef.current;
1684
+ if (!el) return;
1685
+ let disposed = false;
1686
+
1687
+ Promise.all([
1688
+ import('@xterm/xterm'),
1689
+ import('@xterm/addon-fit'),
1690
+ ]).then(([{ Terminal }, { FitAddon }]) => {
1691
+ if (disposed) return;
1692
+
1693
+ const term = new Terminal({
1694
+ cursorBlink: true, fontSize: 13,
1695
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
1696
+ scrollback: 5000,
1697
+ theme: { background: '#0d1117', foreground: '#c9d1d9', cursor: '#58a6ff' },
1698
+ });
1699
+ const fitAddon = new FitAddon();
1700
+ term.loadAddon(fitAddon);
1701
+ term.open(el);
1702
+ setTimeout(() => { try { fitAddon.fit(); } catch {} }, 100);
1703
+
1704
+ const ro = new ResizeObserver(() => { try { fitAddon.fit(); } catch {} });
1705
+ ro.observe(el);
1706
+
1707
+ // Connect to terminal server
1708
+ const wsUrl = getWsUrl();
1709
+ const ws = new WebSocket(wsUrl);
1710
+ ws.binaryType = 'arraybuffer';
1711
+ const decoder = new TextDecoder();
1712
+
1713
+ ws.onopen = () => {
1714
+ ws.send(JSON.stringify({
1715
+ type: 'create',
1716
+ cols: term.cols, rows: term.rows,
1717
+ sessionName: existingSession || preferredSessionName,
1718
+ existingSession: existingSession || undefined,
1719
+ }));
1720
+ };
1721
+ ws.onmessage = async (event) => {
1722
+ try {
1723
+ const msg = JSON.parse(typeof event.data === 'string' ? event.data : decoder.decode(event.data));
1724
+ if (msg.type === 'data') {
1725
+ term.write(typeof msg.data === 'string' ? msg.data : new Uint8Array(Object.values(msg.data)));
1726
+ } else if (msg.type === 'created') {
1727
+ onSessionReady?.(msg.sessionName);
1728
+ // Auto-run CLI on newly created session
1729
+ if (!existingSession) {
1730
+ const cli = cliCmdProp || 'claude';
1731
+ const targetDir = workDir ? `${projectPath}/${workDir}` : projectPath;
1732
+ const cdCmd = `mkdir -p "${targetDir}" && cd "${targetDir}"`;
1733
+ const isClaude = (cliType || 'claude-code') === 'claude-code';
1734
+ const modelFlag = isClaude && profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
1735
+ const envWithoutModel = profileEnv ? Object.fromEntries(
1736
+ Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
1737
+ ) : {};
1738
+ // Unset old profile vars + set new ones
1739
+ const profileVarsToReset = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING', 'DISABLE_AUTOUPDATER', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_MODEL'];
1740
+ const unsetPrefix = profileVarsToReset.map(v => `unset ${v}`).join(' && ') + ' && ';
1741
+ const envExportsClean = unsetPrefix + (Object.keys(envWithoutModel).length > 0
1742
+ ? Object.entries(envWithoutModel).map(([k, v]) => `export ${k}="${v}"`).join(' && ') + ' && '
1743
+ : '');
1744
+ // Resolve session: explicit > boundSessionId > fixedSession (primary) > fresh
1745
+ let resumeId = resumeSessionId || boundSessionId;
1746
+ if (isClaude && !resumeId && isPrimary) {
1747
+ try {
1748
+ const { resolveFixedSession } = await import('@/lib/session-utils');
1749
+ resumeId = (await resolveFixedSession(projectPath)) || undefined;
1750
+ } catch {}
1751
+ }
1752
+ const resumeFlag = isClaude && resumeId ? ` --resume ${resumeId}` : '';
1753
+ let mcpFlag = '';
1754
+ if (isClaude) { try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {} }
1755
+ const sf = skipPermissions ? (cliType === 'codex' ? ' --full-auto' : cliType === 'aider' ? ' --yes' : ' --dangerously-skip-permissions') : '';
1756
+ const cmd = `${envExportsClean}${cdCmd} && ${cli}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`;
1757
+ setTimeout(() => {
1758
+ if (!disposed && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: cmd }));
1759
+ }, 300);
1760
+ }
1761
+ }
1762
+ } catch {}
1763
+ };
1764
+
1765
+ term.onData(data => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data })); });
1766
+ term.onResize(({ cols, rows }) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows })); });
1767
+
1768
+ return () => {
1769
+ disposed = true;
1770
+ ro.disconnect();
1771
+ ws.close();
1772
+ term.dispose();
1773
+ };
1774
+ });
1775
+
1776
+ return () => { disposed = true; };
1777
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
1778
+
1779
+ return <div ref={containerRef} className="w-full h-full" style={{ background: '#0d1117' }} />;
1780
+ }
1781
+
1782
+ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliCmd: cliCmdProp, cliType, workDir, preferredSessionName, existingSession, resumeMode, resumeSessionId, profileEnv, isPrimary, skipPermissions, persistentSession, boundSessionId, onSessionReady, onClose }: {
1434
1783
  agentLabel: string;
1435
1784
  agentIcon: string;
1436
1785
  projectPath: string;
@@ -1443,6 +1792,10 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1443
1792
  resumeMode?: boolean;
1444
1793
  resumeSessionId?: string;
1445
1794
  profileEnv?: Record<string, string>;
1795
+ isPrimary?: boolean;
1796
+ skipPermissions?: boolean;
1797
+ persistentSession?: boolean;
1798
+ boundSessionId?: string;
1446
1799
  onSessionReady?: (name: string) => void;
1447
1800
  onClose: (killSession: boolean) => void;
1448
1801
  }) {
@@ -1501,7 +1854,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1501
1854
  };
1502
1855
 
1503
1856
  let launched = false;
1504
- ws.onmessage = (event) => {
1857
+ ws.onmessage = async (event) => {
1505
1858
  if (disposed) return;
1506
1859
  try {
1507
1860
  const msg = JSON.parse(event.data);
@@ -1543,18 +1896,30 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1543
1896
 
1544
1897
  const cdCmd = `mkdir -p "${targetDir}" && cd "${targetDir}"`;
1545
1898
  const isClaude = (cliType || 'claude-code') === 'claude-code';
1546
- const resumeFlag = isClaude
1547
- ? (resumeSessionId ? ` --resume ${resumeSessionId}` : resumeMode ? ' -c' : '')
1548
- : '';
1549
1899
  const modelFlag = isClaude && profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
1550
- // Remove CLAUDE_MODEL from env exports (passed via --model flag instead)
1551
1900
  const envWithoutModel = profileEnv ? Object.fromEntries(
1552
1901
  Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
1553
1902
  ) : {};
1554
- const envExportsClean = Object.keys(envWithoutModel).length > 0
1903
+ // Unset old profile vars + set new ones (prevents leaking between agent switches)
1904
+ const profileVarsToReset = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING', 'DISABLE_AUTOUPDATER', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_MODEL'];
1905
+ const unsetPrefix = profileVarsToReset.map(v => `unset ${v}`).join(' && ') + ' && ';
1906
+ const envExportsClean = unsetPrefix + (Object.keys(envWithoutModel).length > 0
1555
1907
  ? Object.entries(envWithoutModel).map(([k, v]) => `export ${k}="${v}"`).join(' && ') + ' && '
1556
- : '';
1557
- const cmd = `${envExportsClean}${cdCmd} && ${cli}${resumeFlag}${modelFlag}\n`;
1908
+ : '');
1909
+ // Primary: use fixed session. Non-primary: use explicit sessionId or -c
1910
+ // Resolve session: explicit > boundSessionId > fixedSession (primary) > fresh
1911
+ let resumeId = resumeSessionId || boundSessionId;
1912
+ if (isClaude && !resumeId && isPrimary) {
1913
+ try {
1914
+ const { resolveFixedSession } = await import('@/lib/session-utils');
1915
+ resumeId = (await resolveFixedSession(projectPath)) || undefined;
1916
+ } catch {}
1917
+ }
1918
+ const resumeFlag = isClaude && resumeId ? ` --resume ${resumeId}` : '';
1919
+ let mcpFlag = '';
1920
+ if (isClaude) { try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {} }
1921
+ const sf = skipPermissions ? ' --dangerously-skip-permissions' : '';
1922
+ const cmd = `${envExportsClean}${cdCmd} && ${cli}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`;
1558
1923
  setTimeout(() => {
1559
1924
  if (!disposed && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: cmd }));
1560
1925
  }, 300);
@@ -1642,7 +2007,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1642
2007
  <button onClick={() => { setShowCloseDialog(false); onClose(false); }}
1643
2008
  className="flex-1 px-3 py-1.5 text-[11px] rounded bg-[#2a2a4a] text-gray-300 hover:bg-[#3a3a5a] hover:text-white">
1644
2009
  Suspend
1645
- <span className="block text-[9px] text-gray-500 mt-0.5">Session keeps running</span>
2010
+ <span className="block text-[9px] text-gray-500 mt-0.5">Hide panel, session keeps running</span>
1646
2011
  </button>
1647
2012
  <button onClick={() => {
1648
2013
  setShowCloseDialog(false);
@@ -1651,9 +2016,11 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1651
2016
  }
1652
2017
  onClose(true);
1653
2018
  }}
1654
- className="flex-1 px-3 py-1.5 text-[11px] rounded bg-red-500/20 text-red-400 hover:bg-red-500/30">
1655
- Kill Session
1656
- <span className="block text-[9px] text-red-400/60 mt-0.5">End session, back to auto</span>
2019
+ className={`flex-1 px-3 py-1.5 text-[11px] rounded ${persistentSession ? 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30' : 'bg-red-500/20 text-red-400 hover:bg-red-500/30'}`}>
2020
+ {persistentSession ? 'Restart Session' : 'Kill Session'}
2021
+ <span className={`block text-[9px] mt-0.5 ${persistentSession ? 'text-yellow-400/60' : 'text-red-400/60'}`}>
2022
+ {persistentSession ? 'Kill and restart with fresh env' : 'End session permanently'}
2023
+ </span>
1657
2024
  </button>
1658
2025
  </div>
1659
2026
  <button onClick={() => setShowCloseDialog(false)}
@@ -1758,17 +2125,18 @@ interface AgentNodeData {
1758
2125
  onShowMemory: () => void;
1759
2126
  onShowInbox: () => void;
1760
2127
  onOpenTerminal: () => void;
2128
+ onSwitchSession: () => void;
1761
2129
  inboxPending?: number;
1762
2130
  inboxFailed?: number;
1763
2131
  [key: string]: unknown;
1764
2132
  }
1765
2133
 
1766
2134
  function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
1767
- const { config, state, colorIdx, previewLines, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, inboxPending = 0, inboxFailed = 0 } = data;
2135
+ const { config, state, colorIdx, previewLines, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, onSwitchSession, inboxPending = 0, inboxFailed = 0 } = data;
1768
2136
  const c = COLORS[colorIdx % COLORS.length];
1769
2137
  const smithStatus = state?.smithStatus || 'down';
1770
2138
  const taskStatus = state?.taskStatus || 'idle';
1771
- const mode = state?.mode || 'auto';
2139
+ const hasTmux = !!state?.tmuxSession;
1772
2140
  const smithInfo = SMITH_STATUS[smithStatus] || SMITH_STATUS.down;
1773
2141
  const taskInfo = TASK_STATUS[taskStatus] || TASK_STATUS.idle;
1774
2142
  const currentStep = state?.currentStep;
@@ -1782,6 +2150,9 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
1782
2150
  <Handle type="target" position={Position.Left} style={{ background: c.accent, width: 8, height: 8, border: 'none' }} />
1783
2151
  <Handle type="source" position={Position.Right} style={{ background: c.accent, width: 8, height: 8, border: 'none' }} />
1784
2152
 
2153
+ {/* Primary badge */}
2154
+ {config.primary && <div className="bg-[#f0883e]/20 text-[#f0883e] text-[7px] font-bold text-center py-0.5 rounded-t-lg">PRIMARY</div>}
2155
+
1785
2156
  {/* Header */}
1786
2157
  <div className="flex items-center gap-2 px-3 py-2">
1787
2158
  <span className="text-sm">{config.icon}</span>
@@ -1789,15 +2160,23 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
1789
2160
  <div className="text-xs font-semibold text-white truncate">{config.label}</div>
1790
2161
  <div className="text-[8px]" style={{ color: c.accent }}>{config.backend === 'api' ? config.provider || 'api' : config.agentId || 'cli'}</div>
1791
2162
  </div>
1792
- {/* Status: smith + mode + task */}
2163
+ {/* Status: smith + terminal + task */}
1793
2164
  <div className="flex flex-col items-end gap-0.5">
1794
2165
  <div className="flex items-center gap-1">
1795
2166
  <div className="w-1.5 h-1.5 rounded-full" style={{ background: smithInfo.color, boxShadow: smithInfo.glow ? `0 0 4px ${smithInfo.color}` : 'none' }} />
1796
2167
  <span className="text-[7px]" style={{ color: smithInfo.color }}>{smithInfo.label}</span>
1797
2168
  </div>
1798
2169
  <div className="flex items-center gap-1">
1799
- <div className="w-1.5 h-1.5 rounded-full" style={{ background: mode === 'manual' ? '#d2a8ff' : '#30363d' }} />
1800
- <span className="text-[7px]" style={{ color: mode === 'manual' ? '#d2a8ff' : '#6e7681' }}>{mode}</span>
2170
+ {(() => {
2171
+ // Execution mode is determined by config, not tmux state
2172
+ const isTerminalMode = config.persistentSession;
2173
+ const color = isTerminalMode ? (hasTmux ? '#3fb950' : '#f0883e') : '#484f58';
2174
+ const label = isTerminalMode ? (hasTmux ? 'terminal' : 'terminal (down)') : 'headless';
2175
+ return (<>
2176
+ <div className="w-1.5 h-1.5 rounded-full" style={{ background: color }} />
2177
+ <span className="text-[7px] font-medium" style={{ color }}>{label}</span>
2178
+ </>);
2179
+ })()}
1801
2180
  </div>
1802
2181
  <div className="flex items-center gap-1">
1803
2182
  <div className="w-1.5 h-1.5 rounded-full" style={{ background: taskInfo.color, boxShadow: taskInfo.glow ? `0 0 4px ${taskInfo.color}` : 'none' }} />
@@ -1861,10 +2240,14 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
1861
2240
  className="text-[9px] px-1.5 py-0.5 rounded bg-blue-600/20 text-blue-400 hover:bg-blue-600/30">💬 Message</button>
1862
2241
  )}
1863
2242
  <div className="flex-1" />
1864
- {taskStatus !== 'running' && (
1865
- <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onOpenTerminal(); }}
1866
- className="text-[9px] text-gray-600 hover:text-green-400 px-1" title="Open terminal (manual mode)">⌨️</button>
1867
- )}
2243
+ <span className="flex items-center">
2244
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onOpenTerminal(); }}
2245
+ className={`text-[9px] px-1 ${hasTmux && taskStatus === 'running' ? 'text-green-400 animate-pulse' : 'text-gray-600 hover:text-green-400'}`} title="Open terminal">⌨️</button>
2246
+ {hasTmux && !config.primary && (
2247
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onSwitchSession(); }}
2248
+ className="text-[10px] text-gray-600 hover:text-yellow-400 px-0.5 py-0.5" title="Switch session">▾</button>
2249
+ )}
2250
+ </span>
1868
2251
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowInbox(); }}
1869
2252
  className="text-[9px] text-gray-600 hover:text-orange-400 px-1" title="Messages (inbox/outbox)">📨</button>
1870
2253
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowMemory(); }}
@@ -1904,7 +2287,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
1904
2287
  const [memoryTarget, setMemoryTarget] = useState<{ id: string; label: string } | null>(null);
1905
2288
  const [inboxTarget, setInboxTarget] = useState<{ id: string; label: string } | null>(null);
1906
2289
  const [showBusPanel, setShowBusPanel] = useState(false);
1907
- const [floatingTerminals, setFloatingTerminals] = useState<{ agentId: string; label: string; icon: string; cliId: string; cliCmd?: string; cliType?: string; workDir?: string; tmuxSession?: string; sessionName: string; resumeMode?: boolean; resumeSessionId?: string; profileEnv?: Record<string, string> }[]>([]);
2290
+ const [floatingTerminals, setFloatingTerminals] = useState<{ agentId: string; label: string; icon: string; cliId: string; cliCmd?: string; cliType?: string; workDir?: string; tmuxSession?: string; sessionName: string; resumeMode?: boolean; resumeSessionId?: string; profileEnv?: Record<string, string>; isPrimary?: boolean; skipPermissions?: boolean; persistentSession?: boolean; boundSessionId?: string }[]>([]);
1908
2291
  const [termLaunchDialog, setTermLaunchDialog] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; sessions: string[]; supportsSession?: boolean } | null>(null);
1909
2292
 
1910
2293
  // Expose focusAgent to parent
@@ -1938,27 +2321,8 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
1938
2321
  }
1939
2322
  });
1940
2323
 
1941
- // Auto-open floating terminals for manual agents on page load
1942
- const autoOpenDone = useRef(false);
1943
- useEffect(() => {
1944
- if (autoOpenDone.current || agents.length === 0 || Object.keys(states).length === 0) return;
1945
- autoOpenDone.current = true;
1946
- const manualAgents = agents.filter(a =>
1947
- a.type !== 'input' && states[a.id]?.mode === 'manual' && states[a.id]?.tmuxSession
1948
- );
1949
- if (manualAgents.length > 0) {
1950
- const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
1951
- setFloatingTerminals(manualAgents.map(a => ({
1952
- agentId: a.id,
1953
- label: a.label,
1954
- icon: a.icon,
1955
- cliId: a.agentId || 'claude',
1956
- workDir: a.workDir && a.workDir !== './' && a.workDir !== '.' ? a.workDir : undefined,
1957
- tmuxSession: states[a.id].tmuxSession,
1958
- sessionName: `mw-forge-${safeName(projectName)}-${safeName(a.label)}`,
1959
- })));
1960
- }
1961
- }, [agents, states]);
2324
+ // Auto-open terminals removed persistent sessions run in background tmux.
2325
+ // User opens terminal via ⌨️ button when needed.
1962
2326
 
1963
2327
  // Rebuild nodes when agents/states/preview change — preserve existing positions + dimensions
1964
2328
  useEffect(() => {
@@ -1980,7 +2344,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
1980
2344
  type: 'input' as const,
1981
2345
  data: {
1982
2346
  config: agent,
1983
- state: states[agent.id] || { smithStatus: 'down', mode: 'auto', taskStatus: 'idle', artifacts: [] },
2347
+ state: states[agent.id] || { smithStatus: 'down', taskStatus: 'idle', artifacts: [] },
1984
2348
  onSubmit: (content: string) => {
1985
2349
  // Optimistic update
1986
2350
  wsApi(workspaceId!, 'complete_input', { agentId: agent.id, content });
@@ -2000,7 +2364,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2000
2364
  type: 'agent' as const,
2001
2365
  data: {
2002
2366
  config: agent,
2003
- state: states[agent.id] || { smithStatus: 'down', mode: 'auto', taskStatus: 'idle', artifacts: [] },
2367
+ state: states[agent.id] || { smithStatus: 'down', taskStatus: 'idle', artifacts: [] },
2004
2368
  colorIdx: i,
2005
2369
  previewLines: logPreview[agent.id] || [],
2006
2370
  onRun: () => {
@@ -2023,36 +2387,83 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2023
2387
  inboxFailed: busLog.filter(m => m.to === agent.id && m.status === 'failed' && m.type !== 'ack').length,
2024
2388
  onOpenTerminal: async () => {
2025
2389
  if (!workspaceId) return;
2026
- // Use current state via setState callback to avoid stale closure
2027
- let alreadyOpen = false;
2028
- setFloatingTerminals(prev => { alreadyOpen = prev.some(t => t.agentId === agent.id); return prev; });
2029
- if (alreadyOpen) return;
2390
+ if (!daemonActiveFromStream) {
2391
+ alert('Start daemon first before opening terminal.');
2392
+ return;
2393
+ }
2394
+ // Close existing terminal (config may have changed)
2395
+ setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
2030
2396
 
2031
2397
  const agentState = states[agent.id];
2032
2398
  const existingTmux = agentState?.tmuxSession;
2033
-
2034
- // Build fixed session name: mw-forge-{project}-{agentLabel}
2035
2399
  const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
2036
2400
  const sessName = `mw-forge-${safeName(projectName)}-${safeName(agent.label)}`;
2037
- const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.'
2038
- ? agent.workDir : undefined;
2401
+ const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.' ? agent.workDir : undefined;
2402
+
2403
+ // Always resolve launch info for this agent (cliCmd, env, model)
2404
+ const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
2405
+ const launchInfo = {
2406
+ cliCmd: resolveRes?.cliCmd || 'claude',
2407
+ cliType: resolveRes?.cliType || 'claude-code',
2408
+ profileEnv: {
2409
+ ...(resolveRes?.env || {}),
2410
+ ...(resolveRes?.model ? { CLAUDE_MODEL: resolveRes.model } : {}),
2411
+ FORGE_AGENT_ID: agent.id,
2412
+ FORGE_WORKSPACE_ID: workspaceId!,
2413
+ FORGE_PORT: String(window.location.port || 8403),
2414
+ },
2415
+ };
2039
2416
 
2040
- // If already manual with a tmux session, just reopen (attach)
2041
- if (agentState?.mode === 'manual' && existingTmux) {
2417
+ // If tmux session exists attach (primary or non-primary)
2418
+ if (existingTmux) {
2419
+ wsApi(workspaceId, 'open_terminal', { agentId: agent.id });
2042
2420
  setFloatingTerminals(prev => [...prev, {
2043
2421
  agentId: agent.id, label: agent.label, icon: agent.icon,
2044
- cliId: agent.agentId || 'claude', workDir,
2422
+ cliId: agent.agentId || 'claude', ...launchInfo, workDir,
2045
2423
  tmuxSession: existingTmux, sessionName: sessName,
2424
+ isPrimary: agent.primary, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId,
2046
2425
  }]);
2047
2426
  return;
2048
2427
  }
2049
2428
 
2050
- // Resolve terminal launch info to determine supportsSession
2051
- const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
2052
- const supportsSession = resolveRes?.supportsSession ?? true;
2429
+ // Primary without session open directly (no dialog)
2430
+ if (agent.primary) {
2431
+ const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id }).catch(() => ({})) as any;
2432
+ setFloatingTerminals(prev => [...prev, {
2433
+ agentId: agent.id, label: agent.label, icon: agent.icon,
2434
+ cliId: agent.agentId || 'claude', ...launchInfo, workDir,
2435
+ tmuxSession: res?.tmuxSession || sessName, sessionName: sessName,
2436
+ isPrimary: true, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId,
2437
+ }]);
2438
+ return;
2439
+ }
2053
2440
 
2054
- // Show launch dialog with resolved info
2055
- setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession });
2441
+ // Non-primary: has boundSessionId use it directly; no bound → show dialog
2442
+ if (agent.boundSessionId) {
2443
+ const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id }).catch(() => ({})) as any;
2444
+ setFloatingTerminals(prev => [...prev, {
2445
+ agentId: agent.id, label: agent.label, icon: agent.icon,
2446
+ cliId: agent.agentId || 'claude', ...launchInfo, workDir,
2447
+ tmuxSession: res?.tmuxSession || sessName, sessionName: sessName,
2448
+ resumeSessionId: agent.boundSessionId,
2449
+ isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId,
2450
+ }]);
2451
+ return;
2452
+ }
2453
+ // No bound session → show launch dialog (New / Resume / Select)
2454
+ setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true });
2455
+ },
2456
+ onSwitchSession: async () => {
2457
+ if (!workspaceId) return;
2458
+ // Close existing terminal
2459
+ setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
2460
+ if (agent.id) wsApi(workspaceId, 'close_terminal', { agentId: agent.id });
2461
+ // Show launch dialog
2462
+ const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
2463
+ const sessName = `mw-forge-${safeName(projectName)}-${safeName(agent.label)}`;
2464
+ const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.' ? agent.workDir : undefined;
2465
+ const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
2466
+ setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true });
2056
2467
  },
2057
2468
  } satisfies AgentNodeData,
2058
2469
  };
@@ -2244,8 +2655,22 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2244
2655
  {agents.length === 0 ? (
2245
2656
  <div className="flex-1 flex flex-col items-center justify-center gap-3">
2246
2657
  <span className="text-3xl">🚀</span>
2247
- <div className="text-sm text-gray-400">Add agents to start</div>
2248
- <div className="flex gap-2 mt-2 flex-wrap justify-center">
2658
+ <div className="text-sm text-gray-400">Set up your workspace</div>
2659
+ {/* Primary agent prompt */}
2660
+ <button onClick={() => setModal({ mode: 'add', initial: {
2661
+ label: 'Engineer', icon: '👨‍💻', primary: true, persistentSession: true,
2662
+ role: 'Primary engineer — handles coding tasks in the project root.',
2663
+ backend: 'cli' as const, agentId: 'claude', workDir: './', dependsOn: [], outputs: [], steps: [],
2664
+ }})}
2665
+ className="flex items-center gap-3 px-5 py-3 rounded-lg border-2 border-dashed border-[#f0883e]/50 bg-[#f0883e]/5 hover:bg-[#f0883e]/10 hover:border-[#f0883e]/80 transition-colors">
2666
+ <span className="text-2xl">👨‍💻</span>
2667
+ <div className="text-left">
2668
+ <div className="text-[11px] font-semibold text-[#f0883e]">Add Primary Agent</div>
2669
+ <div className="text-[9px] text-gray-500">Terminal-only, root directory, fixed session</div>
2670
+ </div>
2671
+ </button>
2672
+ <div className="text-[9px] text-gray-600 mt-1">or add other agents:</div>
2673
+ <div className="flex gap-2 flex-wrap justify-center">
2249
2674
  {PRESET_AGENTS.map((p, i) => (
2250
2675
  <button key={i} onClick={() => setModal({ mode: 'add', initial: p })}
2251
2676
  className="text-[10px] px-3 py-1.5 rounded border border-[#30363d] text-gray-300 hover:text-white hover:border-[#58a6ff]/60 flex items-center gap-1">
@@ -2273,7 +2698,20 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2273
2698
  </div>
2274
2699
  </div>
2275
2700
  ) : (
2276
- <div className="flex-1 min-h-0">
2701
+ <div className="flex-1 min-h-0 flex flex-col">
2702
+ {/* No primary agent hint */}
2703
+ {!agents.some(a => a.primary) && (
2704
+ <div className="flex items-center gap-2 px-3 py-1.5 bg-[#f0883e]/10 border-b border-[#f0883e]/20 shrink-0">
2705
+ <span className="text-[10px] text-[#f0883e]">No primary agent set.</span>
2706
+ <button onClick={() => setModal({ mode: 'add', initial: {
2707
+ label: 'Engineer', icon: '👨‍💻', primary: true, persistentSession: true,
2708
+ role: 'Primary engineer — handles coding tasks in the project root.',
2709
+ backend: 'cli' as const, agentId: 'claude', workDir: './', dependsOn: [], outputs: [], steps: [],
2710
+ }})}
2711
+ className="text-[10px] text-[#f0883e] underline hover:text-white">Add one</button>
2712
+ <span className="text-[9px] text-gray-600">or edit an existing agent to set as primary.</span>
2713
+ </div>
2714
+ )}
2277
2715
  <ReactFlow
2278
2716
  nodes={rfNodes}
2279
2717
  edges={rfEdges}
@@ -2378,13 +2816,17 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2378
2816
  setTermLaunchDialog(null);
2379
2817
  const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id });
2380
2818
  if (res.ok) {
2819
+ // Save selected session as boundSessionId if user chose a specific one
2820
+ if (sessionId) {
2821
+ wsApi(workspaceId, 'update', { agentId: agent.id, config: { ...agent, boundSessionId: sessionId } }).catch(() => {});
2822
+ }
2381
2823
  setFloatingTerminals(prev => [...prev, {
2382
2824
  agentId: agent.id, label: agent.label, icon: agent.icon,
2383
2825
  cliId: agent.agentId || 'claude',
2384
2826
  cliCmd: res.cliCmd || 'claude',
2385
2827
  cliType: res.cliType || 'claude-code',
2386
2828
  workDir,
2387
- sessionName: sessName, resumeMode, resumeSessionId: sessionId,
2829
+ sessionName: sessName, resumeMode, resumeSessionId: sessionId, isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: sessionId || agent.boundSessionId,
2388
2830
  profileEnv: {
2389
2831
  ...(res.env || {}),
2390
2832
  ...(res.model ? { CLAUDE_MODEL: res.model } : {}),
@@ -2399,7 +2841,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2399
2841
  />
2400
2842
  )}
2401
2843
 
2402
- {/* Floating terminals for manual agents */}
2844
+ {/* Floating terminals positioned near their agent node */}
2403
2845
  {floatingTerminals.map(ft => (
2404
2846
  <FloatingTerminal
2405
2847
  key={ft.agentId}
@@ -2415,17 +2857,17 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2415
2857
  resumeMode={ft.resumeMode}
2416
2858
  resumeSessionId={ft.resumeSessionId}
2417
2859
  profileEnv={ft.profileEnv}
2860
+ isPrimary={ft.isPrimary}
2861
+ skipPermissions={ft.skipPermissions}
2862
+ persistentSession={ft.persistentSession}
2863
+ boundSessionId={ft.boundSessionId}
2418
2864
  onSessionReady={(name) => {
2419
- if (workspaceId) {
2420
- wsApi(workspaceId, 'set_tmux_session', { agentId: ft.agentId, sessionName: name });
2421
- }
2865
+ if (workspaceId) wsApi(workspaceId, 'set_tmux_session', { agentId: ft.agentId, sessionName: name });
2422
2866
  setFloatingTerminals(prev => prev.map(t => t.agentId === ft.agentId ? { ...t, tmuxSession: name } : t));
2423
2867
  }}
2424
- onClose={() => {
2868
+ onClose={(killSession) => {
2425
2869
  setFloatingTerminals(prev => prev.filter(t => t.agentId !== ft.agentId));
2426
- if (workspaceId) {
2427
- wsApi(workspaceId, 'close_terminal', { agentId: ft.agentId });
2428
- }
2870
+ if (workspaceId) wsApi(workspaceId, 'close_terminal', { agentId: ft.agentId, kill: killSession });
2429
2871
  }}
2430
2872
  />
2431
2873
  ))}