@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.
- package/.forge/mcp.json +8 -0
- package/RELEASE_NOTES.md +10 -6
- package/app/api/monitor/route.ts +12 -0
- package/app/api/project-sessions/route.ts +61 -0
- package/app/api/workspace/route.ts +1 -1
- package/check-forge-status.sh +9 -0
- package/components/MonitorPanel.tsx +15 -0
- package/components/ProjectDetail.tsx +99 -5
- package/components/SessionView.tsx +67 -19
- package/components/WebTerminal.tsx +40 -25
- package/components/WorkspaceView.tsx +599 -109
- 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 +774 -90
- package/lib/workspace/persistence.ts +0 -1
- package/lib/workspace/types.ts +10 -6
- package/lib/workspace/watch-manager.ts +17 -7
- package/lib/workspace-standalone.ts +83 -27
- package/next-env.d.ts +1 -1
- package/package.json +4 -2
- package/qa/.forge/mcp.json +8 -0
package/.forge/mcp.json
ADDED
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
# Forge v0.5.
|
|
1
|
+
# Forge v0.5.9
|
|
2
2
|
|
|
3
|
-
Released: 2026-03-
|
|
3
|
+
Released: 2026-03-30
|
|
4
4
|
|
|
5
|
-
## Changes since v0.5.
|
|
5
|
+
## Changes since v0.5.8
|
|
6
6
|
|
|
7
|
-
###
|
|
8
|
-
-
|
|
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
|
-
|
|
14
|
+
|
|
15
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.8...v0.5.9
|
package/app/api/monitor/route.ts
CHANGED
|
@@ -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',
|
|
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)) {
|
package/check-forge-status.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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={() =>
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
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
|
|
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
|
|
921
|
+
// Resume flag: use fixedSession if available, else -c
|
|
902
922
|
let resumeFlag = '';
|
|
903
923
|
if (info.supportsSession) {
|
|
904
924
|
try {
|
|
905
|
-
const
|
|
906
|
-
const
|
|
907
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|