@aion0/forge 0.5.7 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "forge": {
4
+ "type": "sse",
5
+ "url": "http://localhost:8406/sse?workspaceId=656c9e65-9d73-4cb6-a065-60d966e1fc78"
6
+ }
7
+ }
8
+ }
package/RELEASE_NOTES.md CHANGED
@@ -1,11 +1,15 @@
1
- # Forge v0.5.7
1
+ # Forge v0.5.9
2
2
 
3
- Released: 2026-03-29
3
+ Released: 2026-03-30
4
4
 
5
- ## Changes since v0.5.6
5
+ ## Changes since v0.5.8
6
6
 
7
- ### Other
8
- - v0.5.6: watch enhancements, skills UI, dev mode fixes
7
+ ### Features
8
+ - feat: terminal floats below agent node, follows on drag/zoom
9
9
 
10
+ ### Bug Fixes
11
+ - fix: revise dedup to not block legitimate messages
12
+ - fix: reduce duplicate messages in workspace agent communication
10
13
 
11
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.6...v0.5.7
14
+
15
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.8...v0.5.9
@@ -24,6 +24,17 @@ export async function GET() {
24
24
  const workspace = countProcess('workspace-standalone');
25
25
  const tunnel = countProcess('cloudflared tunnel');
26
26
 
27
+ // MCP Server (runs inside workspace process, check /health endpoint)
28
+ let mcpStatus = { running: false, sessions: 0 };
29
+ try {
30
+ const mcpPort = Number(process.env.MCP_PORT) || 8406;
31
+ const mcpRes = run(`curl -s http://localhost:${mcpPort}/health 2>/dev/null`);
32
+ if (mcpRes.includes('"ok":true')) {
33
+ const data = JSON.parse(mcpRes);
34
+ mcpStatus = { running: true, sessions: data.sessions || 0 };
35
+ }
36
+ } catch {}
37
+
27
38
  // Tunnel URL
28
39
  let tunnelUrl = '';
29
40
  try {
@@ -55,6 +66,7 @@ export async function GET() {
55
66
  telegram: { running: telegram.count > 0, pid: telegram.pid, startedAt: telegram.startedAt },
56
67
  workspace: { running: workspace.count > 0, pid: workspace.pid, startedAt: workspace.startedAt },
57
68
  tunnel: { running: tunnel.count > 0, pid: tunnel.pid, url: tunnelUrl, startedAt: tunnel.startedAt },
69
+ mcp: { running: mcpStatus.running, port: 8406, sessions: mcpStatus.sessions },
58
70
  },
59
71
  sessions,
60
72
  uptime: uptime.replace(/.*up\s+/, '').replace(/,\s+\d+ user.*/, '').trim(),
@@ -0,0 +1,61 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { getFixedSession, setFixedSession, clearFixedSession, getAllFixedSessions } from '@/lib/project-sessions';
3
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ // GET: get fixed session for a project, or all bindings
7
+ export async function GET(req: Request) {
8
+ const url = new URL(req.url);
9
+ const projectPath = url.searchParams.get('projectPath');
10
+ if (projectPath) {
11
+ // Also ensure mcp.json exists when querying
12
+ ensureMcpConfig(projectPath);
13
+ return NextResponse.json({ projectPath, fixedSessionId: getFixedSession(projectPath) || null });
14
+ }
15
+ return NextResponse.json(getAllFixedSessions());
16
+ }
17
+
18
+ // POST: set fixed session or ensure MCP config
19
+ export async function POST(req: Request) {
20
+ const body = await req.json();
21
+ const { projectPath, fixedSessionId, action } = body;
22
+ if (!projectPath) return NextResponse.json({ error: 'projectPath required' }, { status: 400 });
23
+
24
+ // Ensure MCP config action
25
+ if (action === 'ensure_mcp') {
26
+ ensureMcpConfig(projectPath);
27
+ return NextResponse.json({ ok: true });
28
+ }
29
+
30
+ if (!fixedSessionId) {
31
+ clearFixedSession(projectPath);
32
+ return NextResponse.json({ ok: true, cleared: true });
33
+ }
34
+ setFixedSession(projectPath, fixedSessionId);
35
+ return NextResponse.json({ ok: true, projectPath, fixedSessionId });
36
+ }
37
+
38
+ /** Generate .forge/mcp.json in the project directory with workspace context baked in */
39
+ function ensureMcpConfig(projectPath: string): void {
40
+ try {
41
+ const forgeDir = join(projectPath, '.forge');
42
+ const configPath = join(forgeDir, 'mcp.json');
43
+ const mcpPort = Number(process.env.MCP_PORT) || 8406;
44
+
45
+ // Resolve workspace + primary agent for this project
46
+ let wsParam = '';
47
+ try {
48
+ const { findWorkspaceByProject } = require('@/lib/workspace');
49
+ const ws = findWorkspaceByProject(projectPath);
50
+ if (ws) {
51
+ wsParam = `?workspaceId=${ws.id}`;
52
+ }
53
+ } catch {}
54
+
55
+ const config = { mcpServers: { forge: { type: 'sse', url: `http://localhost:${mcpPort}/sse${wsParam}` } } };
56
+
57
+ // Always rewrite (workspace context may have changed)
58
+ mkdirSync(forgeDir, { recursive: true });
59
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
60
+ } catch {}
61
+ }
@@ -71,7 +71,7 @@ export async function POST(req: Request) {
71
71
  dependsOn: agent.dependsOn.map((d: string) => idMap.get(d) || d),
72
72
  entries: agent.type === 'input' ? [] : undefined,
73
73
  });
74
- state.agentStates[idMap.get(agent.id) || agent.id] = { smithStatus: 'down', mode: 'auto', taskStatus: 'idle', history: [], artifacts: [] };
74
+ state.agentStates[idMap.get(agent.id) || agent.id] = { smithStatus: 'down', taskStatus: 'idle', history: [], artifacts: [] };
75
75
  }
