@aion0/forge 0.5.7 → 0.5.9

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, initialPos, onSessionReady, onClose }: {
1434
1783
  agentLabel: string;
1435
1784
  agentIcon: string;
1436
1785
  projectPath: string;
@@ -1443,14 +1792,24 @@ 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;
1799
+ initialPos?: { x: number; y: number };
1446
1800
  onSessionReady?: (name: string) => void;
1447
1801
  onClose: (killSession: boolean) => void;
1448
1802
  }) {
1449
1803
  const containerRef = useRef<HTMLDivElement>(null);
1450
1804
  const wsRef = useRef<WebSocket | null>(null);
1451
1805
  const sessionNameRef = useRef('');
1452
- const [pos, setPos] = useState({ x: 80, y: 60 });
1453
- const [size, setSize] = useState({ w: 750, h: 450 });
1806
+ const [pos, setPos] = useState(initialPos || { x: 80, y: 60 });
1807
+ const [userDragged, setUserDragged] = useState(false);
1808
+ // Follow node position unless user manually dragged the terminal
1809
+ useEffect(() => {
1810
+ if (initialPos && !userDragged) setPos(initialPos);
1811
+ }, [initialPos?.x, initialPos?.y]); // eslint-disable-line react-hooks/exhaustive-deps
1812
+ const [size, setSize] = useState({ w: 500, h: 300 });
1454
1813
  const [showCloseDialog, setShowCloseDialog] = useState(false);
1455
1814
  const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
1456
1815
  const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
@@ -1468,7 +1827,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1468
1827
  if (disposed) return;
1469
1828
 
1470
1829
  const term = new Terminal({
1471
- cursorBlink: true, fontSize: 13,
1830
+ cursorBlink: true, fontSize: 10,
1472
1831
  fontFamily: 'Menlo, Monaco, "Courier New", monospace',
1473
1832
  scrollback: 5000,
1474
1833
  theme: { background: '#0d1117', foreground: '#c9d1d9', cursor: '#58a6ff' },
@@ -1478,7 +1837,15 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1478
1837
  term.open(el);
1479
1838
  setTimeout(() => { try { fitAddon.fit(); } catch {} }, 100);
1480
1839
 
1481
- const ro = new ResizeObserver(() => { try { fitAddon.fit(); } catch {} });
1840
+ // Scale font: min 10 at small size, max 13 at large size
1841
+ const ro = new ResizeObserver(() => {
1842
+ try {
1843
+ const w = el.clientWidth;
1844
+ const newSize = Math.min(13, Math.max(10, Math.floor(w / 60)));
1845
+ if (term.options.fontSize !== newSize) term.options.fontSize = newSize;
1846
+ fitAddon.fit();
1847
+ } catch {}
1848
+ });
1482
1849
  ro.observe(el);
1483
1850
 
1484
1851
  // Connect WebSocket — attach to existing or create new
@@ -1501,7 +1868,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1501
1868
  };
1502
1869
 
1503
1870
  let launched = false;
1504
- ws.onmessage = (event) => {
1871
+ ws.onmessage = async (event) => {
1505
1872
  if (disposed) return;
1506
1873
  try {
1507
1874
  const msg = JSON.parse(event.data);
@@ -1543,18 +1910,30 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1543
1910
 
1544
1911
  const cdCmd = `mkdir -p "${targetDir}" && cd "${targetDir}"`;
1545
1912
  const isClaude = (cliType || 'claude-code') === 'claude-code';
1546
- const resumeFlag = isClaude
1547
- ? (resumeSessionId ? ` --resume ${resumeSessionId}` : resumeMode ? ' -c' : '')
1548
- : '';
1549
1913
  const modelFlag = isClaude && profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
1550
- // Remove CLAUDE_MODEL from env exports (passed via --model flag instead)
1551
1914
  const envWithoutModel = profileEnv ? Object.fromEntries(
1552
1915
  Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
1553
1916
  ) : {};
1554
- const envExportsClean = Object.keys(envWithoutModel).length > 0
1917
+ // Unset old profile vars + set new ones (prevents leaking between agent switches)
1918
+ 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'];
1919
+ const unsetPrefix = profileVarsToReset.map(v => `unset ${v}`).join(' && ') + ' && ';
1920
+ const envExportsClean = unsetPrefix + (Object.keys(envWithoutModel).length > 0
1555
1921
  ? Object.entries(envWithoutModel).map(([k, v]) => `export ${k}="${v}"`).join(' && ') + ' && '
1556
- : '';
1557
- const cmd = `${envExportsClean}${cdCmd} && ${cli}${resumeFlag}${modelFlag}\n`;
1922
+ : '');
1923
+ // Primary: use fixed session. Non-primary: use explicit sessionId or -c
1924
+ // Resolve session: explicit > boundSessionId > fixedSession (primary) > fresh
1925
+ let resumeId = resumeSessionId || boundSessionId;
1926
+ if (isClaude && !resumeId && isPrimary) {
1927
+ try {
1928
+ const { resolveFixedSession } = await import('@/lib/session-utils');
1929
+ resumeId = (await resolveFixedSession(projectPath)) || undefined;
1930
+ } catch {}
1931
+ }
1932
+ const resumeFlag = isClaude && resumeId ? ` --resume ${resumeId}` : '';
1933
+ let mcpFlag = '';
1934
+ if (isClaude) { try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {} }
1935
+ const sf = skipPermissions ? ' --dangerously-skip-permissions' : '';
1936
+ const cmd = `${envExportsClean}${cdCmd} && ${cli}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`;
1558
1937
  setTimeout(() => {
1559
1938
  if (!disposed && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: cmd }));
1560
1939
  }, 300);
@@ -1591,6 +1970,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1591
1970
  onMouseDown={(e) => {
1592
1971
  e.preventDefault();
1593
1972
  dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
1973
+ setUserDragged(true);
1594
1974
  const onMove = (ev: MouseEvent) => {
1595
1975
  if (!dragRef.current) return;
1596
1976
  setPos({ x: Math.max(0, dragRef.current.origX + ev.clientX - dragRef.current.startX), y: Math.max(0, dragRef.current.origY + ev.clientY - dragRef.current.startY) });
@@ -1642,7 +2022,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1642
2022
  <button onClick={() => { setShowCloseDialog(false); onClose(false); }}
1643
2023
  className="flex-1 px-3 py-1.5 text-[11px] rounded bg-[#2a2a4a] text-gray-300 hover:bg-[#3a3a5a] hover:text-white">
1644
2024
  Suspend
1645
- <span className="block text-[9px] text-gray-500 mt-0.5">Session keeps running</span>
2025
+ <span className="block text-[9px] text-gray-500 mt-0.5">Hide panel, session keeps running</span>
1646
2026
  </button>
1647
2027
  <button onClick={() => {
1648
2028
  setShowCloseDialog(false);
@@ -1651,9 +2031,11 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1651
2031
  }
1652
2032
  onClose(true);
1653
2033
  }}
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>
2034
+ 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'}`}>
2035
+ {persistentSession ? 'Restart Session' : 'Kill Session'}
2036
+ <span className={`block text-[9px] mt-0.5 ${persistentSession ? 'text-yellow-400/60' : 'text-red-400/60'}`}>
2037
+ {persistentSession ? 'Kill and restart with fresh env' : 'End session permanently'}
2038
+ </span>
1657
2039
  </button>
1658
2040
  </div>
1659
2041
  <button onClick={() => setShowCloseDialog(false)}
@@ -1746,6 +2128,8 @@ interface AgentNodeData {
1746
2128
  state: AgentState;
1747
2129
  colorIdx: number;
1748
2130
  previewLines: string[];
2131
+ projectPath: string;
2132
+ workspaceId: string | null;
1749
2133
  onRun: () => void;
1750
2134
  onPause: () => void;
1751
2135
  onStop: () => void;
@@ -1758,22 +2142,26 @@ interface AgentNodeData {
1758
2142
  onShowMemory: () => void;
1759
2143
  onShowInbox: () => void;
1760
2144
  onOpenTerminal: () => void;
2145
+ onSwitchSession: () => void;
1761
2146
  inboxPending?: number;
1762
2147
  inboxFailed?: number;
1763
2148
  [key: string]: unknown;
1764
2149
  }
1765
2150
 
2151
+ // PortalTerminal/NodeTerminal removed — xterm cannot render inside React Flow nodes
2152
+ // and createPortal causes event routing issues. Using FloatingTerminal instead.
2153
+
1766
2154
  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;
2155
+ const { config, state, colorIdx, previewLines, projectPath, workspaceId, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, onSwitchSession, inboxPending = 0, inboxFailed = 0 } = data;
1768
2156
  const c = COLORS[colorIdx % COLORS.length];
1769
2157
  const smithStatus = state?.smithStatus || 'down';
1770
2158
  const taskStatus = state?.taskStatus || 'idle';
1771
- const mode = state?.mode || 'auto';
2159
+ const hasTmux = !!state?.tmuxSession;
1772
2160
  const smithInfo = SMITH_STATUS[smithStatus] || SMITH_STATUS.down;
1773
2161
  const taskInfo = TASK_STATUS[taskStatus] || TASK_STATUS.idle;
1774
2162
  const currentStep = state?.currentStep;
1775
2163
  const step = currentStep !== undefined ? config.steps[currentStep] : undefined;
1776
- const isApprovalPending = taskStatus === 'idle' && smithStatus === 'active'; // approximation, actual check would use approvalQueue
2164
+ const isApprovalPending = taskStatus === 'idle' && smithStatus === 'active';
1777
2165
 
1778
2166
  return (
1779
2167
  <div className="w-52 flex flex-col rounded-lg select-none"
@@ -1782,6 +2170,9 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
1782
2170
  <Handle type="target" position={Position.Left} style={{ background: c.accent, width: 8, height: 8, border: 'none' }} />
1783
2171
  <Handle type="source" position={Position.Right} style={{ background: c.accent, width: 8, height: 8, border: 'none' }} />
1784
2172
 
2173
+ {/* Primary badge */}
2174
+ {config.primary && <div className="bg-[#f0883e]/20 text-[#f0883e] text-[7px] font-bold text-center py-0.5 rounded-t-lg">PRIMARY</div>}
2175
+
1785
2176
  {/* Header */}
1786
2177
  <div className="flex items-center gap-2 px-3 py-2">
1787
2178
  <span className="text-sm">{config.icon}</span>
@@ -1789,15 +2180,23 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
1789
2180
  <div className="text-xs font-semibold text-white truncate">{config.label}</div>
1790
2181
  <div className="text-[8px]" style={{ color: c.accent }}>{config.backend === 'api' ? config.provider || 'api' : config.agentId || 'cli'}</div>
1791
2182
  </div>
1792
- {/* Status: smith + mode + task */}
2183
+ {/* Status: smith + terminal + task */}
1793
2184
  <div className="flex flex-col items-end gap-0.5">
1794
2185
  <div className="flex items-center gap-1">
1795
2186
  <div className="w-1.5 h-1.5 rounded-full" style={{ background: smithInfo.color, boxShadow: smithInfo.glow ? `0 0 4px ${smithInfo.color}` : 'none' }} />
1796
2187
  <span className="text-[7px]" style={{ color: smithInfo.color }}>{smithInfo.label}</span>
1797
2188
  </div>
1798
2189
  <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>
2190
+ {(() => {
2191
+ // Execution mode is determined by config, not tmux state
2192
+ const isTerminalMode = config.persistentSession;
2193
+ const color = isTerminalMode ? (hasTmux ? '#3fb950' : '#f0883e') : '#484f58';
2194
+ const label = isTerminalMode ? (hasTmux ? 'terminal' : 'terminal (down)') : 'headless';
2195
+ return (<>
2196
+ <div className="w-1.5 h-1.5 rounded-full" style={{ background: color }} />
2197
+ <span className="text-[7px] font-medium" style={{ color }}>{label}</span>
2198
+ </>);
2199
+ })()}
1801
2200
  </div>
1802
2201
  <div className="flex items-center gap-1">
1803
2202
  <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 +2260,15 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
1861
2260
  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
2261
  )}
1863
2262
  <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
- )}
2263
+ <span className="flex items-center">
2264
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onOpenTerminal(); }}
2265
+ className={`text-[9px] px-1 ${hasTmux && taskStatus === 'running' ? 'text-green-400 animate-pulse' : 'text-gray-600 hover:text-green-400'}`}
2266
+ title="Open terminal">⌨️</button>
2267
+ {hasTmux && !config.primary && (
2268
+ <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onSwitchSession(); }}
2269
+ className="text-[10px] text-gray-600 hover:text-yellow-400 px-0.5 py-0.5" title="Switch session">▾</button>
2270
+ )}
2271
+ </span>
1868
2272
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowInbox(); }}
1869
2273
  className="text-[9px] text-gray-600 hover:text-orange-400 px-1" title="Messages (inbox/outbox)">📨</button>
