@aion0/forge 0.5.6 → 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;
25
- watch?: { enabled: boolean; interval: number; targets: any[]; action?: 'log' | 'analyze' | 'approve'; prompt?: string };
26
+ persistentSession?: boolean;
27
+ skipPermissions?: boolean;
28
+ boundSessionId?: string;
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 || [];
@@ -322,6 +328,109 @@ function useWorkspaceStream(workspaceId: string | null, onEvent?: (event: any) =
322
328
  return { agents, states, logPreview, busLog, setAgents, daemonActive, setDaemonActive };
323
329
  }
324
330
 
331
+ // ─── Session Target Selector (for Watch) ─────────────────
332
+
333
+ function SessionTargetSelector({ target, agents, projectPath, onChange }: {
334
+ target: { type: string; path?: string; pattern?: string; cmd?: string };
335
+ agents: AgentConfig[];
336
+ projectPath?: string;
337
+ onChange: (updated: typeof target) => void;
338
+ }) {
339
+ const [sessions, setSessions] = useState<{ id: string; modified: string; label: string }[]>([]);
340
+
341
+ // Load sessions and mark fixed session
342
+ useEffect(() => {
343
+ if (!projectPath) return;
344
+ const pName = (projectPath || '').replace(/\/+$/, '').split('/').pop() || '';
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
+ });
359
+ }, [projectPath]);
360
+
361
+ return (
362
+ <>
363
+ <select value={target.path || ''} onChange={e => onChange({ ...target, path: e.target.value, cmd: '' })}
364
+ className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white w-24">
365
+ <option value="">Any agent</option>
366
+ {agents.map(a => <option key={a.id} value={a.id}>{a.icon} {a.label}</option>)}
367
+ </select>
368
+ <select value={target.cmd || ''} onChange={e => onChange({ ...target, cmd: e.target.value })}
369
+ className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white w-28">
370
+ <option value="">Latest session</option>
371
+ {sessions.map(s => (
372
+ <option key={s.id} value={s.id}>{s.label}{s.modified ? ` · ${new Date(s.modified).toLocaleDateString()}` : ''}</option>
373
+ ))}
374
+ </select>
375
+ <input value={target.pattern || ''} onChange={e => onChange({ ...target, pattern: e.target.value })}
376
+ placeholder="regex (optional)"
377
+ className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white w-24" />
378
+ </>
379
+ );
380
+ }
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
+
325
434
  // ─── Agent Config Modal ──────────────────────────────────
326
435
 
327
436
  function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfirm, onCancel }: {
@@ -356,11 +465,17 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
356
465
  (initial.steps || []).map(s => `${s.label}: ${s.prompt}`).join('\n') || ''
357
466
  );
358
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);
359
472
  const [watchEnabled, setWatchEnabled] = useState(initial.watch?.enabled || false);
360
473
  const [watchInterval, setWatchInterval] = useState(String(initial.watch?.interval || 60));
361
- const [watchAction, setWatchAction] = useState<'log' | 'analyze' | 'approve'>(initial.watch?.action || 'log');
474
+ const [watchAction, setWatchAction] = useState<'log' | 'analyze' | 'approve' | 'send_message'>(initial.watch?.action || 'log');
362
475
  const [watchPrompt, setWatchPrompt] = useState(initial.watch?.prompt || '');