76
76
  if (template.nodePositions) {
77
77
  for (const [oldId, pos] of Object.entries(template.nodePositions)) {
@@ -41,6 +41,15 @@ else
41
41
  echo " ○ Workspace stopped"
42
42
  fi
43
43
 
44
+ # MCP Server (runs inside workspace-standalone)
45
+ mcp_status=$(curl -s http://localhost:8406/health 2>/dev/null)
46
+ if echo "$mcp_status" | grep -q '"ok":true'; then
47
+ sessions=$(echo "$mcp_status" | python3 -c "import sys,json; print(json.load(sys.stdin).get('sessions',0))" 2>/dev/null)
48
+ echo " ● MCP Server running (port: 8406, sessions: $sessions)"
49
+ else
50
+ echo " ○ MCP Server stopped"
51
+ fi
52
+
44
53
  # Cloudflare Tunnel
45
54
  count=$(ps aux | grep 'cloudflared tunnel' | grep -v grep | wc -l | tr -d ' ')
46
55
  pid=$(ps aux | grep 'cloudflared tunnel' | grep -v grep | awk '{print $2}' | head -1)
@@ -9,6 +9,7 @@ interface MonitorData {
9
9
  telegram: { running: boolean; pid: string; startedAt?: string };
10
10
  workspace: { running: boolean; pid: string; startedAt?: string };
11
11
  tunnel: { running: boolean; pid: string; url: string };
12
+ mcp: { running: boolean; port: number; sessions: number };
12
13
  };
13
14
  sessions: { name: string; created: string; attached: boolean; windows: number }[];
14
15
  uptime: string;
@@ -64,9 +65,23 @@ export default function MonitorPanel({ onClose }: { onClose: () => void }) {
64
65
  )}
65
66
  </div>
66
67
  ))}