1870
2274
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowMemory(); }}
@@ -1904,8 +2308,8 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
1904
2308
  const [memoryTarget, setMemoryTarget] = useState<{ id: string; label: string } | null>(null);
1905
2309
  const [inboxTarget, setInboxTarget] = useState<{ id: string; label: string } | null>(null);
1906
2310
  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> }[]>([]);
1908
- const [termLaunchDialog, setTermLaunchDialog] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; sessions: string[]; supportsSession?: boolean } | null>(null);
2311
+ 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; initialPos?: { x: number; y: number } }[]>([]);
2312
+ const [termLaunchDialog, setTermLaunchDialog] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; sessions: string[]; supportsSession?: boolean; initialPos?: { x: number; y: number } } | null>(null);
1909
2313
 
1910
2314
  // Expose focusAgent to parent
1911
2315
  useImperativeHandle(ref, () => ({
@@ -1938,27 +2342,8 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
1938
2342
  }
1939
2343
  });
1940
2344
 
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]);
2345
+ // Auto-open terminals removed persistent sessions run in background tmux.
2346
+ // User opens terminal via ⌨️ button when needed.
1962
2347
 
1963
2348
  // Rebuild nodes when agents/states/preview change — preserve existing positions + dimensions
1964
2349
  useEffect(() => {
@@ -1980,7 +2365,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
1980
2365
  type: 'input' as const,
1981
2366
  data: {
1982
2367
  config: agent,
1983
- state: states[agent.id] || { smithStatus: 'down', mode: 'auto', taskStatus: 'idle', artifacts: [] },
2368
+ state: states[agent.id] || { smithStatus: 'down', taskStatus: 'idle', artifacts: [] },
1984
2369
  onSubmit: (content: string) => {
1985
2370
  // Optimistic update
1986
2371
  wsApi(workspaceId!, 'complete_input', { agentId: agent.id, content });
@@ -2000,9 +2385,11 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2000
2385
  type: 'agent' as const,
2001
2386
  data: {
2002
2387
  config: agent,
2003
- state: states[agent.id] || { smithStatus: 'down', mode: 'auto', taskStatus: 'idle', artifacts: [] },
2388
+ state: states[agent.id] || { smithStatus: 'down', taskStatus: 'idle', artifacts: [] },
2004
2389
  colorIdx: i,
2005
2390
  previewLines: logPreview[agent.id] || [],
2391
+ projectPath,
2392
+ workspaceId,
2006
2393
  onRun: () => {
2007
2394
  wsApi(workspaceId!, 'run', { agentId: agent.id });
2008
2395
  },
@@ -2023,36 +2410,91 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2023
2410
  inboxFailed: busLog.filter(m => m.to === agent.id && m.status === 'failed' && m.type !== 'ack').length,
2024
2411
  onOpenTerminal: async () => {
2025
2412
  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;
2413
+ if (!daemonActiveFromStream) {
2414
+ alert('Start daemon first before opening terminal.');
2415
+ return;
2416
+ }
2417
+ // Close existing terminal (config may have changed)
2418
+ setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
2419
+
2420
+ // Get node screen position for initial terminal placement
2421
+ const nodeEl = document.querySelector(`[data-id="${agent.id}"]`);
2422
+ const nodeRect = nodeEl?.getBoundingClientRect();
2423
+ const initialPos = nodeRect
2424
+ ? { x: nodeRect.left, y: nodeRect.bottom + 4 }
2425
+ : { x: 80, y: 60 };
2030
2426
 
2031
2427
  const agentState = states[agent.id];
2032
2428
  const existingTmux = agentState?.tmuxSession;
2033
-
2034
- // Build fixed session name: mw-forge-{project}-{agentLabel}
2035
2429
  const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
2036
2430
  const sessName = `mw-forge-${safeName(projectName)}-${safeName(agent.label)}`;
2037
- const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.'
2038
- ? agent.workDir : undefined;
2431
+ const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.' ? agent.workDir : undefined;
2432
+
2433
+ // Always resolve launch info for this agent (cliCmd, env, model)
2434
+ const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
2435
+ const launchInfo = {
2436
+ cliCmd: resolveRes?.cliCmd || 'claude',
2437
+ cliType: resolveRes?.cliType || 'claude-code',
2438
+ profileEnv: {
2439
+ ...(resolveRes?.env || {}),
2440
+ ...(resolveRes?.model ? { CLAUDE_MODEL: resolveRes.model } : {}),
2441
+ FORGE_AGENT_ID: agent.id,
2442
+ FORGE_WORKSPACE_ID: workspaceId!,
2443
+ FORGE_PORT: String(window.location.port || 8403),
2444
+ },
2445
+ };
2039
2446
 
2040
- // If already manual with a tmux session, just reopen (attach)
2041
- if (agentState?.mode === 'manual' && existingTmux) {
2447
+ // If tmux session exists attach (primary or non-primary)
2448
+ if (existingTmux) {
2449
+ wsApi(workspaceId, 'open_terminal', { agentId: agent.id });
2042
2450
  setFloatingTerminals(prev => [...prev, {
2043
2451
  agentId: agent.id, label: agent.label, icon: agent.icon,
2044
- cliId: agent.agentId || 'claude', workDir,
2452
+ cliId: agent.agentId || 'claude', ...launchInfo, workDir,
2045
2453
  tmuxSession: existingTmux, sessionName: sessName,
2454
+ isPrimary: agent.primary, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2046
2455
  }]);
2047
2456
  return;
2048
2457
  }
2049
2458
 
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;
2459
+ // Primary without session open directly (no dialog)
2460
+ if (agent.primary) {
2461
+ const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id }).catch(() => ({})) as any;
2462
+ setFloatingTerminals(prev => [...prev, {
2463
+ agentId: agent.id, label: agent.label, icon: agent.icon,
2464
+ cliId: agent.agentId || 'claude', ...launchInfo, workDir,
2465
+ tmuxSession: res?.tmuxSession || sessName, sessionName: sessName,
2466
+ isPrimary: true, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2467
+ }]);
2468
+ return;
2469
+ }
2053
2470
 
2054
- // Show launch dialog with resolved info
2055
- setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession });
2471
+ // Non-primary: has boundSessionId use it directly; no bound → show dialog
2472
+ if (agent.boundSessionId) {
2473
+ const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id }).catch(() => ({})) as any;
2474
+ setFloatingTerminals(prev => [...prev, {
2475
+ agentId: agent.id, label: agent.label, icon: agent.icon,
2476
+ cliId: agent.agentId || 'claude', ...launchInfo, workDir,
2477
+ tmuxSession: res?.tmuxSession || sessName, sessionName: sessName,
2478
+ resumeSessionId: agent.boundSessionId,
2479
+ isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2480
+ }]);
2481
+ return;
2482
+ }
2483
+ // No bound session → show launch dialog (New / Resume / Select)
2484
+ setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true, initialPos });
2485
+ },
2486
+ onSwitchSession: async () => {
2487
+ if (!workspaceId) return;
2488
+ setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
2489
+ if (agent.id) wsApi(workspaceId, 'close_terminal', { agentId: agent.id });
2490
+ const nodeEl = document.querySelector(`[data-id="${agent.id}"]`);
2491
+ const nodeRect = nodeEl?.getBoundingClientRect();
2492
+ const switchPos = nodeRect ? { x: nodeRect.left, y: nodeRect.bottom + 4 } : { x: 80, y: 60 };
2493
+ const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
2494
+ const sessName = `mw-forge-${safeName(projectName)}-${safeName(agent.label)}`;
2495
+ const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.' ? agent.workDir : undefined;
2496
+ const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
2497
+ setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true, initialPos: switchPos });
2056
2498
  },
