@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.
- package/.forge/mcp.json +8 -0
- package/RELEASE_NOTES.md +130 -4
- 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/SkillsPanel.tsx +1 -0
- package/components/WebTerminal.tsx +40 -25
- package/components/WorkspaceView.tsx +649 -97
- 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 +763 -80
- package/lib/workspace/persistence.ts +0 -1
- package/lib/workspace/types.ts +18 -11
- package/lib/workspace/watch-manager.ts +251 -2
- package/lib/workspace-standalone.ts +83 -27
- package/middleware.ts +6 -0
- package/next.config.ts +8 -0
- 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;
|
|
25
|
-
|
|
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 [
|
|
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
|
-
|
|
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
|
|
571
|
-
<label className="text-[8px] text-gray-600">Interval (
|
|
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-
|
|
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 ? '▼' : '▶'}
|
|
1544
|
+
{showSessions ? '▼' : '▶'} All sessions ({sessions.length})
|
|
1290
1545
|
</button>
|
|
1291
1546
|
)}
|
|
1292
1547
|
|
|
1293
|
-
{showSessions && sessions.
|
|
1294
|
-
<
|
|
1295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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">
|
|
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=
|
|
1545
|
-
Kill Session
|
|
1546
|
-
<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>
|
|
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
|
|
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 +
|
|
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
|
-
|
|
1690
|
-
|
|
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
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
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
|
|
1832
|
-
|
|
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',
|
|
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',
|
|
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
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
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
|
-
|
|
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
|
|
1931
|
-
if (
|
|
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
|
-
//
|
|
1941
|
-
|
|
1942
|
-
|
|
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
|
-
//
|
|
1945
|
-
|
|
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">
|
|
2138
|
-
|
|
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
|
|
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
|
))}
|