68
+ {/* MCP Server — separate row (no pid, uses port + sessions) */}
69
+ <div className="flex items-center gap-2 text-xs">
70
+ <span className={data.processes.mcp?.running ? 'text-green-400' : 'text-gray-500'}>●</span>
71
+ <span className="text-[var(--text-primary)] w-28">MCP Server</span>
72
+ {data.processes.mcp?.running ? (
73
+ <>
74
+ <span className="text-[var(--text-secondary)] font-mono text-[10px]">port: {data.processes.mcp.port}</span>
75
+ <span className="text-gray-500 font-mono text-[9px]">{data.processes.mcp.sessions} session(s)</span>
76
+ </>
77
+ ) : (
78
+ <span className="text-gray-500 text-[10px]">stopped</span>
79
+ )}
80
+ </div>
67
81
  {data.processes.tunnel.running && data.processes.tunnel.url && (
68
82
  <div className="pl-6 text-[10px] text-[var(--accent)] truncate">{data.processes.tunnel.url}</div>
69
83
  )}
84
+ <div className="border-t border-[var(--border)] my-1" />
70
85
  </div>
71
86
  </div>
72
87
 
@@ -82,6 +82,9 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
82
82
  const [pipelineBindings, setPipelineBindings] = useState<{ id: number; workflowName: string; enabled: boolean; config: any; lastRunAt: string | null; nextRunAt: string | null }[]>([]);
83
83
  const [pipelineRuns, setPipelineRuns] = useState<{ id: string; workflowName: string; pipelineId: string; status: string; summary: string; dedupKey: string | null; createdAt: string }[]>([]);
84
84
  const [availableWorkflows, setAvailableWorkflows] = useState<{ name: string; description?: string; builtin?: boolean; type?: string }[]>([]);
85
+ const [boundSession, setBoundSession] = useState<{ sessionId: string } | null>(null);
86
+ const [showSessionPicker, setShowSessionPicker] = useState(false);
87
+ const [availableSessions, setAvailableSessions] = useState<{ id: string; modified: string; size: number }[]>([]);
85
88
  const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
86
89
  const [expandedPipeline, setExpandedPipeline] = useState<any>(null);
87
90
  const [showAddPipeline, setShowAddPipeline] = useState(false);
@@ -401,7 +404,26 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
401
404
  // Fetch git info and file tree in parallel
402
405
  fetchGitInfo();
403
406
  fetchTree();
404
- }, [projectPath, fetchGitInfo, fetchTree]);
407
+ // Fetch project-level fixed session
408
+ fetchBoundSession();
409
+ }, [projectPath, fetchGitInfo, fetchTree]); // eslint-disable-line react-hooks/exhaustive-deps
410
+
411
+ const fetchBoundSession = useCallback(() => {
412
+ fetch(`/api/project-sessions?projectPath=${encodeURIComponent(projectPath)}`)
413
+ .then(r => r.json())
414
+ .then(data => {
415
+ if (data?.fixedSessionId) setBoundSession({ sessionId: data.fixedSessionId });
416
+ else setBoundSession(null);
417
+ })
418
+ .catch(() => {});
419
+ }, [projectPath]);
420
+
421
+ // Listen for session binding changes (from SessionView or other components)
422
+ useEffect(() => {
423
+ const handler = () => fetchBoundSession();
424
+ window.addEventListener('forge:session-bound', handler);
425
+ return () => window.removeEventListener('forge:session-bound', handler);
426
+ }, [fetchBoundSession]);
405
427
 
406
428
  // Lazy load tab-specific data only when switching to that tab