363
- const [watchTargets, setWatchTargets] = useState<{ type: string; path?: string; cmd?: string }[]>(
476
+ const [watchSendTo, setWatchSendTo] = useState(initial.watch?.sendTo || '');
477
+ const [watchDebounce, setWatchDebounce] = useState(String(initial.watch?.targets?.[0]?.debounce ?? 10));
478
+ const [watchTargets, setWatchTargets] = useState<{ type: string; path?: string; cmd?: string; pattern?: string }[]>(
364
479
  initial.watch?.targets || []
365
480
  );
366
481
  const [projectDirs, setProjectDirs] = useState<string[]>([]);
@@ -529,8 +644,9 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
529
644
  <div className="flex gap-2">
530
645
  <div className="flex flex-col gap-1 w-28">
531
646
  <label className="text-[9px] text-gray-500 uppercase">Work Dir</label>
532
- <input value={workDirVal} onChange={e => setWorkDirVal(e.target.value)} placeholder={label ? `${label.toLowerCase().replace(/\s+/g, '-')}/` : 'engineer/'}
533
- 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' : ''}`} />
534
650
  <div className="text-[8px] text-gray-600 mt-0.5">
535
651
  → {'{project}/'}{(workDirVal || (label ? `${label.toLowerCase().replace(/\s+/g, '-')}/` : '')).replace(/^\.?\//, '')}
536
652
  </div>
@@ -542,6 +658,22 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
542
658
  </div>
543
659
  </div>
544
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
+
545
677
  {/* Requires Approval */}
546
678
  <div className="flex items-center gap-2">
547
679
  <input type="checkbox" id="requiresApproval" checked={requiresApproval} onChange={e => setRequiresApproval(e.target.checked)}
@@ -549,6 +681,25 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
549
681
  <label htmlFor="requiresApproval" className="text-[9px] text-gray-400">Require approval before processing inbox messages</label>
550
682
  </div>
551
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
+
552
703
  {/* Steps */}
553
704
  <div className="flex flex-col gap-1">
554
705
  <label className="text-[9px] text-gray-500 uppercase">Steps (one per line — Label: Prompt)</label>
@@ -567,10 +718,15 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
567
718
  </div>
568
719
  {watchEnabled && (<>
569
720
  <div className="flex gap-2">
570
- <div className="flex flex-col gap-0.5 flex-1">
571
- <label className="text-[8px] text-gray-600">Interval (seconds)</label>
721
+ <div className="flex flex-col gap-0.5">
722
+ <label className="text-[8px] text-gray-600">Interval (s)</label>
572
723
  <input value={watchInterval} onChange={e => setWatchInterval(e.target.value)} type="number" min="10"
573
- className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] w-20" />
724
+ className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] w-16" />
725
+ </div>
726
+ <div className="flex flex-col gap-0.5">
727
+ <label className="text-[8px] text-gray-600">Debounce (s)</label>
728
+ <input value={watchDebounce} onChange={e => setWatchDebounce(e.target.value)} type="number" min="0"
729
+ className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff] w-16" />
574
730
  </div>
575
731
  <div className="flex flex-col gap-0.5 flex-1">
576
732
  <label className="text-[8px] text-gray-600">On Change</label>
@@ -579,8 +735,21 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
579
735
  <option value="log">Log only</option>
580
736
  <option value="analyze">Auto analyze</option>
581
737
  <option value="approve">Require approval</option>
738
+ <option value="send_message">Send to agent</option>
582
739
  </select>
583
740
  </div>
741
+ {watchAction === 'send_message' && (
742
+ <div className="flex flex-col gap-0.5 flex-1">
743
+ <label className="text-[8px] text-gray-600">Send to</label>
744
+ <select value={watchSendTo} onChange={e => setWatchSendTo(e.target.value)}
745
+ className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff]">
746
+ <option value="">Select agent...</option>
747
+ {existingAgents.filter(a => a.id !== initial.id).map(a =>
748
+ <option key={a.id} value={a.id}>{a.icon} {a.label}</option>
749
+ )}
750
+ </select>
751
+ </div>
752
+ )}
584
753
  </div>
585
754
  <div className="flex flex-col gap-1">
586
755
  <label className="text-[8px] text-gray-600">Targets</label>
@@ -594,6 +763,8 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
594
763
  <option value="directory">Directory</option>
595
764
  <option value="git">Git</option>
596
765
  <option value="agent_output">Agent Output</option>
766
+ <option value="agent_log">Agent Log</option>
767
+ <option value="session">Session Output</option>
597
768
  <option value="command">Command</option>
598
769
  </select>
599
770
  {t.type === 'directory' && (
@@ -618,6 +789,36 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
618
789
  )}
619
790
  </select>
620
791
  )}
