@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.
@@ -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,8 +1,134 @@
1
- # Forge v0.5.6
1
+ # Forge v0.5.8
2
2
 
3
- Released: 2026-03-28
3
+ Released: 2026-03-30
4
4
 
5
- ## Changes since v0.5.5
5
+ ## Changes since v0.5.7
6
6
 
7
+ ### Features
8
+ - feat: Restart Session button for persistentSession agents
9
+ - feat: boundSessionId drives non-primary terminal open flow
10
+ - feat: boundSessionId per agent — each agent has its own last session
11
+ - feat: execution mode determined by config, not runtime tmux state
12
+ - feat: /resolve API for workspace lookup + skills use env var → API fallback
13
+ - feat: switch session button (▾) for non-primary agents with active terminal
14
+ - feat: auto-fallback to headless when terminal startup fails
15
+ - feat: Forge agent monitors all inbox states
16
+ - feat: Forge agent — autonomous bus monitor with periodic scanning
17
+ - feat: Forge agent auto-requests summary when agent completes without reply
18
+ - feat: nudge sender when target agent completes — inject hint into terminal
19
+ - feat: restore auto-reply on completion + keep check_outbox as supplement
20
+ - feat: auto-reply to message sender on completion + check_outbox tool
21
+ - feat: add MCP Server to monitor panel and status script
22
+ - feat: get_agents tool + fuzzy send_message matching
23
+ - feat: inject FORGE env vars at all terminal launch points
24
+ - feat: --mcp-config injected at all claude terminal launch points
25
+ - feat: Forge MCP Server — replace HTTP skills with native tool calls
26
+ - feat: open terminal button on session list items
27
+ - feat: detect CLI startup failures in persistent sessions
28
+ - feat: all terminal entry points use fixedSessionId via --resume
29
+ - feat: bind button on sessions list to set fixedSessionId
30
+ - feat: click-to-change session binding in project header
31
+ - feat: show bound session in project header and session list
32
+ - feat: session picker dropdown for fixedSessionId binding
33
+ - feat: session list shows full ID on expand with copy button
34
+ - feat: show and edit fixedSessionId in agent config modal
35
+ - feat: primary agent setup prompt for new and existing workspaces
36
+ - feat: primary agent checkbox in agent config modal
37
+ - feat: terminal dock — inline right panel with tabs and resizable width
38
+ - feat: VibeCoding uses workspace primary agent's fixed session
39
+ - feat: fixed CLI session binding for primary agent
40
+ - feat: primary agent — terminal-only, fixed session, root directory
41
+ - feat: persistent terminal session per agent
42
+ - feat: watch send_message auto-detects tmux sessions (workspace + VibeCoding)
43
+ - feat: watch send_message injects directly into terminal session
7
44
 