2057
2499
  } satisfies AgentNodeData,
2058
2500
  };
@@ -2244,8 +2686,22 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2244
2686
  {agents.length === 0 ? (
2245
2687
  <div className="flex-1 flex flex-col items-center justify-center gap-3">
2246
2688
  <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">
2689
+ <div className="text-sm text-gray-400">Set up your workspace</div>
2690
+ {/* Primary agent prompt */}
2691
+ <button onClick={() => setModal({ mode: 'add', initial: {
2692
+ label: 'Engineer', icon: '👨‍💻', primary: true, persistentSession: true,
2693
+ role: 'Primary engineer — handles coding tasks in the project root.',
2694
+ backend: 'cli' as const, agentId: 'claude', workDir: './', dependsOn: [], outputs: [], steps: [],
2695
+ }})}
2696
+ 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">
2697
+ <span className="text-2xl">👨‍💻</span>
2698
+ <div className="text-left">
2699
+ <div className="text-[11px] font-semibold text-[#f0883e]">Add Primary Agent</div>
2700
+ <div className="text-[9px] text-gray-500">Terminal-only, root directory, fixed session</div>
2701
+ </div>
2702
+ </button>
2703
+ <div className="text-[9px] text-gray-600 mt-1">or add other agents:</div>
2704
+ <div className="flex gap-2 flex-wrap justify-center">
2249
2705
  {PRESET_AGENTS.map((p, i) => (
2250
2706
  <button key={i} onClick={() => setModal({ mode: 'add', initial: p })}
2251
2707
  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,11 +2729,40 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2273
2729
  </div>
2274
2730
  </div>
2275
2731
  ) : (
2276
- <div className="flex-1 min-h-0">
2732
+ <div className="flex-1 min-h-0 flex flex-col">
2733
+ {/* No primary agent hint */}
2734
+ {!agents.some(a => a.primary) && (
2735
+ <div className="flex items-center gap-2 px-3 py-1.5 bg-[#f0883e]/10 border-b border-[#f0883e]/20 shrink-0">
2736
+ <span className="text-[10px] text-[#f0883e]">No primary agent set.</span>
2737
+ <button onClick={() => setModal({ mode: 'add', initial: {
2738
+ label: 'Engineer', icon: '👨‍💻', primary: true, persistentSession: true,
2739
+ role: 'Primary engineer — handles coding tasks in the project root.',
2740
+ backend: 'cli' as const, agentId: 'claude', workDir: './', dependsOn: [], outputs: [], steps: [],
2741
+ }})}
2742
+ className="text-[10px] text-[#f0883e] underline hover:text-white">Add one</button>
2743
+ <span className="text-[9px] text-gray-600">or edit an existing agent to set as primary.</span>
2744
+ </div>
2745
+ )}
2277
2746
  <ReactFlow
2278
2747
  nodes={rfNodes}
2279
2748
  edges={rfEdges}
2280
2749
  onNodesChange={onNodesChange}
2750
+ onNodeDragStop={() => {
2751
+ // Reposition terminals to follow their nodes
2752
+ setFloatingTerminals(prev => prev.map(ft => {
2753
+ const nodeEl = document.querySelector(`[data-id="${ft.agentId}"]`);
2754
+ const rect = nodeEl?.getBoundingClientRect();
2755
+ return rect ? { ...ft, initialPos: { x: rect.left, y: rect.bottom + 4 } } : ft;
2756
+ }));
2757
+ }}
2758
+ onMoveEnd={() => {
2759
+ // Reposition after pan/zoom
2760
+ setFloatingTerminals(prev => prev.map(ft => {
2761
+ const nodeEl = document.querySelector(`[data-id="${ft.agentId}"]`);
2762
+ const rect = nodeEl?.getBoundingClientRect();
2763
+ return rect ? { ...ft, initialPos: { x: rect.left, y: rect.bottom + 4 } } : ft;
2764
+ }));
2765
+ }}
2281
2766
  nodeTypes={nodeTypes}
2282
2767
  fitView
2283
2768
  fitViewOptions={{ padding: 0.3 }}
@@ -2378,13 +2863,17 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2378
2863
  setTermLaunchDialog(null);
2379
2864
  const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id });
2380
2865
  if (res.ok) {
2866
+ // Save selected session as boundSessionId if user chose a specific one
2867
+ if (sessionId) {
2868
+ wsApi(workspaceId, 'update', { agentId: agent.id, config: { ...agent, boundSessionId: sessionId } }).catch(() => {});
2869
+ }
2381
2870
  setFloatingTerminals(prev => [...prev, {
2382
2871
  agentId: agent.id, label: agent.label, icon: agent.icon,
2383
2872
  cliId: agent.agentId || 'claude',
2384
2873
  cliCmd: res.cliCmd || 'claude',
2385
2874
  cliType: res.cliType || 'claude-code',
2386
2875
  workDir,
2387
- sessionName: sessName, resumeMode, resumeSessionId: sessionId,
2876
+ sessionName: sessName, resumeMode, resumeSessionId: sessionId, isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: sessionId || agent.boundSessionId, initialPos: termLaunchDialog.initialPos,
2388
2877
  profileEnv: {
2389
2878
  ...(res.env || {}),
2390
2879
  ...(res.model ? { CLAUDE_MODEL: res.model } : {}),
@@ -2399,7 +2888,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2399
2888
  />
2400
2889
  )}
2401
2890
 
2402
- {/* Floating terminals for manual agents */}
2891
+ {/* Floating terminals positioned near their agent node */}
2403
2892
  {floatingTerminals.map(ft => (
2404
2893
  <FloatingTerminal
2405
2894
  key={ft.agentId}
@@ -2415,17 +2904,18 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2415
2904
  resumeMode={ft.resumeMode}
2416
2905
  resumeSessionId={ft.resumeSessionId}
2417
2906
  profileEnv={ft.profileEnv}
2907
+ isPrimary={ft.isPrimary}
2908
+ skipPermissions={ft.skipPermissions}
2909
+ persistentSession={ft.persistentSession}
2910
+ boundSessionId={ft.boundSessionId}
2911
+ initialPos={ft.initialPos}
2418
2912
  onSessionReady={(name) => {
2419
- if (workspaceId) {
2420
- wsApi(workspaceId, 'set_tmux_session', { agentId: ft.agentId, sessionName: name });
2421
- }
2913
+ if (workspaceId) wsApi(workspaceId, 'set_tmux_session', { agentId: ft.agentId, sessionName: name });
2422
2914
  setFloatingTerminals(prev => prev.map(t => t.agentId === ft.agentId ? { ...t, tmuxSession: name } : t));
2423
2915
  }}
2424
- onClose={() => {
2916
+ onClose={(killSession) => {
2425
2917
  setFloatingTerminals(prev => prev.filter(t => t.agentId !== ft.agentId));
2426
- if (workspaceId) {
2427
- wsApi(workspaceId, 'close_terminal', { agentId: ft.agentId });
2428
- }
2918
+ if (workspaceId) wsApi(workspaceId, 'close_terminal', { agentId: ft.agentId, kill: killSession });
2429
2919
  }}
2430
2920
  />
2431
2921
  ))}