792
+ {t.type === 'agent_log' && (<>
793
+ <select value={t.path || ''} onChange={e => {
794
+ const next = [...watchTargets];
795
+ next[i] = { ...t, path: e.target.value };
796
+ setWatchTargets(next);
797
+ }} className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white flex-1">
798
+ <option value="">Select agent...</option>
799
+ {existingAgents.filter(a => a.id !== initial.id).map(a =>
800
+ <option key={a.id} value={a.id}>{a.icon} {a.label}</option>
801
+ )}
802
+ </select>
803
+ <input value={t.pattern || ''} onChange={e => {
804
+ const next = [...watchTargets];
805
+ next[i] = { ...t, pattern: e.target.value };
806
+ setWatchTargets(next);
807
+ }} placeholder="keyword (optional)"
808
+ className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white w-24" />
809
+ </>)}
810
+ {t.type === 'session' && (
811
+ <SessionTargetSelector
812
+ target={t}
813
+ agents={existingAgents.filter(a => a.id !== initial.id && (!a.agentId || a.agentId === 'claude'))}
814
+ projectPath={projectPath}
815
+ onChange={(updated) => {
816
+ const next = [...watchTargets];
817
+ next[i] = updated;
818
+ setWatchTargets(next);
819
+ }}
820
+ />
821
+ )}
621
822
  {t.type === 'command' && (
622
823
  <input value={t.cmd || ''} onChange={e => {
623
824
  const next = [...watchTargets];
@@ -641,6 +842,14 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
641
842
  className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff]" />
642
843
  </div>
643
844
  )}
845
+ {watchAction === 'send_message' && (
846
+ <div className="flex flex-col gap-0.5">
847
+ <label className="text-[8px] text-gray-600">Message context (sent with detected changes)</label>
848
+ <input value={watchPrompt} onChange={e => setWatchPrompt(e.target.value)}
849
+ placeholder="Review the following changes and report issues..."
850
+ className="text-xs bg-[#161b22] border border-[#30363d] rounded px-2 py-1 text-white focus:outline-none focus:border-[#58a6ff]" />
851
+ </div>
852
+ )}
644
853
  </>)}
645
854
  </div>
646
855
  </div>
@@ -651,16 +860,20 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
651
860
  onConfirm({
652
861
  label: label.trim(), icon: icon.trim() || '🤖', role: role.trim(),
653
862
  backend, agentId, dependsOn: Array.from(selectedDeps),
654
- workDir: workDirVal.trim() || label.trim().toLowerCase().replace(/\s+/g, '-') + '/',
863
+ workDir: isPrimary ? './' : (workDirVal.trim() || label.trim().toLowerCase().replace(/\s+/g, '-') + '/'),
655
864
  outputs: outputs.split(',').map(s => s.trim()).filter(Boolean),
656
865
  steps: parseSteps(),
866
+ primary: isPrimary || undefined,
657
867
  requiresApproval: requiresApproval || undefined,
868
+ persistentSession: isPrimary ? true : (persistentSession || undefined),
869
+ skipPermissions: persistentSession ? (skipPermissions ? undefined : false) : undefined,
658
870
  watch: watchEnabled && watchTargets.length > 0 ? {
659
871
  enabled: true,
660
872
  interval: Math.max(10, parseInt(watchInterval) || 60),
661
- targets: watchTargets,
873
+ targets: watchTargets.map(t => ({ ...t, debounce: parseInt(watchDebounce) || 10 })),
662
874
  action: watchAction,
663
875
  prompt: watchPrompt || undefined,
876
+ sendTo: watchSendTo || undefined,
664
877
  } : undefined,
665
878
  } as any);
666
879
  }} className="text-xs px-3 py-1.5 rounded bg-[#238636] text-white hover:bg-[#2ea043] disabled:opacity-40">