8
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.5...v0.5.6
45
+ ### Bug Fixes
46
+ - fix: unset profile env vars before setting new ones on agent switch
47
+ - fix: normalize workDir paths before encoding session directory
48
+ - fix: session list uses agent's workDir, not project root
49
+ - fix: clear boundSessionId on agent change even without tmux session
50
+ - fix: kill terminal and clear boundSessionId when agent CLI changes
51
+ - fix: rename "Keep terminal session alive" to "Terminal mode"
52
+ - fix: codex skipPermissionsFlag back to --full-auto
53
+ - fix: codex skip flag + -c only when sessions exist
54
+ - fix: set FORGE env vars as separate tmux command before CLI start
55
+ - fix: set FORGE env vars in tmux, skills use env vars not curl lookup
56
+ - fix: mark forge skills as FALLBACK so MCP tools take priority
57
+ - fix: nudge explicitly says use MCP tool, not forge-send skill
58
+ - fix: skills prefer MCP tools, fix subdirectory workspace lookup
59
+ - fix: show headless after Kill, terminal (pending) only before daemon start
60
+ - fix: Suspend vs Kill terminal behavior
61
+ - fix: Forge agent nudge uses stronger language to trigger send_message
62
+ - fix: always resolve agent launch info before opening terminal
63
+ - fix: bus log updates immediately after message delete (no refresh needed)
64
+ - fix: stop daemon kills tmux sessions + Forge agent only scans new messages
65
+ - fix: enlarge session switch button (▾) for easier clicking
66
+ - fix: terminal button visible during running state (was hidden)
67
+ - fix: daemon persistent session uses -c for non-primary agents
68
+ - fix: restore -c flag for non-primary agents to resume latest session
69
+ - fix: Forge agent processes historical pending/running, skips done/failed
70
+ - fix: Forge agent only scans messages after daemon start, ignores history
71
+ - fix: Forge agent restarts message loop for stuck pending messages
72
+ - fix: add skipPermissions to FloatingTerminalInline (was missing)
73
+ - fix: pass skipPermissions to FloatingTerminal, add --dangerously-skip-permissions
74
+ - fix: block terminal open when daemon not started
75
+ - fix: re-open terminal when agent config changes (close old first)
76
+ - fix: don't use claude -c for workspace agents (subdirs may have no session)
77
+ - fix: MCP monitor shows port not pid, add separator after Tunnel
78
+ - fix: add type:'sse' to MCP config (required by Claude Code schema)
79
+ - fix: MCP server port 7830 → 8406 (follows 8403/8404/8405 sequence)
80
+ - fix: MCP agentId resolved by server, not hardcoded in all URLs
81
+ - fix: MAX_ACTIVE limits daemon start, not workspace loading
82
+ - fix: increase MAX_ACTIVE workspaces from 2 to 5
83
+ - fix: move all session action buttons to second row (right-aligned)
84
+ - fix: skip message loop when persistent session fails to start
85
+ - fix: only primary agent uses fixedSession, others use -c or session dialog
86
+ - fix: refresh fixed session display after bind via window event
87
+ - fix: resolveFixedSession auto-binds latest session when not set
88
+ - fix: add error logging to session bind button for debugging
89
+ - fix: session list uses disk files as source of truth, not stale index
90
+ - fix: bind button always visible, set session when none bound
91
+ - fix: limit fixedSessionId auto-bind to only 3 entry points
92
+ - fix: show bind button even when no session is bound yet
93
+ - fix: auto-bind fixedSessionId at Next.js API layer
94
+ - fix: ensure primary session binding at all entry points
95
+ - fix: auto-bind fixedSessionId on agent add and config update
96
+ - fix: show terminal (pending) for persistentSession agents without tmux yet
97
+ - fix: replace nested button with div+span in terminal dock tabs
98
+ - fix: don't auto-open terminal UI on workspace load
99
+ - fix: session binding edge cases
100
+ - fix: auto-bind latest CLI session on upgrade instead of creating new
101
+ - fix: persistent session starts claude with -c (resume last session)
102
+ - fix: verify stored tmuxSession is alive before using, fallback to find
103
+ - fix: findTmuxSession reads terminal-state.json for VibeCoding sessions
104
+ - fix: session watch detects file switch (new session created)
105
+ - fix: getAllAgentStates preserves tmuxSession + currentMessageId
106
+ - fix: persistent session emits state update so frontend knows tmuxSession
107
+ - fix: watch send_message sends configured prompt only + auto-Enter
108
+ - fix: open terminal attaches to existing tmux session regardless of mode
109
+ - fix: session watch only sends last matching entry, not all content
110
+ - fix: remove .js extension from dynamic import for Next.js compat
111
+ - fix: workspace terminal defaults to resume last session (-c)
112
+ - fix: updateAgentConfig rebuilds worker + emits status update
113
+ - fix: CLI backend auto-finds latest session when no sessionId
114
+
115
+ ### Performance
116
+ - perf: cache session binding check, defer SessionView loading
117
+
118
+ ### Refactoring
119
+ - refactor: MCP context via URL params, remove FORGE env var injection
120
+ - refactor: fixedSessionId is project-level, not workspace/agent-level
121
+ - refactor: remove manual/auto mode, unify message execution via tmuxSession
122
+
123
+ ### Documentation
124
+ - revert auto-resume + document persistent terminal session plan
125
+
126
+ ### Other
127
+ - revert: remove orchestrator auto-reply, let agent decide via MCP
128
+ - cleanup: remove fixedSessionId from agent config and workspace state
129
+ - simplify: remove auto-detect session binding complexity
130
+ - revert: restore floating terminal windows, remove dock panel
131
+ - revert auto-resume + document persistent terminal session plan
132
+
133
+
134
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.7...v0.5.8
@@ -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>
@@ -378,6 +378,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
378
378
  ))}
379
379
  </div>
380
380
  </div>
381
+ <span className="text-[8px] px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400">Claude Code</span>
381
382
  <button
382
383
  onClick={sync}
383
384
  disabled={syncing}