@aion0/forge 0.5.7 → 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.forge/mcp.json +8 -0
- package/RELEASE_NOTES.md +128 -5
- package/app/api/monitor/route.ts +12 -0
- package/app/api/project-sessions/route.ts +61 -0
- package/app/api/workspace/route.ts +1 -1
- package/check-forge-status.sh +9 -0
- package/components/MonitorPanel.tsx +15 -0
- package/components/ProjectDetail.tsx +99 -5
- package/components/SessionView.tsx +67 -19
- package/components/WebTerminal.tsx +40 -25
- package/components/WorkspaceView.tsx +545 -103
- package/lib/claude-sessions.ts +26 -28
- package/lib/forge-mcp-server.ts +389 -0
- package/lib/forge-skills/forge-inbox.md +13 -12
- package/lib/forge-skills/forge-send.md +13 -6
- package/lib/forge-skills/forge-status.md +12 -12
- package/lib/project-sessions.ts +48 -0
- package/lib/session-utils.ts +49 -0
- package/lib/workspace/__tests__/state-machine.test.ts +2 -2
- package/lib/workspace/agent-worker.ts +2 -5
- package/lib/workspace/backends/cli-backend.ts +3 -0
- package/lib/workspace/orchestrator.ts +740 -88
- package/lib/workspace/persistence.ts +0 -1
- package/lib/workspace/types.ts +10 -6
- package/lib/workspace/watch-manager.ts +17 -7
- package/lib/workspace-standalone.ts +83 -27
- package/package.json +4 -2
|
@@ -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
|
|
341
|
+
// Load sessions and mark fixed session
|
|
336
342
|
useEffect(() => {
|
|
337
343
|
if (!projectPath) return;
|
|
338
344
|
const pName = (projectPath || '').replace(/\/+$/, '').split('/').pop() || '';
|
|
339
|
-
|
|
340
|
-
.then(r => r.json())
|
|
341
|
-
.then(
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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 ? '▼' : '▶'}
|
|
1544
|
+
{showSessions ? '▼' : '▶'} All sessions ({sessions.length})
|
|
1400
1545
|
</button>
|
|
1401
1546
|
)}
|
|
1402
1547
|
|
|
1403
|
-
{showSessions && sessions.
|
|
1404
|
-
<
|
|
1405
|
-
|
|
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
|
-
|
|
1572
|
+
// ─── Terminal Dock (right side panel with tabs) ──────────
|
|
1573
|
+
type TerminalEntry = { agentId: string; label: string; icon: string; cliId: string; cliCmd?: string; cliType?: string; workDir?: string; tmuxSession?: string; sessionName: string; resumeMode?: boolean; resumeSessionId?: string; profileEnv?: Record<string, string> };
|
|
1574
|
+
|
|
1575
|
+
function TerminalDock({ terminals, projectPath, workspaceId, onSessionReady, onClose }: {
|
|
1576
|
+
terminals: TerminalEntry[];
|
|
1577
|
+
projectPath: string;
|
|
1578
|
+
workspaceId: string | null;
|
|
1579
|
+
onSessionReady: (agentId: string, name: string) => void;
|
|
1580
|
+
onClose: (agentId: string) => void;
|
|
1581
|
+
}) {
|
|
1582
|
+
const [activeTab, setActiveTab] = useState(terminals[0]?.agentId || '');
|
|
1583
|
+
const [width, setWidth] = useState(520);
|
|
1584
|
+
const dragRef = useRef<{ startX: number; origW: number } | null>(null);
|
|
1585
|
+
|
|
1586
|
+
// Auto-select new tab when added
|
|
1587
|
+
useEffect(() => {
|
|
1588
|
+
if (terminals.length > 0 && !terminals.find(t => t.agentId === activeTab)) {
|
|
1589
|
+
setActiveTab(terminals[terminals.length - 1].agentId);
|
|
1590
|
+
}
|
|
1591
|
+
}, [terminals, activeTab]);
|
|
1592
|
+
|
|
1593
|
+
const active = terminals.find(t => t.agentId === activeTab);
|
|
1594
|
+
|
|
1595
|
+
return (
|
|
1596
|
+
<div className="flex shrink-0" style={{ width }}>
|
|
1597
|
+
{/* Resize handle */}
|
|
1598
|
+
<div
|
|
1599
|
+
className="w-1 cursor-col-resize hover:bg-[#58a6ff]/30 active:bg-[#58a6ff]/50 transition-colors"
|
|
1600
|
+
style={{ background: '#21262d' }}
|
|
1601
|
+
onMouseDown={(e) => {
|
|
1602
|
+
e.preventDefault();
|
|
1603
|
+
dragRef.current = { startX: e.clientX, origW: width };
|
|
1604
|
+
const onMove = (ev: MouseEvent) => {
|
|
1605
|
+
if (!dragRef.current) return;
|
|
1606
|
+
const newW = dragRef.current.origW - (ev.clientX - dragRef.current.startX);
|
|
1607
|
+
setWidth(Math.max(300, Math.min(1200, newW)));
|
|
1608
|
+
};
|
|
1609
|
+
const onUp = () => { dragRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
|
1610
|
+
window.addEventListener('mousemove', onMove);
|
|
1611
|
+
window.addEventListener('mouseup', onUp);
|
|
1612
|
+
}}
|
|
1613
|
+
/>
|
|
1614
|
+
<div className="flex-1 flex flex-col min-w-0 bg-[#0d1117] border-l border-[#30363d]">
|
|
1615
|
+
{/* Tabs */}
|
|
1616
|
+
<div className="flex items-center bg-[#161b22] border-b border-[#30363d] overflow-x-auto shrink-0">
|
|
1617
|
+
{terminals.map(t => (
|
|
1618
|
+
<div
|
|
1619
|
+
key={t.agentId}
|
|
1620
|
+
onClick={() => setActiveTab(t.agentId)}
|
|
1621
|
+
className={`flex items-center gap-1.5 px-3 py-1.5 text-[10px] border-r border-[#30363d] shrink-0 cursor-pointer ${
|
|
1622
|
+
t.agentId === activeTab
|
|
1623
|
+
? 'bg-[#0d1117] text-white border-b-2 border-b-[#58a6ff]'
|
|
1624
|
+
: 'text-gray-500 hover:text-gray-300 hover:bg-[#1c2128]'
|
|
1625
|
+
}`}
|
|
1626
|
+
>
|
|
1627
|
+
<span>{t.icon}</span>
|
|
1628
|
+
<span className="font-medium">{t.label}</span>
|
|
1629
|
+
<span
|
|
1630
|
+
onClick={(e) => { e.stopPropagation(); onClose(t.agentId); }}
|
|
1631
|
+
className="ml-1 text-gray-600 hover:text-red-400 text-[8px] cursor-pointer"
|
|
1632
|
+
>✕</span>
|
|
1633
|
+
</div>
|
|
1634
|
+
))}
|
|
1635
|
+
</div>
|
|
1636
|
+
{/* Active terminal */}
|
|
1637
|
+
{active && (
|
|
1638
|
+
<div className="flex-1 min-h-0" key={active.agentId}>
|
|
1639
|
+
<FloatingTerminalInline
|
|
1640
|
+
agentLabel={active.label}
|
|
1641
|
+
agentIcon={active.icon}
|
|
1642
|
+
projectPath={projectPath}
|
|
1643
|
+
agentCliId={active.cliId}
|
|
1644
|
+
cliCmd={active.cliCmd}
|
|
1645
|
+
cliType={active.cliType}
|
|
1646
|
+
workDir={active.workDir}
|
|
1647
|
+
preferredSessionName={active.sessionName}
|
|
1648
|
+
existingSession={active.tmuxSession}
|
|
1649
|
+
resumeMode={active.resumeMode}
|
|
1650
|
+
resumeSessionId={active.resumeSessionId}
|
|
1651
|
+
profileEnv={active.profileEnv}
|
|
1652
|
+
onSessionReady={(name) => onSessionReady(active.agentId, name)}
|
|
1653
|
+
/>
|
|
1654
|
+
</div>
|
|
1655
|
+
)}
|
|
1656
|
+
</div>
|
|
1657
|
+
</div>
|
|
1658
|
+
);
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// ─── Inline Terminal (no drag/resize, fills parent) ──────
|
|
1662
|
+
function FloatingTerminalInline({ agentLabel, agentIcon, projectPath, agentCliId, cliCmd: cliCmdProp, cliType, workDir, preferredSessionName, existingSession, resumeMode, resumeSessionId, profileEnv, isPrimary, skipPermissions, boundSessionId, onSessionReady }: {
|
|
1663
|
+
agentLabel: string;
|
|
1664
|
+
agentIcon: string;
|
|
1665
|
+
projectPath: string;
|
|
1666
|
+
agentCliId: string;
|
|
1667
|
+
cliCmd?: string;
|
|
1668
|
+
cliType?: string;
|
|
1669
|
+
workDir?: string;
|
|
1670
|
+
preferredSessionName?: string;
|
|
1671
|
+
existingSession?: string;
|
|
1672
|
+
resumeMode?: boolean;
|
|
1673
|
+
resumeSessionId?: string;
|
|
1674
|
+
profileEnv?: Record<string, string>;
|
|
1675
|
+
isPrimary?: boolean;
|
|
1676
|
+
skipPermissions?: boolean;
|
|
1677
|
+
boundSessionId?: string;
|
|
1678
|
+
onSessionReady?: (name: string) => void;
|
|
1679
|
+
}) {
|
|
1680
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
1681
|
+
|
|
1682
|
+
useEffect(() => {
|
|
1683
|
+
const el = containerRef.current;
|
|
1684
|
+
if (!el) return;
|
|
1685
|
+
let disposed = false;
|
|
1686
|
+
|
|
1687
|
+
Promise.all([
|
|
1688
|
+
import('@xterm/xterm'),
|
|
1689
|
+
import('@xterm/addon-fit'),
|
|
1690
|
+
]).then(([{ Terminal }, { FitAddon }]) => {
|
|
1691
|
+
if (disposed) return;
|
|
1692
|
+
|
|
1693
|
+
const term = new Terminal({
|
|
1694
|
+
cursorBlink: true, fontSize: 13,
|
|
1695
|
+
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
1696
|
+
scrollback: 5000,
|
|
1697
|
+
theme: { background: '#0d1117', foreground: '#c9d1d9', cursor: '#58a6ff' },
|
|
1698
|
+
});
|
|
1699
|
+
const fitAddon = new FitAddon();
|
|
1700
|
+
term.loadAddon(fitAddon);
|
|
1701
|
+
term.open(el);
|
|
1702
|
+
setTimeout(() => { try { fitAddon.fit(); } catch {} }, 100);
|
|
1703
|
+
|
|
1704
|
+
const ro = new ResizeObserver(() => { try { fitAddon.fit(); } catch {} });
|
|
1705
|
+
ro.observe(el);
|
|
1706
|
+
|
|
1707
|
+
// Connect to terminal server
|
|
1708
|
+
const wsUrl = getWsUrl();
|
|
1709
|
+
const ws = new WebSocket(wsUrl);
|
|
1710
|
+
ws.binaryType = 'arraybuffer';
|
|
1711
|
+
const decoder = new TextDecoder();
|
|
1712
|
+
|
|
1713
|
+
ws.onopen = () => {
|
|
1714
|
+
ws.send(JSON.stringify({
|
|
1715
|
+
type: 'create',
|
|
1716
|
+
cols: term.cols, rows: term.rows,
|
|
1717
|
+
sessionName: existingSession || preferredSessionName,
|
|
1718
|
+
existingSession: existingSession || undefined,
|
|
1719
|
+
}));
|
|
1720
|
+
};
|
|
1721
|
+
ws.onmessage = async (event) => {
|
|
1722
|
+
try {
|
|
1723
|
+
const msg = JSON.parse(typeof event.data === 'string' ? event.data : decoder.decode(event.data));
|
|
1724
|
+
if (msg.type === 'data') {
|
|
1725
|
+
term.write(typeof msg.data === 'string' ? msg.data : new Uint8Array(Object.values(msg.data)));
|
|
1726
|
+
} else if (msg.type === 'created') {
|
|
1727
|
+
onSessionReady?.(msg.sessionName);
|
|
1728
|
+
// Auto-run CLI on newly created session
|
|
1729
|
+
if (!existingSession) {
|
|
1730
|
+
const cli = cliCmdProp || 'claude';
|
|
1731
|
+
const targetDir = workDir ? `${projectPath}/${workDir}` : projectPath;
|
|
1732
|
+
const cdCmd = `mkdir -p "${targetDir}" && cd "${targetDir}"`;
|
|
1733
|
+
const isClaude = (cliType || 'claude-code') === 'claude-code';
|
|
1734
|
+
const modelFlag = isClaude && profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
|
|
1735
|
+
const envWithoutModel = profileEnv ? Object.fromEntries(
|
|
1736
|
+
Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
|
|
1737
|
+
) : {};
|
|
1738
|
+
// Unset old profile vars + set new ones
|
|
1739
|
+
const profileVarsToReset = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING', 'DISABLE_AUTOUPDATER', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_MODEL'];
|
|
1740
|
+
const unsetPrefix = profileVarsToReset.map(v => `unset ${v}`).join(' && ') + ' && ';
|
|
1741
|
+
const envExportsClean = unsetPrefix + (Object.keys(envWithoutModel).length > 0
|
|
1742
|
+
? Object.entries(envWithoutModel).map(([k, v]) => `export ${k}="${v}"`).join(' && ') + ' && '
|
|
1743
|
+
: '');
|
|
1744
|
+
// Resolve session: explicit > boundSessionId > fixedSession (primary) > fresh
|
|
1745
|
+
let resumeId = resumeSessionId || boundSessionId;
|
|
1746
|
+
if (isClaude && !resumeId && isPrimary) {
|
|
1747
|
+
try {
|
|
1748
|
+
const { resolveFixedSession } = await import('@/lib/session-utils');
|
|
1749
|
+
resumeId = (await resolveFixedSession(projectPath)) || undefined;
|
|
1750
|
+
} catch {}
|
|
1751
|
+
}
|
|
1752
|
+
const resumeFlag = isClaude && resumeId ? ` --resume ${resumeId}` : '';
|
|
1753
|
+
let mcpFlag = '';
|
|
1754
|
+
if (isClaude) { try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {} }
|
|
1755
|
+
const sf = skipPermissions ? (cliType === 'codex' ? ' --full-auto' : cliType === 'aider' ? ' --yes' : ' --dangerously-skip-permissions') : '';
|
|
1756
|
+
const cmd = `${envExportsClean}${cdCmd} && ${cli}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`;
|
|
1757
|
+
setTimeout(() => {
|
|
1758
|
+
if (!disposed && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: cmd }));
|
|
1759
|
+
}, 300);
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
} catch {}
|
|
1763
|
+
};
|
|
1764
|
+
|
|
1765
|
+
term.onData(data => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data })); });
|
|
1766
|
+
term.onResize(({ cols, rows }) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows })); });
|
|
1767
|
+
|
|
1768
|
+
return () => {
|
|
1769
|
+
disposed = true;
|
|
1770
|
+
ro.disconnect();
|
|
1771
|
+
ws.close();
|
|
1772
|
+
term.dispose();
|
|
1773
|
+
};
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
return () => { disposed = true; };
|
|
1777
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
1778
|
+
|
|
1779
|
+
return <div ref={containerRef} className="w-full h-full" style={{ background: '#0d1117' }} />;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliCmd: cliCmdProp, cliType, workDir, preferredSessionName, existingSession, resumeMode, resumeSessionId, profileEnv, isPrimary, skipPermissions, persistentSession, boundSessionId, onSessionReady, onClose }: {
|
|
1434
1783
|
agentLabel: string;
|
|
1435
1784
|
agentIcon: string;
|
|
1436
1785
|
projectPath: string;
|
|
@@ -1443,6 +1792,10 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
1443
1792
|
resumeMode?: boolean;
|
|
1444
1793
|
resumeSessionId?: string;
|
|
1445
1794
|
profileEnv?: Record<string, string>;
|
|
1795
|
+
isPrimary?: boolean;
|
|
1796
|
+
skipPermissions?: boolean;
|
|
1797
|
+
persistentSession?: boolean;
|
|
1798
|
+
boundSessionId?: string;
|
|
1446
1799
|
onSessionReady?: (name: string) => void;
|
|
1447
1800
|
onClose: (killSession: boolean) => void;
|
|
1448
1801
|
}) {
|
|
@@ -1501,7 +1854,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
1501
1854
|
};
|
|
1502
1855
|
|
|
1503
1856
|
let launched = false;
|
|
1504
|
-
ws.onmessage = (event) => {
|
|
1857
|
+
ws.onmessage = async (event) => {
|
|
1505
1858
|
if (disposed) return;
|
|
1506
1859
|
try {
|
|
1507
1860
|
const msg = JSON.parse(event.data);
|
|
@@ -1543,18 +1896,30 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
1543
1896
|
|
|
1544
1897
|
const cdCmd = `mkdir -p "${targetDir}" && cd "${targetDir}"`;
|
|
1545
1898
|
const isClaude = (cliType || 'claude-code') === 'claude-code';
|
|
1546
|
-
const resumeFlag = isClaude
|
|
1547
|
-
? (resumeSessionId ? ` --resume ${resumeSessionId}` : resumeMode ? ' -c' : '')
|
|
1548
|
-
: '';
|
|
1549
1899
|
const modelFlag = isClaude && profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
|
|
1550
|
-
// Remove CLAUDE_MODEL from env exports (passed via --model flag instead)
|
|
1551
1900
|
const envWithoutModel = profileEnv ? Object.fromEntries(
|
|
1552
1901
|
Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
|
|
1553
1902
|
) : {};
|
|
1554
|
-
|
|
1903
|
+
// Unset old profile vars + set new ones (prevents leaking between agent switches)
|
|
1904
|
+
const profileVarsToReset = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING', 'DISABLE_AUTOUPDATER', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_MODEL'];
|
|
1905
|
+
const unsetPrefix = profileVarsToReset.map(v => `unset ${v}`).join(' && ') + ' && ';
|
|
1906
|
+
const envExportsClean = unsetPrefix + (Object.keys(envWithoutModel).length > 0
|
|
1555
1907
|
? Object.entries(envWithoutModel).map(([k, v]) => `export ${k}="${v}"`).join(' && ') + ' && '
|
|
1556
|
-
: '';
|
|
1557
|
-
|
|
1908
|
+
: '');
|
|
1909
|
+
// Primary: use fixed session. Non-primary: use explicit sessionId or -c
|
|
1910
|
+
// Resolve session: explicit > boundSessionId > fixedSession (primary) > fresh
|
|
1911
|
+
let resumeId = resumeSessionId || boundSessionId;
|
|
1912
|
+
if (isClaude && !resumeId && isPrimary) {
|
|
1913
|
+
try {
|
|
1914
|
+
const { resolveFixedSession } = await import('@/lib/session-utils');
|
|
1915
|
+
resumeId = (await resolveFixedSession(projectPath)) || undefined;
|
|
1916
|
+
} catch {}
|
|
1917
|
+
}
|
|
1918
|
+
const resumeFlag = isClaude && resumeId ? ` --resume ${resumeId}` : '';
|
|
1919
|
+
let mcpFlag = '';
|
|
1920
|
+
if (isClaude) { try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {} }
|
|
1921
|
+
const sf = skipPermissions ? ' --dangerously-skip-permissions' : '';
|
|
1922
|
+
const cmd = `${envExportsClean}${cdCmd} && ${cli}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`;
|
|
1558
1923
|
setTimeout(() => {
|
|
1559
1924
|
if (!disposed && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: cmd }));
|
|
1560
1925
|
}, 300);
|
|
@@ -1642,7 +2007,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
1642
2007
|
<button onClick={() => { setShowCloseDialog(false); onClose(false); }}
|
|
1643
2008
|
className="flex-1 px-3 py-1.5 text-[11px] rounded bg-[#2a2a4a] text-gray-300 hover:bg-[#3a3a5a] hover:text-white">
|
|
1644
2009
|
Suspend
|
|
1645
|
-
<span className="block text-[9px] text-gray-500 mt-0.5">
|
|
2010
|
+
<span className="block text-[9px] text-gray-500 mt-0.5">Hide panel, session keeps running</span>
|
|
1646
2011
|
</button>
|
|
1647
2012
|
<button onClick={() => {
|
|
1648
2013
|
setShowCloseDialog(false);
|
|
@@ -1651,9 +2016,11 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
1651
2016
|
}
|
|
1652
2017
|
onClose(true);
|
|
1653
2018
|
}}
|
|
1654
|
-
className=
|
|
1655
|
-
Kill Session
|
|
1656
|
-
<span className=
|
|
2019
|
+
className={`flex-1 px-3 py-1.5 text-[11px] rounded ${persistentSession ? 'bg-yellow-500/20 text-yellow-400 hover:bg-yellow-500/30' : 'bg-red-500/20 text-red-400 hover:bg-red-500/30'}`}>
|
|
2020
|
+
{persistentSession ? 'Restart Session' : 'Kill Session'}
|
|
2021
|
+
<span className={`block text-[9px] mt-0.5 ${persistentSession ? 'text-yellow-400/60' : 'text-red-400/60'}`}>
|
|
2022
|
+
{persistentSession ? 'Kill and restart with fresh env' : 'End session permanently'}
|
|
2023
|
+
</span>
|
|
1657
2024
|
</button>
|
|
1658
2025
|
</div>
|
|
1659
2026
|
<button onClick={() => setShowCloseDialog(false)}
|
|
@@ -1758,17 +2125,18 @@ interface AgentNodeData {
|
|
|
1758
2125
|
onShowMemory: () => void;
|
|
1759
2126
|
onShowInbox: () => void;
|
|
1760
2127
|
onOpenTerminal: () => void;
|
|
2128
|
+
onSwitchSession: () => void;
|
|
1761
2129
|
inboxPending?: number;
|
|
1762
2130
|
inboxFailed?: number;
|
|
1763
2131
|
[key: string]: unknown;
|
|
1764
2132
|
}
|
|
1765
2133
|
|
|
1766
2134
|
function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
|
|
1767
|
-
const { config, state, colorIdx, previewLines, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, inboxPending = 0, inboxFailed = 0 } = data;
|
|
2135
|
+
const { config, state, colorIdx, previewLines, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, onSwitchSession, inboxPending = 0, inboxFailed = 0 } = data;
|
|
1768
2136
|
const c = COLORS[colorIdx % COLORS.length];
|
|
1769
2137
|
const smithStatus = state?.smithStatus || 'down';
|
|
1770
2138
|
const taskStatus = state?.taskStatus || 'idle';
|
|
1771
|
-
const
|
|
2139
|
+
const hasTmux = !!state?.tmuxSession;
|
|
1772
2140
|
const smithInfo = SMITH_STATUS[smithStatus] || SMITH_STATUS.down;
|
|
1773
2141
|
const taskInfo = TASK_STATUS[taskStatus] || TASK_STATUS.idle;
|
|
1774
2142
|
const currentStep = state?.currentStep;
|
|
@@ -1782,6 +2150,9 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
|
|
|
1782
2150
|
<Handle type="target" position={Position.Left} style={{ background: c.accent, width: 8, height: 8, border: 'none' }} />
|
|
1783
2151
|
<Handle type="source" position={Position.Right} style={{ background: c.accent, width: 8, height: 8, border: 'none' }} />
|
|
1784
2152
|
|
|
2153
|
+
{/* Primary badge */}
|
|
2154
|
+
{config.primary && <div className="bg-[#f0883e]/20 text-[#f0883e] text-[7px] font-bold text-center py-0.5 rounded-t-lg">PRIMARY</div>}
|
|
2155
|
+
|
|
1785
2156
|
{/* Header */}
|
|
1786
2157
|
<div className="flex items-center gap-2 px-3 py-2">
|
|
1787
2158
|
<span className="text-sm">{config.icon}</span>
|
|
@@ -1789,15 +2160,23 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
|
|
|
1789
2160
|
<div className="text-xs font-semibold text-white truncate">{config.label}</div>
|
|
1790
2161
|
<div className="text-[8px]" style={{ color: c.accent }}>{config.backend === 'api' ? config.provider || 'api' : config.agentId || 'cli'}</div>
|
|
1791
2162
|
</div>
|
|
1792
|
-
{/* Status: smith +
|
|
2163
|
+
{/* Status: smith + terminal + task */}
|
|
1793
2164
|
<div className="flex flex-col items-end gap-0.5">
|
|
1794
2165
|
<div className="flex items-center gap-1">
|
|
1795
2166
|
<div className="w-1.5 h-1.5 rounded-full" style={{ background: smithInfo.color, boxShadow: smithInfo.glow ? `0 0 4px ${smithInfo.color}` : 'none' }} />
|
|
1796
2167
|
<span className="text-[7px]" style={{ color: smithInfo.color }}>{smithInfo.label}</span>
|
|
1797
2168
|
</div>
|
|
1798
2169
|
<div className="flex items-center gap-1">
|
|
1799
|
-
|
|
1800
|
-
|
|
2170
|
+
{(() => {
|
|
2171
|
+
// Execution mode is determined by config, not tmux state
|
|
2172
|
+
const isTerminalMode = config.persistentSession;
|
|
2173
|
+
const color = isTerminalMode ? (hasTmux ? '#3fb950' : '#f0883e') : '#484f58';
|
|
2174
|
+
const label = isTerminalMode ? (hasTmux ? 'terminal' : 'terminal (down)') : 'headless';
|
|
2175
|
+
return (<>
|
|
2176
|
+
<div className="w-1.5 h-1.5 rounded-full" style={{ background: color }} />
|
|
2177
|
+
<span className="text-[7px] font-medium" style={{ color }}>{label}</span>
|
|
2178
|
+
</>);
|
|
2179
|
+
})()}
|
|
1801
2180
|
</div>
|
|
1802
2181
|
<div className="flex items-center gap-1">
|
|
1803
2182
|
<div className="w-1.5 h-1.5 rounded-full" style={{ background: taskInfo.color, boxShadow: taskInfo.glow ? `0 0 4px ${taskInfo.color}` : 'none' }} />
|
|
@@ -1861,10 +2240,14 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
|
|
|
1861
2240
|
className="text-[9px] px-1.5 py-0.5 rounded bg-blue-600/20 text-blue-400 hover:bg-blue-600/30">💬 Message</button>
|
|
1862
2241
|
)}
|
|
1863
2242
|
<div className="flex-1" />
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
2243
|
+
<span className="flex items-center">
|
|
2244
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onOpenTerminal(); }}
|
|
2245
|
+
className={`text-[9px] px-1 ${hasTmux && taskStatus === 'running' ? 'text-green-400 animate-pulse' : 'text-gray-600 hover:text-green-400'}`} title="Open terminal">⌨️</button>
|
|
2246
|
+
{hasTmux && !config.primary && (
|
|
2247
|
+
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onSwitchSession(); }}
|
|
2248
|
+
className="text-[10px] text-gray-600 hover:text-yellow-400 px-0.5 py-0.5" title="Switch session">▾</button>
|
|
2249
|
+
)}
|
|
2250
|
+
</span>
|
|
1868
2251
|
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowInbox(); }}
|
|
1869
2252
|
className="text-[9px] text-gray-600 hover:text-orange-400 px-1" title="Messages (inbox/outbox)">📨</button>
|
|
1870
2253
|
<button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onShowMemory(); }}
|
|
@@ -1904,7 +2287,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
1904
2287
|
const [memoryTarget, setMemoryTarget] = useState<{ id: string; label: string } | null>(null);
|
|
1905
2288
|
const [inboxTarget, setInboxTarget] = useState<{ id: string; label: string } | null>(null);
|
|
1906
2289
|
const [showBusPanel, setShowBusPanel] = useState(false);
|
|
1907
|
-
const [floatingTerminals, setFloatingTerminals] = useState<{ agentId: string; label: string; icon: string; cliId: string; cliCmd?: string; cliType?: string; workDir?: string; tmuxSession?: string; sessionName: string; resumeMode?: boolean; resumeSessionId?: string; profileEnv?: Record<string, string
|
|
2290
|
+
const [floatingTerminals, setFloatingTerminals] = useState<{ agentId: string; label: string; icon: string; cliId: string; cliCmd?: string; cliType?: string; workDir?: string; tmuxSession?: string; sessionName: string; resumeMode?: boolean; resumeSessionId?: string; profileEnv?: Record<string, string>; isPrimary?: boolean; skipPermissions?: boolean; persistentSession?: boolean; boundSessionId?: string }[]>([]);
|
|
1908
2291
|
const [termLaunchDialog, setTermLaunchDialog] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; sessions: string[]; supportsSession?: boolean } | null>(null);
|
|
1909
2292
|
|
|
1910
2293
|
// Expose focusAgent to parent
|
|
@@ -1938,27 +2321,8 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
1938
2321
|
}
|
|
1939
2322
|
});
|
|
1940
2323
|
|
|
1941
|
-
// Auto-open
|
|
1942
|
-
|
|
1943
|
-
useEffect(() => {
|
|
1944
|
-
if (autoOpenDone.current || agents.length === 0 || Object.keys(states).length === 0) return;
|
|
1945
|
-
autoOpenDone.current = true;
|
|
1946
|
-
const manualAgents = agents.filter(a =>
|
|
1947
|
-
a.type !== 'input' && states[a.id]?.mode === 'manual' && states[a.id]?.tmuxSession
|
|
1948
|
-
);
|
|
1949
|
-
if (manualAgents.length > 0) {
|
|
1950
|
-
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
1951
|
-
setFloatingTerminals(manualAgents.map(a => ({
|
|
1952
|
-
agentId: a.id,
|
|
1953
|
-
label: a.label,
|
|
1954
|
-
icon: a.icon,
|
|
1955
|
-
cliId: a.agentId || 'claude',
|
|
1956
|
-
workDir: a.workDir && a.workDir !== './' && a.workDir !== '.' ? a.workDir : undefined,
|
|
1957
|
-
tmuxSession: states[a.id].tmuxSession,
|
|
1958
|
-
sessionName: `mw-forge-${safeName(projectName)}-${safeName(a.label)}`,
|
|
1959
|
-
})));
|
|
1960
|
-
}
|
|
1961
|
-
}, [agents, states]);
|
|
2324
|
+
// Auto-open terminals removed — persistent sessions run in background tmux.
|
|
2325
|
+
// User opens terminal via ⌨️ button when needed.
|
|
1962
2326
|
|
|
1963
2327
|
// Rebuild nodes when agents/states/preview change — preserve existing positions + dimensions
|
|
1964
2328
|
useEffect(() => {
|
|
@@ -1980,7 +2344,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
1980
2344
|
type: 'input' as const,
|
|
1981
2345
|
data: {
|
|
1982
2346
|
config: agent,
|
|
1983
|
-
state: states[agent.id] || { smithStatus: 'down',
|
|
2347
|
+
state: states[agent.id] || { smithStatus: 'down', taskStatus: 'idle', artifacts: [] },
|
|
1984
2348
|
onSubmit: (content: string) => {
|
|
1985
2349
|
// Optimistic update
|
|
1986
2350
|
wsApi(workspaceId!, 'complete_input', { agentId: agent.id, content });
|
|
@@ -2000,7 +2364,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2000
2364
|
type: 'agent' as const,
|
|
2001
2365
|
data: {
|
|
2002
2366
|
config: agent,
|
|
2003
|
-
state: states[agent.id] || { smithStatus: 'down',
|
|
2367
|
+
state: states[agent.id] || { smithStatus: 'down', taskStatus: 'idle', artifacts: [] },
|
|
2004
2368
|
colorIdx: i,
|
|
2005
2369
|
previewLines: logPreview[agent.id] || [],
|
|
2006
2370
|
onRun: () => {
|
|
@@ -2023,36 +2387,83 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2023
2387
|
inboxFailed: busLog.filter(m => m.to === agent.id && m.status === 'failed' && m.type !== 'ack').length,
|
|
2024
2388
|
onOpenTerminal: async () => {
|
|
2025
2389
|
if (!workspaceId) return;
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2390
|
+
if (!daemonActiveFromStream) {
|
|
2391
|
+
alert('Start daemon first before opening terminal.');
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
// Close existing terminal (config may have changed)
|
|
2395
|
+
setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
|
|
2030
2396
|
|
|
2031
2397
|
const agentState = states[agent.id];
|
|
2032
2398
|
const existingTmux = agentState?.tmuxSession;
|
|
2033
|
-
|
|
2034
|
-
// Build fixed session name: mw-forge-{project}-{agentLabel}
|
|
2035
2399
|
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
2036
2400
|
const sessName = `mw-forge-${safeName(projectName)}-${safeName(agent.label)}`;
|
|
2037
|
-
const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.'
|
|
2038
|
-
|
|
2401
|
+
const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.' ? agent.workDir : undefined;
|
|
2402
|
+
|
|
2403
|
+
// Always resolve launch info for this agent (cliCmd, env, model)
|
|
2404
|
+
const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
|
|
2405
|
+
const launchInfo = {
|
|
2406
|
+
cliCmd: resolveRes?.cliCmd || 'claude',
|
|
2407
|
+
cliType: resolveRes?.cliType || 'claude-code',
|
|
2408
|
+
profileEnv: {
|
|
2409
|
+
...(resolveRes?.env || {}),
|
|
2410
|
+
...(resolveRes?.model ? { CLAUDE_MODEL: resolveRes.model } : {}),
|
|
2411
|
+
FORGE_AGENT_ID: agent.id,
|
|
2412
|
+
FORGE_WORKSPACE_ID: workspaceId!,
|
|
2413
|
+
FORGE_PORT: String(window.location.port || 8403),
|
|
2414
|
+
},
|
|
2415
|
+
};
|
|
2039
2416
|
|
|
2040
|
-
// If
|
|
2041
|
-
if (
|
|
2417
|
+
// If tmux session exists → attach (primary or non-primary)
|
|
2418
|
+
if (existingTmux) {
|
|
2419
|
+
wsApi(workspaceId, 'open_terminal', { agentId: agent.id });
|
|
2042
2420
|
setFloatingTerminals(prev => [...prev, {
|
|
2043
2421
|
agentId: agent.id, label: agent.label, icon: agent.icon,
|
|
2044
|
-
cliId: agent.agentId || 'claude', workDir,
|
|
2422
|
+
cliId: agent.agentId || 'claude', ...launchInfo, workDir,
|
|
2045
2423
|
tmuxSession: existingTmux, sessionName: sessName,
|
|
2424
|
+
isPrimary: agent.primary, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId,
|
|
2046
2425
|
}]);
|
|
2047
2426
|
return;
|
|
2048
2427
|
}
|
|
2049
2428
|
|
|
2050
|
-
//
|
|
2051
|
-
|
|
2052
|
-
|
|
2429
|
+
// Primary without session → open directly (no dialog)
|
|
2430
|
+
if (agent.primary) {
|
|
2431
|
+
const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id }).catch(() => ({})) as any;
|
|
2432
|
+
setFloatingTerminals(prev => [...prev, {
|
|
2433
|
+
agentId: agent.id, label: agent.label, icon: agent.icon,
|
|
2434
|
+
cliId: agent.agentId || 'claude', ...launchInfo, workDir,
|
|
2435
|
+
tmuxSession: res?.tmuxSession || sessName, sessionName: sessName,
|
|
2436
|
+
isPrimary: true, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId,
|
|
2437
|
+
}]);
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2053
2440
|
|
|
2054
|
-
//
|
|
2055
|
-
|
|
2441
|
+
// Non-primary: has boundSessionId → use it directly; no bound → show dialog
|
|
2442
|
+
if (agent.boundSessionId) {
|
|
2443
|
+
const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id }).catch(() => ({})) as any;
|
|
2444
|
+
setFloatingTerminals(prev => [...prev, {
|
|
2445
|
+
agentId: agent.id, label: agent.label, icon: agent.icon,
|
|
2446
|
+
cliId: agent.agentId || 'claude', ...launchInfo, workDir,
|
|
2447
|
+
tmuxSession: res?.tmuxSession || sessName, sessionName: sessName,
|
|
2448
|
+
resumeSessionId: agent.boundSessionId,
|
|
2449
|
+
isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId,
|
|
2450
|
+
}]);
|
|
2451
|
+
return;
|
|
2452
|
+
}
|
|
2453
|
+
// No bound session → show launch dialog (New / Resume / Select)
|
|
2454
|
+
setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true });
|
|
2455
|
+
},
|
|
2456
|
+
onSwitchSession: async () => {
|
|
2457
|
+
if (!workspaceId) return;
|
|
2458
|
+
// Close existing terminal
|
|
2459
|
+
setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
|
|
2460
|
+
if (agent.id) wsApi(workspaceId, 'close_terminal', { agentId: agent.id });
|
|
2461
|
+
// Show launch dialog
|
|
2462
|
+
const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
|
|
2463
|
+
const sessName = `mw-forge-${safeName(projectName)}-${safeName(agent.label)}`;
|
|
2464
|
+
const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.' ? agent.workDir : undefined;
|
|
2465
|
+
const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
|
|
2466
|
+
setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true });
|
|
2056
2467
|
},
|
|
2057
2468
|
} satisfies AgentNodeData,
|
|
2058
2469
|
};
|
|
@@ -2244,8 +2655,22 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2244
2655
|
{agents.length === 0 ? (
|
|
2245
2656
|
<div className="flex-1 flex flex-col items-center justify-center gap-3">
|
|
2246
2657
|
<span className="text-3xl">🚀</span>
|
|
2247
|
-
<div className="text-sm text-gray-400">
|
|
2248
|
-
|
|
2658
|
+
<div className="text-sm text-gray-400">Set up your workspace</div>
|
|
2659
|
+
{/* Primary agent prompt */}
|
|
2660
|
+
<button onClick={() => setModal({ mode: 'add', initial: {
|
|
2661
|
+
label: 'Engineer', icon: '👨💻', primary: true, persistentSession: true,
|
|
2662
|
+
role: 'Primary engineer — handles coding tasks in the project root.',
|
|
2663
|
+
backend: 'cli' as const, agentId: 'claude', workDir: './', dependsOn: [], outputs: [], steps: [],
|
|
2664
|
+
}})}
|
|
2665
|
+
className="flex items-center gap-3 px-5 py-3 rounded-lg border-2 border-dashed border-[#f0883e]/50 bg-[#f0883e]/5 hover:bg-[#f0883e]/10 hover:border-[#f0883e]/80 transition-colors">
|
|
2666
|
+
<span className="text-2xl">👨💻</span>
|
|
2667
|
+
<div className="text-left">
|
|
2668
|
+
<div className="text-[11px] font-semibold text-[#f0883e]">Add Primary Agent</div>
|
|
2669
|
+
<div className="text-[9px] text-gray-500">Terminal-only, root directory, fixed session</div>
|
|
2670
|
+
</div>
|
|
2671
|
+
</button>
|
|
2672
|
+
<div className="text-[9px] text-gray-600 mt-1">or add other agents:</div>
|
|
2673
|
+
<div className="flex gap-2 flex-wrap justify-center">
|
|
2249
2674
|
{PRESET_AGENTS.map((p, i) => (
|
|
2250
2675
|
<button key={i} onClick={() => setModal({ mode: 'add', initial: p })}
|
|
2251
2676
|
className="text-[10px] px-3 py-1.5 rounded border border-[#30363d] text-gray-300 hover:text-white hover:border-[#58a6ff]/60 flex items-center gap-1">
|
|
@@ -2273,7 +2698,20 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2273
2698
|
</div>
|
|
2274
2699
|
</div>
|
|
2275
2700
|
) : (
|
|
2276
|
-
<div className="flex-1 min-h-0">
|
|
2701
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
2702
|
+
{/* No primary agent hint */}
|
|
2703
|
+
{!agents.some(a => a.primary) && (
|
|
2704
|
+
<div className="flex items-center gap-2 px-3 py-1.5 bg-[#f0883e]/10 border-b border-[#f0883e]/20 shrink-0">
|
|
2705
|
+
<span className="text-[10px] text-[#f0883e]">No primary agent set.</span>
|
|
2706
|
+
<button onClick={() => setModal({ mode: 'add', initial: {
|
|
2707
|
+
label: 'Engineer', icon: '👨💻', primary: true, persistentSession: true,
|
|
2708
|
+
role: 'Primary engineer — handles coding tasks in the project root.',
|
|
2709
|
+
backend: 'cli' as const, agentId: 'claude', workDir: './', dependsOn: [], outputs: [], steps: [],
|
|
2710
|
+
}})}
|
|
2711
|
+
className="text-[10px] text-[#f0883e] underline hover:text-white">Add one</button>
|
|
2712
|
+
<span className="text-[9px] text-gray-600">or edit an existing agent to set as primary.</span>
|
|
2713
|
+
</div>
|
|
2714
|
+
)}
|
|
2277
2715
|
<ReactFlow
|
|
2278
2716
|
nodes={rfNodes}
|
|
2279
2717
|
edges={rfEdges}
|
|
@@ -2378,13 +2816,17 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2378
2816
|
setTermLaunchDialog(null);
|
|
2379
2817
|
const res = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id });
|
|
2380
2818
|
if (res.ok) {
|
|
2819
|
+
// Save selected session as boundSessionId if user chose a specific one
|
|
2820
|
+
if (sessionId) {
|
|
2821
|
+
wsApi(workspaceId, 'update', { agentId: agent.id, config: { ...agent, boundSessionId: sessionId } }).catch(() => {});
|
|
2822
|
+
}
|
|
2381
2823
|
setFloatingTerminals(prev => [...prev, {
|
|
2382
2824
|
agentId: agent.id, label: agent.label, icon: agent.icon,
|
|
2383
2825
|
cliId: agent.agentId || 'claude',
|
|
2384
2826
|
cliCmd: res.cliCmd || 'claude',
|
|
2385
2827
|
cliType: res.cliType || 'claude-code',
|
|
2386
2828
|
workDir,
|
|
2387
|
-
sessionName: sessName, resumeMode, resumeSessionId: sessionId,
|
|
2829
|
+
sessionName: sessName, resumeMode, resumeSessionId: sessionId, isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: sessionId || agent.boundSessionId,
|
|
2388
2830
|
profileEnv: {
|
|
2389
2831
|
...(res.env || {}),
|
|
2390
2832
|
...(res.model ? { CLAUDE_MODEL: res.model } : {}),
|
|
@@ -2399,7 +2841,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2399
2841
|
/>
|
|
2400
2842
|
)}
|
|
2401
2843
|
|
|
2402
|
-
{/* Floating terminals
|
|
2844
|
+
{/* Floating terminals — positioned near their agent node */}
|
|
2403
2845
|
{floatingTerminals.map(ft => (
|
|
2404
2846
|
<FloatingTerminal
|
|
2405
2847
|
key={ft.agentId}
|
|
@@ -2415,17 +2857,17 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
2415
2857
|
resumeMode={ft.resumeMode}
|
|
2416
2858
|
resumeSessionId={ft.resumeSessionId}
|
|
2417
2859
|
profileEnv={ft.profileEnv}
|
|
2860
|
+
isPrimary={ft.isPrimary}
|
|
2861
|
+
skipPermissions={ft.skipPermissions}
|
|
2862
|
+
persistentSession={ft.persistentSession}
|
|
2863
|
+
boundSessionId={ft.boundSessionId}
|
|
2418
2864
|
onSessionReady={(name) => {
|
|
2419
|
-
if (workspaceId) {
|
|
2420
|
-
wsApi(workspaceId, 'set_tmux_session', { agentId: ft.agentId, sessionName: name });
|
|
2421
|
-
}
|
|
2865
|
+
if (workspaceId) wsApi(workspaceId, 'set_tmux_session', { agentId: ft.agentId, sessionName: name });
|
|
2422
2866
|
setFloatingTerminals(prev => prev.map(t => t.agentId === ft.agentId ? { ...t, tmuxSession: name } : t));
|
|
2423
2867
|
}}
|
|
2424
|
-
onClose={() => {
|
|
2868
|
+
onClose={(killSession) => {
|
|
2425
2869
|
setFloatingTerminals(prev => prev.filter(t => t.agentId !== ft.agentId));
|
|
2426
|
-
if (workspaceId) {
|
|
2427
|
-
wsApi(workspaceId, 'close_terminal', { agentId: ft.agentId });
|
|
2428
|
-
}
|
|
2870
|
+
if (workspaceId) wsApi(workspaceId, 'close_terminal', { agentId: ft.agentId, kill: killSession });
|
|
2429
2871
|
}}
|
|
2430
2872
|
/>
|
|
2431
2873
|
))}
|