@@ -1224,6 +1437,48 @@ function BusPanel({ busLog, agents, onClose }: {
1224
1437
 
1225
1438
  // ─── Terminal Launch Dialog ───────────────────────────────
1226
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
+
1227
1482
  function TerminalLaunchDialog({ agent, workDir, sessName, projectPath, workspaceId, supportsSession, onLaunch, onCancel }: {
1228
1483
  agent: AgentConfig; workDir?: string; sessName: string; projectPath: string; workspaceId: string;
1229
1484
  supportsSession?: boolean;
@@ -1239,7 +1494,7 @@ function TerminalLaunchDialog({ agent, workDir, sessName, projectPath, workspace
1239
1494
  if (!isClaude) return;
1240
1495
  fetch(`/api/workspace/${workspaceId}/smith`, {
1241
1496
  method: 'POST', headers: { 'Content-Type': 'application/json' },
1242
- body: JSON.stringify({ action: 'sessions' }),
1497
+ body: JSON.stringify({ action: 'sessions', agentId: agent.id }),
1243
1498
  }).then(r => r.json()).then(d => {
1244
1499
  if (d.sessions?.length) setSessions(d.sessions);
1245
1500
  }).catch(() => {});
@@ -1286,19 +1541,13 @@ function TerminalLaunchDialog({ agent, workDir, sessName, projectPath, workspace
1286
1541
  {isClaude && sessions.length > 1 && (
1287
1542
  <button onClick={() => setShowSessions(!showSessions)}
1288
1543
  className="w-full text-[9px] text-gray-500 hover:text-white py-1">
1289
- {showSessions ? '▼' : '▶'} More sessions ({sessions.length - 1})
1544
+ {showSessions ? '▼' : '▶'} All sessions ({sessions.length})
1290
1545
  </button>
1291
1546
  )}
1292
1547
 
1293
- {showSessions && sessions.slice(1).map(s => (
1294
- <button key={s.id} onClick={() => onLaunch(true, s.id)}
1295
- className="w-full text-left px-3 py-1.5 rounded border border-[#21262d] hover:border-[#30363d] hover:bg-[#161b22] transition-colors">
1296
- <div className="flex items-center gap-2">
1297
- <span className="text-[9px] text-gray-400 font-mono">{s.id.slice(0, 8)}</span>
1298
- <span className="text-[8px] text-gray-600">{formatTime(s.modified)}</span>
1299
- <span className="text-[8px] text-gray-600">{formatSize(s.size)}</span>
1300
- </div>
1301
- </button>
1548
+ {showSessions && sessions.map(s => (
1549
+ <SessionItem key={s.id} session={s} formatTime={formatTime} formatSize={formatSize}
1550
+ onSelect={() => onLaunch(true, s.id)} />
1302
1551
  ))}
1303
1552
  </div>
1304
1553
 
@@ -1320,7 +1569,217 @@ function getWsUrl() {
1320
1569
  return `${p}//${h}:${port + 1}`;
1321
1570
  }
1322
1571
 
1323
- 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 }: {
1324
1783
  agentLabel: string;
1325
1784
  agentIcon: string;
1326
1785
  projectPath: string;
@@ -1333,6 +1792,10 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1333
1792
  resumeMode?: boolean;
1334
1793
  resumeSessionId?: string;
1335
1794
  profileEnv?: Record<string, string>;
1795
+ isPrimary?: boolean;
1796
+ skipPermissions?: boolean;
1797
+ persistentSession?: boolean;
1798
+ boundSessionId?: string;
1336
1799
  onSessionReady?: (name: string) => void;
1337
1800
  onClose: (killSession: boolean) => void;
1338
1801
  }) {
@@ -1391,7 +1854,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1391
1854
  };
1392
1855
 
1393
1856
  let launched = false;
1394
- ws.onmessage = (event) => {
1857
+ ws.onmessage = async (event) => {
1395
1858
  if (disposed) return;
1396
1859
  try {
1397
1860
  const msg = JSON.parse(event.data);
@@ -1433,18 +1896,30 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1433
1896
 
1434
1897
  const cdCmd = `mkdir -p "${targetDir}" && cd "${targetDir}"`;
1435
1898
  const isClaude = (cliType || 'claude-code') === 'claude-code';
1436
- const resumeFlag = isClaude
1437
- ? (resumeSessionId ? ` --resume ${resumeSessionId}` : resumeMode ? ' -c' : '')
1438
- : '';
1439
1899
  const modelFlag = isClaude && profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
1440
- // Remove CLAUDE_MODEL from env exports (passed via --model flag instead)
1441
1900
  const envWithoutModel = profileEnv ? Object.fromEntries(
1442
1901
  Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
1443
1902
  ) : {};
1444
- 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
1445
1907
  ? Object.entries(envWithoutModel).map(([k, v]) => `export ${k}="${v}"`).join(' && ') + ' && '
1446
- : '';
1447
- 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`;
1448
1923
  setTimeout(() => {
1449
1924
  if (!disposed && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: cmd }));
1450
1925
  }, 300);
@@ -1532,7 +2007,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1532
2007
  <button onClick={() => { setShowCloseDialog(false); onClose(false); }}
1533
2008
  className="flex-1 px-3 py-1.5 text-[11px] rounded bg-[#2a2a4a] text-gray-300 hover:bg-[#3a3a5a] hover:text-white">
1534
2009
  Suspend
1535
- <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>
1536
2011
  </button>
1537
2012
  <button onClick={() => {
1538
2013
  setShowCloseDialog(false);
@@ -1541,9 +2016,11 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1541
2016
  }
1542
2017
  onClose(true);
1543
2018
  }}
1544
- className="flex-1 px-3 py-1.5 text-[11px] rounded bg-red-500/20 text-red-400 hover:bg-red-500/30">
1545
- Kill Session
1546
- <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>
1547
2024
  </button>
1548
2025
  </div>
1549
2026
  <button onClick={() => setShowCloseDialog(false)}
@@ -1648,17 +2125,18 @@ interface AgentNodeData {
1648
2125
  onShowMemory: () => void;
1649
2126
  onShowInbox: () => void;
1650
2127
  onOpenTerminal: () => void;
2128
+ onSwitchSession: () => void;
1651
2129
  inboxPending?: number;
1652
2130
  inboxFailed?: number;
1653
2131
  [key: string]: unknown;
1654
2132
  }
1655
2133
 
1656
2134
  function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
1657
- 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;
1658
2136
  const c = COLORS[colorIdx % COLORS.length];
1659
2137
  const smithStatus = state?.smithStatus || 'down';
1660
2138
  const taskStatus = state?.taskStatus || 'idle';
1661
- const mode = state?.mode || 'auto';
2139
+ const hasTmux = !!state?.tmuxSession;
1662
2140
  const smithInfo = SMITH_STATUS[smithStatus] || SMITH_STATUS.down;
1663
2141
  const taskInfo = TASK_STATUS[taskStatus] || TASK_STATUS.idle;
1664
2142
  const currentStep = state?.currentStep;
@@ -1672,6 +2150,9 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
1672
2150
  <Handle type="target" position={Position.Left} style={{ background: c.accent, width: 8, height: 8, border: 'none' }} />
1673
2151
  <Handle type="source" position={Position.Right} style={{ background: c.accent, width: 8, height: 8, border: 'none' }} />
1674
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
+
1675
2156
  {/* Header */}
1676
2157
  <div className="flex items-center gap-2 px-3 py-2">
1677
2158
  <span className="text-sm">{config.icon}</span>
@@ -1679,15 +2160,23 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
1679
2160
  <div className="text-xs font-semibold text-white truncate">{config.label}</div>
1680
2161
  <div className="text-[8px]" style={{ color: c.accent }}>{config.backend === 'api' ? config.provider || 'api' : config.agentId || 'cli'}</div>
1681
2162
  </div>
1682
- {/* Status: smith + mode + task */}
2163
+ {/* Status: smith + terminal + task */}
1683
2164
  <div className="flex flex-col items-end gap-0.5">
1684
2165
  <div className="flex items-center gap-1">
1685
2166
  <div className="w-1.5 h-1.5 rounded-full" style={{ background: smithInfo.color, boxShadow: smithInfo.glow ? `0 0 4px ${smithInfo.color}` : 'none' }} />
1686
2167
  <span className="text-[7px]" style={{ color: smithInfo.color }}>{smithInfo.label}</span>
1687
2168
  </div>
1688
2169
  <div className="flex items-center gap-1">
1689
- <div className="w-1.5 h-1.5 rounded-full" style={{ background: mode === 'manual' ? '#d2a8ff' : '#30363d' }} />
1690
- <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
+ })()}
1691
2180
  </div>
1692
2181
  <div className="flex items-center gap-1">
1693
2182
  <div className="w-1.5 h-1.5 rounded-full" style={{ background: taskInfo.color, boxShadow: taskInfo.glow ? `0 0 4px ${taskInfo.color}` : 'none' }} />
@@ -1751,10 +2240,14 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
1751
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>
1752
2241
  )}
1753
2242
  <div className="flex-1" />
1754
- {taskStatus !== 'running' && (
1755
- <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onOpenTerminal(); }}
1756
- className="text-[9px] text-gray-600 hover:text-green-400 px-1" title="Open terminal (manual mode)">⌨️</button>
1757
- )}
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>
1758
2251
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowInbox(); }}
1759
2252
  className="text-[9px] text-gray-600 hover:text-orange-400 px-1" title="Messages (inbox/outbox)">📨</button>
1760
2253
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowMemory(); }}
@@ -1794,7 +2287,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
1794
2287
  const [memoryTarget, setMemoryTarget] = useState<{ id: string; label: string } | null>(null);
1795
2288
  const [inboxTarget, setInboxTarget] = useState<{ id: string; label: string } | null>(null);
1796
2289
  const [showBusPanel, setShowBusPanel] = useState(false);
1797
- 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 }[]>([]);
1798
2291
  const [termLaunchDialog, setTermLaunchDialog] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; sessions: string[]; supportsSession?: boolean } | null>(null);
1799
2292
 
1800
2293
  // Expose focusAgent to parent
@@ -1828,27 +2321,8 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
1828
2321
  }
1829
2322
  });
1830
2323
 
1831
- // Auto-open floating terminals for manual agents on page load
1832
- const autoOpenDone = useRef(false);
1833
- useEffect(() => {
1834
- if (autoOpenDone.current || agents.length === 0 || Object.keys(states).length === 0) return;
1835
- autoOpenDone.current = true;
1836
- const manualAgents = agents.filter(a =>
1837
- a.type !== 'input' && states[a.id]?.mode === 'manual' && states[a.id]?.tmuxSession
1838
- );
1839
- if (manualAgents.length > 0) {
1840
- const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
1841
- setFloatingTerminals(manualAgents.map(a => ({
1842
- agentId: a.id,
1843
- label: a.label,
1844
- icon: a.icon,
1845
- cliId: a.agentId || 'claude',
1846
- workDir: a.workDir && a.workDir !== './' && a.workDir !== '.' ? a.workDir : undefined,
1847
- tmuxSession: states[a.id].tmuxSession,
1848
- sessionName: `mw-forge-${safeName(projectName)}-${safeName(a.label)}`,
1849
- })));
1850
- }
1851
- }, [agents, states]);
2324
+ // Auto-open terminals removed persistent sessions run in background tmux.
2325
+ // User opens terminal via ⌨️ button when needed.
1852
2326
 
1853
2327
  // Rebuild nodes when agents/states/preview change — preserve existing positions + dimensions
1854
2328
  useEffect(() => {
@@ -1870,7 +2344,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
1870
2344
  type: 'input' as const,
1871
2345
  data: {
1872
2346
  config: agent,
1873
- state: states[agent.id] || { smithStatus: 'down', mode: 'auto', taskStatus: 'idle', artifacts: [] },
2347
+ state: states[agent.id] || { smithStatus: 'down', taskStatus: 'idle', artifacts: [] },
1874
2348
  onSubmit: (content: string) => {
1875
2349
  // Optimistic update
1876
2350
  wsApi(workspaceId!, 'complete_input', { agentId: agent.id, content });
@@ -1890,7 +2364,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
1890
2364
  type: 'agent' as const,
1891
2365
  data: {
1892
2366
  config: agent,
1893
- state: states[agent.id] || { smithStatus: 'down', mode: 'auto', taskStatus: 'idle', artifacts: [] },
2367
+ state: states[agent.id] || { smithStatus: 'down', taskStatus: 'idle', artifacts: [] },
1894
2368
  colorIdx: i,
1895
2369
  previewLines: logPreview[agent.id] || [],
1896
2370
  onRun: () => {
@@ -1913,36 +2387,83 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
1913
2387
  inboxFailed: busLog.filter(m => m.to === agent.id && m.status === 'failed' && m.type !== 'ack').length,
1914
2388
  onOpenTerminal: async () => {
1915
2389
  if (!workspaceId) return;
1916
- // Use current state via setState callback to avoid stale closure
1917
- let alreadyOpen = false;
1918
- setFloatingTerminals(prev => { alreadyOpen = prev.some(t => t.agentId === agent.id); return prev; });
1919
- 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));
1920
2396
 
1921
2397
  const agentState = states[agent.id];
1922
2398
  const existingTmux = agentState?.tmuxSession;
1923
-
1924
- // Build fixed session name: mw-forge-{project}-{agentLabel}
1925
2399
  const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
1926
2400
  const sessName = `mw-forge-${safeName(projectName)}-${safeName(agent.label)}`;
1927
- const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.'
1928
- ? 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
+ };
1929
2416
 
1930
- // If already manual with a tmux session, just reopen (attach)
1931
- 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 });
1932
2420
  setFloatingTerminals(prev => [...prev, {
1933
2421
  agentId: agent.id, label: agent.label, icon: agent.icon,
1934
- cliId: agent.agentId || 'claude', workDir,
2422
+ cliId: agent.agentId || 'claude', ...launchInfo, workDir,
1935
2423
  tmuxSession: existingTmux, sessionName: sessName,
2424
+ isPrimary: agent.primary, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId,
1936
2425
  }]);
1937
2426
  return;
1938
2427
  }
1939
2428
 
1940
- // Resolve terminal launch info to determine supportsSession
1941
- const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
1942
- 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
+ }
1943
2440
 
1944
- // Show launch dialog with resolved info
1945
- 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 });
1946
2467
  },
1947
2468
  } satisfies AgentNodeData,
1948
2469
  };
@@ -2134,8 +2655,22 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2134
2655
  {agents.length === 0 ? (
2135
2656
  <div className="flex-1 flex flex-col items-center justify-center gap-3">
2136
2657
  <span className="text-3xl">🚀</span>
2137
- <div className="text-sm text-gray-400">Add agents to start</div>
2138
- <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">
2139
2674
  {PRESET_AGENTS.map((p, i) => (
2140
2675
  <button key={i} onClick={() => setModal({ mode: 'add', initial: p })}
2141
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">
@@ -2163,7 +2698,20 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2163
2698
  </div>
2164
2699
  </div>
2165
2700
  ) : (
2166
- <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
+ )}
2167
2715
  <ReactFlow
2168
2716
  nodes={rfNodes}
2169
2717
  edges={rfEdges}
@@ -2268,13 +2816,17 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2268
2816
  setTermLaunchDialog(null);
2269
2817
  const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id });
2270
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
+ }
2271
2823
  setFloatingTerminals(prev => [...prev, {
2272
2824
  agentId: agent.id, label: agent.label, icon: agent.icon,
2273
2825
  cliId: agent.agentId || 'claude',
2274
2826
  cliCmd: res.cliCmd || 'claude',
2275
2827
  cliType: res.cliType || 'claude-code',
2276
2828
  workDir,
2277
- sessionName: sessName, resumeMode, resumeSessionId: sessionId,
2829
+ sessionName: sessName, resumeMode, resumeSessionId: sessionId, isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: sessionId || agent.boundSessionId,
2278
2830
  profileEnv: {
2279
2831
  ...(res.env || {}),
2280
2832
  ...(res.model ? { CLAUDE_MODEL: res.model } : {}),
@@ -2289,7 +2841,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2289
2841
  />
2290
2842
  )}
2291
2843
 
2292
- {/* Floating terminals for manual agents */}
2844
+ {/* Floating terminals positioned near their agent node */}
2293
2845
  {floatingTerminals.map(ft => (
2294
2846
  <FloatingTerminal
2295
2847
  key={ft.agentId}
@@ -2305,17 +2857,17 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2305
2857
  resumeMode={ft.resumeMode}
2306
2858
  resumeSessionId={ft.resumeSessionId}
2307
2859
  profileEnv={ft.profileEnv}
2860
+ isPrimary={ft.isPrimary}
2861
+ skipPermissions={ft.skipPermissions}
2862
+ persistentSession={ft.persistentSession}
2863
+ boundSessionId={ft.boundSessionId}
2308
2864
  onSessionReady={(name) => {
2309
- if (workspaceId) {
2310
- wsApi(workspaceId, 'set_tmux_session', { agentId: ft.agentId, sessionName: name });
2311
- }
2865
+ if (workspaceId) wsApi(workspaceId, 'set_tmux_session', { agentId: ft.agentId, sessionName: name });
2312
2866
  setFloatingTerminals(prev => prev.map(t => t.agentId === ft.agentId ? { ...t, tmuxSession: name } : t));
2313
2867
  }}
2314
- onClose={() => {
2868
+ onClose={(killSession) => {
2315
2869
  setFloatingTerminals(prev => prev.filter(t => t.agentId !== ft.agentId));
2316
- if (workspaceId) {
2317
- wsApi(workspaceId, 'close_terminal', { agentId: ft.agentId });
2318
- }
2870
+ if (workspaceId) wsApi(workspaceId, 'close_terminal', { agentId: ft.agentId, kill: killSession });
2319
2871
  }}
2320
2872
  />
2321
2873
  ))}