407
429
  useEffect(() => {
@@ -443,11 +465,78 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
443
465
  </button>
444
466
  </div>
445
467
  </div>
446
- <div className="text-[9px] text-[var(--text-secondary)] mt-0.5">
447
- {projectPath}
468
+ <div className="text-[9px] text-[var(--text-secondary)] mt-0.5 flex items-center gap-2 flex-wrap">
469
+ <span>{projectPath}</span>
448
470
  {gitInfo?.remote && (
449
- <span className="ml-2">{gitInfo.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}</span>
471
+ <span>{gitInfo.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}</span>
450
472
  )}
473
+ {/* Fixed session: show current or "set session" */}
474
+ <span className="inline-flex items-center gap-1 px-1.5 py-0 rounded bg-[#f0883e]/10 text-[#f0883e] relative">
475
+ {boundSession?.sessionId ? (
476
+ <>
477
+ <span className="text-[8px]">session:</span>
478
+ <button onClick={() => {
479
+ fetch(`/api/claude-sessions/${encodeURIComponent(projectName)}`)
480
+ .then(r => r.json())
481
+ .then(data => { if (Array.isArray(data)) setAvailableSessions(data.map((s: any) => ({ id: s.sessionId || s.id, modified: s.modified || '', size: s.size || 0 }))); })
482
+ .catch(() => {});
483
+ setShowSessionPicker(!showSessionPicker);
484
+ }}
485
+ className="font-mono text-[8px] hover:text-white underline decoration-dotted" title="Click to change">
486
+ {boundSession.sessionId.slice(0, 8)}
487
+ </button>
488
+ <button onClick={() => navigator.clipboard.writeText(boundSession.sessionId)}
489
+ className="text-[7px] hover:text-white" title={boundSession.sessionId}>copy</button>
490
+ </>
491
+ ) : (
492
+ <button onClick={() => {
493
+ fetch(`/api/claude-sessions/${encodeURIComponent(projectName)}`)
494
+ .then(r => r.json())
495
+ .then(data => { if (Array.isArray(data)) setAvailableSessions(data.map((s: any) => ({ id: s.sessionId || s.id, modified: s.modified || '', size: s.size || 0 }))); })
496
+ .catch(() => {});
497
+ setShowSessionPicker(!showSessionPicker);
498
+ }}
499
+ className="text-[8px] hover:text-white underline decoration-dotted">
500
+ set session
501
+ </button>
502
+ )}
503
+ {/* Session picker dropdown */}
504
+ {showSessionPicker && (
505
+ <div className="absolute top-full left-0 mt-1 z-50 bg-[var(--bg-primary)] border border-[var(--border)] rounded-lg shadow-xl p-2 min-w-[280px]"
506
+ onClick={e => e.stopPropagation()}>
507
+ <div className="text-[9px] text-[var(--text-secondary)] mb-1.5 font-medium">{boundSession?.sessionId ? 'Change session' : 'Select session'}</div>
508
+ <div className="max-h-48 overflow-y-auto space-y-1">
509
+ {availableSessions.map(s => (
510
+ <button key={s.id} onClick={async () => {
511
+ try {
512
+ await fetch('/api/project-sessions', {
513
+ method: 'POST',
514
+ headers: { 'Content-Type': 'application/json' },
515
+ body: JSON.stringify({ projectPath, fixedSessionId: s.id }),
516
+ });
517
+ setBoundSession({ sessionId: s.id });
518
+ window.dispatchEvent(new Event('forge:session-bound'));
519
+ } catch {}
520
+ setShowSessionPicker(false);
521
+ }}
522
+ className={`w-full text-left px-2 py-1.5 rounded text-[9px] flex items-center gap-2 ${
523
+ s.id === boundSession?.sessionId
524
+ ? 'bg-[#f0883e]/20 text-[#f0883e]'
525
+ : 'hover:bg-[var(--bg-secondary)] text-[var(--text-secondary)]'
526
+ }`}>
527
+ <span className="font-mono">{s.id.slice(0, 8)}</span>
528
+ {s.id === boundSession?.sessionId && <span className="text-[7px]">current</span>}
529
+ {s.modified && <span className="text-[8px] opacity-60">{(() => { const d = Date.now() - new Date(s.modified).getTime(); return d < 3600000 ? `${Math.floor(d/60000)}m ago` : d < 86400000 ? `${Math.floor(d/3600000)}h ago` : new Date(s.modified).toLocaleDateString(); })()}</span>}
530
+ {s.size > 0 && <span className="text-[8px] opacity-60">{s.size < 1048576 ? `${(s.size/1024).toFixed(0)}KB` : `${(s.size/1048576).toFixed(1)}MB`}</span>}
531
+ </button>
532
+ ))}
533
+ {availableSessions.length === 0 && <div className="text-[9px] text-[var(--text-secondary)] px-2 py-1">No sessions found</div>}
534
+ </div>
535
+ <button onClick={() => setShowSessionPicker(false)}
536
+ className="w-full mt-1.5 text-[8px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] pt-1 border-t border-[var(--border)]">Close</button>
537
+ </div>
538
+ )}
539
+ </span>
451
540
  </div>
452
541
  {/* Tab switcher */}
453
542
  <div className="flex items-center gap-2 mt-1.5">
@@ -1464,7 +1553,12 @@ function AgentTerminalButton({ projectPath, projectName }: { projectPath: string
1464
1553
  </button>
1465
1554
 
1466
1555
  {sessions.length > 0 && (
1467
- <button onClick={() => openWithAgent(launchDialog.agentId, true, undefined, launchDialog.env, launchDialog.model)}
1556
+ <button onClick={async () => {
1557
+ // Use fixedSession if available, otherwise latest session
1558
+ const { resolveFixedSession } = await import('@/lib/session-utils');
1559
+ const fixedId = await resolveFixedSession(projectPath);
1560
+ openWithAgent(launchDialog.agentId, true, fixedId || sessions[0].id, launchDialog.env, launchDialog.model);
1561
+ }}
1468
1562
  className="w-full text-left px-3 py-2 rounded border border-[#30363d] hover:border-[#3fb950] hover:bg-[#161b22] transition-colors">
1469
1563
  <div className="text-xs text-white font-semibold">Resume Latest</div>
1470
1564
  <div className="text-[9px] text-gray-500">{sessions[0].id.slice(0, 8)} · {formatTime(sessions[0].modified)} · {formatSize(sessions[0].size)}</div>
@@ -58,6 +58,8 @@ export default function SessionView({
58
58
  const [batchMode, setBatchMode] = useState(false);
59
59
  const [selectedIds, setSelectedIds] = useState<Map<string, Set<string>>>(new Map());
60
60
  const bottomRef = useRef<HTMLDivElement>(null);
61
+ // Project-level fixed session binding
62
+ const [boundSessions, setBoundSessions] = useState<Record<string, { sessionId: string }>>({});
61
63
 
62
64
  // Load cached sessions tree
63
65
  const loadTree = useCallback(async (force = false) => {
@@ -84,6 +86,21 @@ export default function SessionView({
84
86
  } catch {}
85
87
  }, []);
86
88
 
89
+ // Load project-level fixed session bindings
90
+ const loadBoundSessions = useCallback(async () => {
91
+ try {
92
+ const res = await fetch('/api/project-sessions');
93
+ const all = await res.json(); // { "/path/to/project": "session-uuid", ... }
94
+ const bound: Record<string, { sessionId: string }> = {};
95
+ for (const p of projects) {
96
+ if (all[p.path]) {
97
+ bound[p.name] = { sessionId: all[p.path] };
98
+ }
99
+ }
100
+ setBoundSessions(bound);
101
+ } catch {}
102
+ }, [projects]);
103
+
87
104
  useEffect(() => {
88
105
  // In single-project mode: load cached first (fast), then sync in background
89
106
  if (singleProject) {
@@ -92,7 +109,10 @@ export default function SessionView({
92
109
  loadTree(true);
93
110
  }
94
111
  loadWatchers();
95
- }, [loadTree, loadWatchers, singleProject]);
112
+ // Defer bound session loading to avoid blocking initial render
113
+ const t = setTimeout(loadBoundSessions, 500);
114
+ return () => clearTimeout(t);
115
+ }, [loadTree, loadWatchers, loadBoundSessions, singleProject]);
96
116
 
97
117
  // Auto-expand project if only one or if pre-selected
98
118
  useEffect(() => {
@@ -403,32 +423,60 @@ export default function SessionView({
403
423
  {s.summary || s.firstPrompt?.slice(0, 40) || s.sessionId.slice(0, 8)}
404
424
  </span>
405
425
  {isWatched && <span className="text-[8px] text-[var(--accent)]">👁</span>}
406
- {/* Hover actions — hide in batch mode */}
426
+ </div>
427
+ <div className="flex items-center gap-2 mt-0.5">
428
+ <span className="text-[8px] text-[var(--text-secondary)] font-mono">{s.sessionId.slice(0, 8)}</span>
429
+ {boundSessions[project]?.sessionId === s.sessionId && (
430
+ <span className="text-[7px] px-1 py-0 rounded bg-[#f0883e]/20 text-[#f0883e] font-medium">bound</span>
431
+ )}
432
+ {s.gitBranch && <span className="text-[8px] text-[var(--accent)]">{s.gitBranch}</span>}
433
+ {s.modified && (
434
+ <span className="text-[8px] text-[var(--text-secondary)]">
435
+ {timeAgo(s.modified)}
436
+ </span>
437
+ )}
438
+ {/* Actions — right side */}
407
439
  {!batchMode && (
408
- <span className="hidden group-hover:flex items-center gap-0.5 shrink-0">
440
+ <span className="hidden group-hover:flex items-center gap-0.5 ml-auto shrink-0">
441
+ <button
442
+ onClick={(e) => {
443
+ e.stopPropagation();
444
+ const pp = projects.find(p => p.name === project)?.path || '';
445
+ if (pp && onOpenInTerminal) onOpenInTerminal(s.sessionId, pp);
446
+ else if (pp) window.dispatchEvent(new CustomEvent('forge:open-terminal', { detail: { projectPath: pp, projectName: project, agentId: 'claude', resumeMode: true, sessionId: s.sessionId } }));
447
+ }}
448
+ className="text-[8px] px-1 py-0.5 rounded bg-green-500/10 text-green-400 hover:bg-green-500/20"
449
+ title="Open this session in terminal"
450
+ >open</button>
451
+ {boundSessions[project]?.sessionId !== s.sessionId && (
452
+ <button
453
+ onClick={async (e) => {
454
+ e.stopPropagation();
455
+ const pp = projects.find(p => p.name === project)?.path || '';
456
+ if (!pp) return;
457
+ const res = await fetch('/api/project-sessions', {
458
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
459
+ body: JSON.stringify({ projectPath: pp, fixedSessionId: s.sessionId }),
460
+ });
461
+ if (res.ok) {
462
+ setBoundSessions(prev => ({ ...prev, [project]: { sessionId: s.sessionId } }));
463
+ window.dispatchEvent(new Event('forge:session-bound'));
464
+ }
465
+ }}
466
+ className="text-[8px] px-1 py-0.5 rounded bg-[#f0883e]/10 text-[#f0883e] hover:bg-[#f0883e]/20"
467
+ title="Set as fixed session"
468
+ >bind</button>
469
+ )}
409
470
  <button
410
471
  onClick={(e) => { e.stopPropagation(); createMonitorTask(project, s.sessionId); }}
411
472
  className="text-[8px] px-1 py-0.5 rounded bg-[var(--accent)]/10 text-[var(--accent)] hover:bg-[var(--accent)]/20"
412
- title="Create monitor task (notify via Telegram)"
413
- >
414
- monitor
415
- </button>
473
+ title="Monitor via Telegram"
474
+ >monitor</button>
416
475
  <button
417
476
  onClick={(e) => { e.stopPropagation(); deleteSessionById(project, s.sessionId); }}
418
477
  className="text-[8px] px-1 py-0.5 rounded bg-red-500/10 text-red-400 hover:bg-red-500/20"
419
478
  title="Delete session"
420
- >
421
- del
422
- </button>
423
- </span>
424
- )}
425
- </div>
426
- <div className="flex items-center gap-2 mt-0.5">
427
- <span className="text-[8px] text-[var(--text-secondary)] font-mono">{s.sessionId.slice(0, 8)}</span>
428
- {s.gitBranch && <span className="text-[8px] text-[var(--accent)]">{s.gitBranch}</span>}
429
- {s.modified && (
430
- <span className="text-[8px] text-[var(--text-secondary)]">
431
- {timeAgo(s.modified)}
479
+ >del</button>
432
480
  </span>
433
481
  )}
434
482
  </div>
@@ -310,11 +310,13 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
310
310
  // ─── Imperative handle for parent ─────────────────────
311
311
 
312
312
  useImperativeHandle(ref, () => ({
313
- openSessionInTerminal(sessionId: string, projectPath: string) {
313
+ async openSessionInTerminal(sessionId: string, projectPath: string) {
314
314
  const tree = makeTerminal(undefined, projectPath);
315
315
  const paneId = firstTerminalId(tree);
316
316
  const sf = skipPermissions ? ' --dangerously-skip-permissions' : '';
317
- const cmd = `cd "${projectPath}" && claude --resume ${sessionId}${sf}\n`;
317
+ let mcpFlag = '';
318
+ try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {}
319
+ const cmd = `cd "${projectPath}" && claude --resume ${sessionId}${sf}${mcpFlag}\n`;
318
320
  pendingCommands.set(paneId, cmd);
319
321
  const projectName = projectPath.split('/').pop() || 'Terminal';
320
322
  const newTab: TabState = {
@@ -334,12 +336,21 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
334
336
  const knownClis = ['claude', 'codex', 'aider'];
335
337
  const agentCmd = knownClis.includes(agent) ? agent : 'claude';
336
338
 
337
- // Resume flag from user's choice
339
+ // Resume flag: explicit sessionId > fixedSession (set async below) > -c
338
340
  let resumeFlag = '';
339
341
  if (agentCmd === 'claude') {
340
342
  if (sessionId) resumeFlag = ` --resume ${sessionId}`;
341
343
  else if (resumeMode) resumeFlag = ' -c';
342
344
  }
345
+ // Override with fixedSession if available (async, patched before command is sent)
346
+ let fixedSessionPending: Promise<void> | null = null;
347
+ if (agentCmd === 'claude' && !sessionId && projectPath) {
348
+ fixedSessionPending = import('@/lib/session-utils').then(({ resolveFixedSession }) =>
349
+ resolveFixedSession(projectPath).then(fixedId => {
350
+ if (fixedId) resumeFlag = ` --resume ${fixedId}`;
351
+ })
352
+ ).catch(() => {});
353
+ }
343
354
 
344
355
  // Model flag from profile
345
356
  const modelFlag = profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
@@ -368,6 +379,15 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
368
379
  if (skipPermissions && agentCmd === 'claude') sf = ' --dangerously-skip-permissions';
369
380
  }
370
381
 
382
+ // Wait for fixedSession resolution before building command
383
+ if (fixedSessionPending) await fixedSessionPending;
384
+
385
+ // MCP + env vars for claude-code agents
386
+ let mcpFlag = '';
387
+ if (agentCmd === 'claude' && projectPath) {
388
+ try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {}
389
+ }
390
+
371
391
  let targetTabId: number | null = null;
372
392
 
373
393
  setTabs(prev => {
@@ -378,7 +398,7 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
378
398
  }
379
399
  const tree = makeTerminal(undefined, projectPath);
380
400
  const paneId = firstTerminalId(tree);
381
- pendingCommands.set(paneId, `${envPrefix}cd "${projectPath}" && ${agentCmd}${resumeFlag}${modelFlag}${sf}\n`);
401
+ pendingCommands.set(paneId, `${envPrefix}cd "${projectPath}" && ${agentCmd}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`);
382
402
  const newTab: TabState = {
383
403
  id: nextId++,
384
404
  label: projectName,
@@ -898,13 +918,13 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
898
918
  // Model flag (claude-code only)
899
919
  const modelFlag = info.supportsSession && profileEnv.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
900
920
 
901
- // Resume flag (claude-code only)
921
+ // Resume flag: use fixedSession if available, else -c
902
922
  let resumeFlag = '';
903
923
  if (info.supportsSession) {
904
924
  try {
905
- const sRes = await fetch(`/api/claude-sessions/${encodeURIComponent(p.name)}`);
906
- const sData = await sRes.json();
907
- if (Array.isArray(sData) && sData.length > 0) resumeFlag = ' -c';
925
+ const { resolveFixedSession, buildResumeFlag } = await import('@/lib/session-utils');
926
+ const fixedId = await resolveFixedSession(p.path);
927
+ resumeFlag = buildResumeFlag(fixedId, true);
908
928
  } catch {}
909
929
  }
910
930
 
@@ -917,7 +937,13 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
917
937
  sf = cfg?.skipPermissionsFlag ? ` ${cfg.skipPermissionsFlag}` : (cliCmd === 'claude' ? ' --dangerously-skip-permissions' : '');
918
938
  }
919
939
 
920
- cmd = `${envExports}cd "${p.path}" && ${cliCmd}${resumeFlag}${modelFlag}${sf}\n`;
940
+ // MCP + env vars
941
+ let mcpFlag = '';
942
+ if (cliCmd === 'claude') {
943
+ try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(p.path); } catch {}
944
+ }
945
+
946
+ cmd = `${envExports}cd "${p.path}" && ${cliCmd}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`;
921
947
  } catch {
922
948
  cmd = `cd "${p.path}" && ${a.id}\n`;
923
949
  }
@@ -1448,29 +1474,18 @@ const MemoTerminalPane = memo(function TerminalPane({
1448
1474
  // Auto-run claude for project tabs (only if no pendingCommand already set)
1449
1475
  if (isNewlyCreated && projectPathRef.current && !pendingCommands.has(id)) {
1450
1476
  isNewlyCreated = false;
1451
- // Check if project has existing claude sessions to decide -c flag
1452
1477
  const pp = projectPathRef.current;
1453
- const pName = pp.replace(/\/+$/, '').split('/').pop() || '';
1454
- fetch(`/api/claude-sessions/${encodeURIComponent(pName)}`)
1455
- .then(r => r.json())
1456
- .then(sData => {
1457
- const hasSession = Array.isArray(sData) ? sData.length > 0 : false;
1458
- const resumeFlag = hasSession ? ' -c' : '';
1459
- const skipFlag = skipPermRef.current ? ' --dangerously-skip-permissions' : '';
1460
- setTimeout(() => {
1461
- if (!disposed && ws?.readyState === WebSocket.OPEN) {
1462
- ws.send(JSON.stringify({ type: 'input', data: `cd "${pp}" && claude${resumeFlag}${skipFlag}\n` }));
1463
- }
1464
- }, 300);
1465
- })
1466
- .catch(() => {
1478
+ import('@/lib/session-utils').then(({ resolveFixedSession, buildResumeFlag, getMcpFlag }) => {
1479
+ Promise.all([resolveFixedSession(pp), getMcpFlag(pp)]).then(([fixedId, mcpFlag]) => {
1480
+ const resumeFlag = buildResumeFlag(fixedId, true);
1467
1481
  const skipFlag = skipPermRef.current ? ' --dangerously-skip-permissions' : '';
1468
1482
  setTimeout(() => {
1469
1483
  if (!disposed && ws?.readyState === WebSocket.OPEN) {
1470
- ws.send(JSON.stringify({ type: 'input', data: `cd "${pp}" && claude${skipFlag}\n` }));
1484
+ ws.send(JSON.stringify({ type: 'input', data: `cd "${pp}" && claude${resumeFlag}${skipFlag}${mcpFlag}\n` }));
1471
1485
  }
1472
1486
  }, 300);
1473
1487
  });
1488
+ });
1474
1489
  }
1475
1490
  isNewlyCreated = false;
1476
1491
  // Force tmux to redraw by toggling size, then send reset