@aion0/forge 0.5.21 → 0.5.22

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.
Files changed (39) hide show
  1. package/.forge/agent-context.json +1 -1
  2. package/.forge/mcp.json +1 -1
  3. package/RELEASE_NOTES.md +31 -9
  4. package/app/api/plugins/route.ts +75 -0
  5. package/components/Dashboard.tsx +1 -0
  6. package/components/PipelineEditor.tsx +135 -9
  7. package/components/PluginsPanel.tsx +472 -0
  8. package/components/ProjectDetail.tsx +36 -98
  9. package/components/SessionView.tsx +4 -4
  10. package/components/SettingsModal.tsx +160 -66
  11. package/components/SkillsPanel.tsx +14 -5
  12. package/components/TerminalLauncher.tsx +398 -0
  13. package/components/WebTerminal.tsx +84 -84
  14. package/components/WorkspaceView.tsx +256 -76
  15. package/lib/agents/index.ts +7 -4
  16. package/lib/builtin-plugins/docker.yaml +70 -0
  17. package/lib/builtin-plugins/http.yaml +66 -0
  18. package/lib/builtin-plugins/jenkins.yaml +92 -0
  19. package/lib/builtin-plugins/llm-vision.yaml +85 -0
  20. package/lib/builtin-plugins/playwright.yaml +111 -0
  21. package/lib/builtin-plugins/shell-command.yaml +60 -0
  22. package/lib/builtin-plugins/slack.yaml +48 -0
  23. package/lib/builtin-plugins/webhook.yaml +56 -0
  24. package/lib/forge-mcp-server.ts +116 -2
  25. package/lib/pipeline.ts +62 -5
  26. package/lib/plugins/executor.ts +347 -0
  27. package/lib/plugins/registry.ts +228 -0
  28. package/lib/plugins/types.ts +103 -0
  29. package/lib/project-sessions.ts +7 -2
  30. package/lib/session-utils.ts +7 -3
  31. package/lib/terminal-standalone.ts +6 -34
  32. package/lib/workspace/agent-worker.ts +1 -1
  33. package/lib/workspace/orchestrator.ts +414 -136
  34. package/lib/workspace/presets.ts +5 -3
  35. package/lib/workspace/session-monitor.ts +14 -10
  36. package/lib/workspace/types.ts +3 -1
  37. package/lib/workspace-standalone.ts +38 -21
  38. package/package.json +1 -1
  39. package/qa/.forge/agent-context.json +1 -1
@@ -0,0 +1,398 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * TerminalLauncher — unified terminal session picker and open utilities.
5
+ *
6
+ * Two main exports:
7
+ * 1. TerminalSessionPicker — dialog component for choosing how to open a terminal.
8
+ * Shows: "Current Session" (highlighted), "New Session", expandable list of other sessions.
9
+ *
10
+ * 2. openWorkspaceTerminal / buildProjectTerminalConfig — helpers to open a terminal
11
+ * correctly depending on context (workspace smith vs project/VibeCoding).
12
+ *
13
+ * Workspace smiths:
14
+ * - Need FORGE env vars injected via the forge launch script.
15
+ * - Must go through open_terminal API → daemon creates tmux → FloatingTerminal attaches.
16
+ *
17
+ * Project / VibeCoding:
18
+ * - Build profileEnv client-side from agent profile.
19
+ * - FloatingTerminal creates a new tmux session and runs the CLI.
20
+ */
21
+
22
+ import { useState, useEffect } from 'react';
23
+
24
+ // ─── Types ────────────────────────────────────────────────
25
+
26
+ export interface SessionInfo {
27
+ id: string;
28
+ modified: string; // ISO string
29
+ size: number; // bytes
30
+ }
31
+
32
+ /**
33
+ * Selection result from TerminalSessionPicker.
34
+ * mode='current' → open with currentSessionId (resume)
35
+ * mode='new' → open a fresh session (no --resume)
36
+ * mode='session' → open with a specific sessionId (resume)
37
+ */
38
+ export type PickerSelection =
39
+ | { mode: 'current'; sessionId: string }
40
+ | { mode: 'new' }
41
+ | { mode: 'session'; sessionId: string };
42
+
43
+ // ─── Session Fetchers ─────────────────────────────────────
44
+
45
+ /**
46
+ * Fetch sessions for a workspace agent (workDir-scoped, via workspace API).
47
+ * Used by workspace smith Open Terminal.
48
+ */
49
+ export async function fetchAgentSessions(workspaceId: string, agentId: string): Promise<SessionInfo[]> {
50
+ try {
51
+ const res = await fetch(`/api/workspace/${workspaceId}/smith`, {
52
+ method: 'POST',
53
+ headers: { 'Content-Type': 'application/json' },
54
+ body: JSON.stringify({ action: 'sessions', agentId }),
55
+ });
56
+ const data = await res.json();
57
+ return data.sessions || [];
58
+ } catch {
59
+ return [];
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Fetch sessions for a project (project-level, via claude-sessions API).
65
+ * Used by ProjectDetail terminal button and VibeCoding / SessionView.
66
+ */
67
+ export async function fetchProjectSessions(projectName: string): Promise<SessionInfo[]> {
68
+ try {
69
+ const res = await fetch(`/api/claude-sessions/${encodeURIComponent(projectName)}`);
70
+ const data = await res.json();
71
+ if (!Array.isArray(data)) return [];
72
+ return data.map((s: any) => ({
73
+ id: s.sessionId || s.id || '',
74
+ modified: s.modified || '',
75
+ size: s.fileSize || s.size || 0,
76
+ }));
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+
82
+ // ─── Formatting helpers ───────────────────────────────────
83
+
84
+ function formatRelativeTime(iso: string): string {
85
+ const diff = Date.now() - new Date(iso).getTime();
86
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
87
+ if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
88
+ return new Date(iso).toLocaleDateString();
89
+ }
90
+
91
+ function formatSize(bytes: number): string {
92
+ if (bytes < 1024) return `${bytes}B`;
93
+ if (bytes < 1_048_576) return `${(bytes / 1024).toFixed(0)}KB`;
94
+ return `${(bytes / 1_048_576).toFixed(1)}MB`;
95
+ }
96
+
97
+ // ─── SessionItem ──────────────────────────────────────────
98
+
99
+ function SessionItem({ session, onSelect }: {
100
+ session: SessionInfo;
101
+ onSelect: () => void;
102
+ }) {
103
+ const [expanded, setExpanded] = useState(false);
104
+ const [copied, setCopied] = useState(false);
105
+
106
+ const copyId = (e: React.MouseEvent) => {
107
+ e.stopPropagation();
108
+ navigator.clipboard.writeText(session.id).then(() => {
109
+ setCopied(true);
110
+ setTimeout(() => setCopied(false), 1500);
111
+ });
112
+ };
113
+
114
+ return (
115
+ <div className="rounded border border-[#21262d] hover:border-[#30363d] hover:bg-[#161b22] transition-colors">
116
+ <div className="flex items-center gap-2 px-3 py-1.5 cursor-pointer" onClick={() => setExpanded(!expanded)}>
117
+ <span className="text-[8px] text-gray-600">{expanded ? '▼' : '▶'}</span>
118
+ <span className="text-[9px] text-gray-400 font-mono">{session.id.slice(0, 8)}</span>
119
+ <span className="text-[8px] text-gray-600">{formatRelativeTime(session.modified)}</span>
120
+ <span className="text-[8px] text-gray-600">{formatSize(session.size)}</span>
121
+ <button
122
+ onClick={e => { e.stopPropagation(); onSelect(); }}
123
+ className="ml-auto text-[8px] px-1.5 py-0.5 rounded bg-[#238636]/20 text-[#3fb950] hover:bg-[#238636]/40"
124
+ >
125
+ Resume
126
+ </button>
127
+ </div>
128
+ {expanded && (
129
+ <div className="px-3 pb-2 flex items-center gap-1.5">
130
+ <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">
131
+ {session.id}
132
+ </code>
133
+ <button
134
+ onClick={copyId}
135
+ className="text-[8px] px-1.5 py-0.5 rounded bg-[#30363d] text-gray-400 hover:text-white hover:bg-[#484f58] shrink-0"
136
+ >
137
+ {copied ? '✓' : 'Copy'}
138
+ </button>
139
+ </div>
140
+ )}
141
+ </div>
142
+ );
143
+ }
144
+
145
+ // ─── TerminalSessionPicker ────────────────────────────────
146
+
147
+ /**
148
+ * Unified dialog for choosing how to open a terminal session.
149
+ *
150
+ * Props:
151
+ * agentLabel — Display name for the agent / project (shown in title).
152
+ * currentSessionId — Bound/fixed session to show as "Current Session". null → no current.
153
+ * sessions — List of all available sessions (pre-fetched or lazy). If null, loading spinner shown.
154
+ * supportsSession — Whether the agent supports claude --resume. Default true.
155
+ * onSelect — Called with the picker result when user chooses an option.
156
+ * onCancel — Called when user dismisses without selecting.
157
+ */
158
+ export function TerminalSessionPicker({
159
+ agentLabel,
160
+ currentSessionId,
161
+ sessions,
162
+ supportsSession = true,
163
+ onSelect,
164
+ onCancel,
165
+ }: {
166
+ agentLabel: string;
167
+ currentSessionId: string | null;
168
+ sessions: SessionInfo[] | null; // null = loading
169
+ supportsSession?: boolean;
170
+ onSelect: (selection: PickerSelection) => void;
171
+ onCancel: () => void;
172
+ }) {
173
+ const [showAll, setShowAll] = useState(false);
174
+
175
+ const isClaude = supportsSession !== false;
176
+
177
+ // Other sessions = all sessions except the current one
178
+ const otherSessions = sessions?.filter(s => s.id !== currentSessionId) ?? [];
179
+
180
+ return (
181
+ <div
182
+ className="fixed inset-0 z-50 flex items-center justify-center"
183
+ style={{ background: 'rgba(0,0,0,0.75)' }}
184
+ onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
185
+ >
186
+ <div
187
+ className="w-80 rounded-lg border border-[#30363d] p-4 shadow-xl"
188
+ style={{ background: '#0d1117' }}
189
+ >
190
+ <div className="text-sm font-bold text-white mb-3">⌨️ {agentLabel}</div>
191
+
192
+ <div className="space-y-2">
193
+ {/* Current Session — shown first, highlighted if exists */}
194
+ {isClaude && currentSessionId && (
195
+ <button
196
+ onClick={() => onSelect({ mode: 'current', sessionId: currentSessionId })}
197
+ className="w-full text-left px-3 py-2 rounded border border-[#3fb950]/60 hover:border-[#3fb950] hover:bg-[#161b22] transition-colors"
198
+ >
199
+ <div className="text-xs text-white font-semibold flex items-center gap-1.5">
200
+ <span className="text-[#3fb950]">●</span> Current Session
201
+ </div>
202
+ <div className="text-[9px] text-gray-500 font-mono mt-0.5">
203
+ {currentSessionId.slice(0, 16)}…
204
+ </div>
205
+ </button>
206
+ )}
207
+
208
+ {/* New Session */}
209
+ <button
210
+ onClick={() => onSelect({ mode: 'new' })}
211
+ className="w-full text-left px-3 py-2 rounded border border-[#30363d] hover:border-[#58a6ff] hover:bg-[#161b22] transition-colors"
212
+ >
213
+ <div className="text-xs text-white font-semibold">
214
+ {isClaude ? 'New Session' : 'Open Terminal'}
215
+ </div>
216
+ <div className="text-[9px] text-gray-500">
217
+ {isClaude ? 'Start fresh claude session' : 'Launch terminal'}
218
+ </div>
219
+ </button>
220
+
221
+ {/* Loading indicator */}
222
+ {isClaude && sessions === null && (
223
+ <div className="text-[9px] text-gray-600 text-center py-1">Loading sessions…</div>
224
+ )}
225
+
226
+ {/* Toggle for other sessions */}
227
+ {isClaude && otherSessions.length > 0 && (
228
+ <button
229
+ onClick={() => setShowAll(!showAll)}
230
+ className="w-full text-[9px] text-gray-500 hover:text-white py-1"
231
+ >
232
+ {showAll ? '▼' : '▶'} Other sessions ({otherSessions.length})
233
+ </button>
234
+ )}
235
+
236
+ {/* Other sessions list */}
237
+ {showAll && otherSessions.map(s => (
238
+ <SessionItem
239
+ key={s.id}
240
+ session={s}
241
+ onSelect={() => onSelect({ mode: 'session', sessionId: s.id })}
242
+ />
243
+ ))}
244
+ </div>
245
+
246
+ <button
247
+ onClick={onCancel}
248
+ className="w-full mt-3 text-[9px] text-gray-500 hover:text-white"
249
+ >
250
+ Cancel
251
+ </button>
252
+ </div>
253
+ </div>
254
+ );
255
+ }
256
+
257
+ // ─── TerminalSessionPicker with lazy fetch ─────────────────
258
+
259
+ /**
260
+ * Higher-level picker that fetches sessions automatically.
261
+ * Accepts a `fetchSessions` async function — result populates the session list.
262
+ */
263
+ export function TerminalSessionPickerLazy({
264
+ agentLabel,
265
+ currentSessionId,
266
+ fetchSessions,
267
+ supportsSession = true,
268
+ onSelect,
269
+ onCancel,
270
+ }: {
271
+ agentLabel: string;
272
+ currentSessionId: string | null;
273
+ fetchSessions: () => Promise<SessionInfo[]>;
274
+ supportsSession?: boolean;
275
+ onSelect: (selection: PickerSelection) => void;
276
+ onCancel: () => void;
277
+ }) {
278
+ const [sessions, setSessions] = useState<SessionInfo[] | null>(null);
279
+
280
+ useEffect(() => {
281
+ if (!supportsSession) {
282
+ setSessions([]);
283
+ return;
284
+ }
285
+ fetchSessions().then(setSessions).catch(() => setSessions([]));
286
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
287
+
288
+ return (
289
+ <TerminalSessionPicker
290
+ agentLabel={agentLabel}
291
+ currentSessionId={currentSessionId}
292
+ sessions={sessions}
293
+ supportsSession={supportsSession}
294
+ onSelect={onSelect}
295
+ onCancel={onCancel}
296
+ />
297
+ );
298
+ }
299
+
300
+ // ─── Workspace Terminal Open ──────────────────────────────
301
+
302
+ /**
303
+ * Result from resolving how to open a workspace terminal.
304
+ * When tmuxSession is set → FloatingTerminal should attach (existingSession=tmuxSession).
305
+ * When tmuxSession is null → daemon couldn't create session; fall back to dialog or skip.
306
+ */
307
+ export interface WorkspaceTerminalInfo {
308
+ tmuxSession: string | null;
309
+ cliCmd?: string;
310
+ cliType?: string;
311
+ supportsSession?: boolean;
312
+ }
313
+
314
+ /**
315
+ * Ask the orchestrator to create/find the tmux session for a workspace agent.
316
+ * Returns tmuxSession name that FloatingTerminal can attach to.
317
+ * This is the ONLY correct way to open a workspace terminal — ensures FORGE env vars
318
+ * are injected via the forge launch script (not client-side profileEnv).
319
+ */
320
+ export async function resolveWorkspaceTerminal(
321
+ workspaceId: string,
322
+ agentId: string,
323
+ ): Promise<WorkspaceTerminalInfo> {
324
+ try {
325
+ const res = await fetch(`/api/workspace/${workspaceId}/smith`, {
326
+ method: 'POST',
327
+ headers: { 'Content-Type': 'application/json' },
328
+ body: JSON.stringify({ action: 'open_terminal', agentId }),
329
+ });
330
+ const data = await res.json();
331
+ return {
332
+ tmuxSession: data.tmuxSession || null,
333
+ cliCmd: data.cliCmd,
334
+ cliType: data.cliType,
335
+ supportsSession: data.supportsSession ?? true,
336
+ };
337
+ } catch {
338
+ return { tmuxSession: null };
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Resolve agent info for a workspace agent (resolveOnly — no session created).
344
+ * Used to get cliCmd, cliType, env, model, supportsSession without side effects.
345
+ */
346
+ export async function resolveWorkspaceAgentInfo(
347
+ workspaceId: string,
348
+ agentId: string,
349
+ ): Promise<{ cliCmd?: string; cliType?: string; env?: Record<string, string>; model?: string; supportsSession?: boolean }> {
350
+ try {
351
+ const res = await fetch(`/api/workspace/${workspaceId}/smith`, {
352
+ method: 'POST',
353
+ headers: { 'Content-Type': 'application/json' },
354
+ body: JSON.stringify({ action: 'open_terminal', agentId, resolveOnly: true }),
355
+ });
356
+ return await res.json();
357
+ } catch {
358
+ return {};
359
+ }
360
+ }
361
+
362
+ // ─── Project Terminal Config ──────────────────────────────
363
+
364
+ /**
365
+ * Result for opening a project terminal.
366
+ * FloatingTerminal uses profileEnv + resumeSessionId when creating a new tmux session.
367
+ */
368
+ export interface ProjectTerminalConfig {
369
+ profileEnv: Record<string, string>;
370
+ resumeSessionId?: string;
371
+ cliCmd?: string;
372
+ cliType?: string;
373
+ }
374
+
375
+ /**
376
+ * Build config for opening a project terminal (VibeCoding / ProjectDetail).
377
+ * Agent env and model are resolved server-side via /api/agents?resolve=<agentId>.
378
+ * FORGE vars are NOT included here — project terminals don't use workspace context.
379
+ */
380
+ export async function buildProjectTerminalConfig(
381
+ agentId: string,
382
+ resumeSessionId?: string,
383
+ ): Promise<ProjectTerminalConfig> {
384
+ try {
385
+ const res = await fetch(`/api/agents?resolve=${encodeURIComponent(agentId)}`);
386
+ const info = await res.json();
387
+ const profileEnv: Record<string, string> = { ...(info.env || {}) };
388
+ if (info.model) profileEnv.CLAUDE_MODEL = info.model;
389
+ return {
390
+ profileEnv,
391
+ resumeSessionId,
392
+ cliCmd: info.cliCmd,
393
+ cliType: info.cliType,
394
+ };
395
+ } catch {
396
+ return { profileEnv: {}, resumeSessionId };
397
+ }
398
+ }
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useEffect, useRef, useCallback, memo, useImperativeHandle, forwardRef } from 'react';
4
+ import { TerminalSessionPickerLazy, fetchProjectSessions } from './TerminalLauncher';
4
5
  import { Terminal } from '@xterm/xterm';
5
6
  import { FitAddon } from '@xterm/addon-fit';
6
7
  import { WebglAddon } from '@xterm/addon-webgl';
@@ -219,6 +220,7 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
219
220
  const [refreshKeys, setRefreshKeys] = useState<Record<number, number>>({});
220
221
  const [tabCodeOpen, setTabCodeOpen] = useState<Record<number, boolean>>({});
221
222
  const [showNewTabModal, setShowNewTabModal] = useState(false);
223
+ const [vibePickerInfo, setVibePickerInfo] = useState<{ projectPath: string; projectName: string; agentId: string; profileEnv?: Record<string, string>; supportsSession: boolean; currentSessionId: string | null } | null>(null);
222
224
  const [projectRoots, setProjectRoots] = useState<string[]>([]);
223
225
  const [allProjects, setAllProjects] = useState<{ name: string; path: string; root: string }[]>([]);
224
226
  const [skipPermissions, setSkipPermissions] = useState(false);
@@ -332,24 +334,42 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
332
334
  },
333
335
  async openProjectTerminal(projectPath: string, projectName: string, agentId?: string, resumeMode?: boolean, sessionId?: string, profileEnv?: Record<string, string>) {
334
336
  const agent = agentId || 'claude';
335
- // Resolve CLI command — profiles use base agent's binary
336
- const knownClis = ['claude', 'codex', 'aider'];
337
- const agentCmd = knownClis.includes(agent) ? agent : 'claude';
338
337
 
339
- // Resume flag: explicit sessionId > fixedSession (set async below) > -c
338
+ // Resolve agent info via API get correct cliCmd, cliType, supportsSession
339
+ let agentCmd = 'claude';
340
+ let supportsSession = true;
341
+ let agentSkipFlag = '';
342
+ try {
343
+ const resolveRes = await fetch(`/api/agents?resolve=${encodeURIComponent(agent)}`);
344
+ const info = await resolveRes.json();
345
+ agentCmd = info.cliCmd || 'claude';
346
+ supportsSession = info.supportsSession ?? true;
347
+ // Merge profile env if not already provided
348
+ if (!profileEnv && (info.env || info.model)) {
349
+ const pe: Record<string, string> = { ...(info.env || {}) };
350
+ if (info.model) pe.CLAUDE_MODEL = info.model;
351
+ profileEnv = pe;
352
+ }
353
+ // Get skip-permissions flag from agent config
354
+ const agentsRes = await fetch('/api/agents');
355
+ const agentsData = await agentsRes.json();
356
+ const agentConfig = (agentsData.agents || []).find((a: any) => a.id === agent);
357
+ agentSkipFlag = agentConfig?.skipPermissionsFlag || '';
358
+ } catch {}
359
+
360
+ // Resume flag: explicit sessionId > fixedSession > -c (only for session-capable agents)
340
361
  let resumeFlag = '';
341
- if (agentCmd === 'claude') {
362
+ if (supportsSession) {
342
363
  if (sessionId) resumeFlag = ` --resume ${sessionId}`;
343
364
  else if (resumeMode) resumeFlag = ' -c';
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 => {
365
+ // Override with fixedSession if no explicit sessionId
366
+ if (!sessionId && projectPath) {
367
+ try {
368
+ const { resolveFixedSession } = await import('@/lib/session-utils');
369
+ const fixedId = await resolveFixedSession(projectPath);
350
370
  if (fixedId) resumeFlag = ` --resume ${fixedId}`;
351
- })
352
- ).catch(() => {});
371
+ } catch {}
372
+ }
353
373
  }
354
374
 
355
375
  // Model flag from profile
@@ -364,25 +384,13 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
364
384
  : '';
365
385
  const envPrefix = envExports ? envExports + ' && ' : '';
366
386
 
367
- // Get skip-permissions flag
387
+ // Skip-permissions flag
368
388
  let sf = '';
369
- try {
370
- const agentRes = await fetch('/api/agents');
371
- const agentData = await agentRes.json();
372
- const agentConfig = (agentData.agents || []).find((a: any) => a.id === agent);
373
- if (agentConfig?.skipPermissionsFlag && skipPermissions) {
374
- sf = ` ${agentConfig.skipPermissionsFlag}`;
375
- } else if (skipPermissions && agentCmd === 'claude') {
376
- sf = ' --dangerously-skip-permissions';
377
- }
378
- } catch {
379
- if (skipPermissions && agentCmd === 'claude') sf = ' --dangerously-skip-permissions';
389
+ if (skipPermissions) {
390
+ sf = agentSkipFlag ? ` ${agentSkipFlag}` : (agentCmd === 'claude' ? ' --dangerously-skip-permissions' : '');
380
391
  }
381
392
 
382
- // Wait for fixedSession resolution before building command
383
- if (fixedSessionPending) await fixedSessionPending;
384
-
385
- // MCP + env vars for claude-code agents
393
+ // MCP config for claude-code agents
386
394
  let mcpFlag = '';
387
395
  if (agentCmd === 'claude' && projectPath) {
388
396
  try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {}
@@ -391,7 +399,8 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
391
399
  let targetTabId: number | null = null;
392
400
 
393
401
  setTabs(prev => {
394
- const existing = prev.find(t => t.projectPath === projectPath);
402
+ // Reuse existing tab only if same project AND same agent
403
+ const existing = prev.find(t => t.projectPath === projectPath && (!t.agent || t.agent === agent));
395
404
  if (existing) {
396
405
  targetTabId = existing.id;
397
406
  return prev;
@@ -401,11 +410,12 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
401
410
  pendingCommands.set(paneId, `${envPrefix}cd "${projectPath}" && ${agentCmd}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`);
402
411
  const newTab: TabState = {
403
412
  id: nextId++,
404
- label: projectName,
413
+ label: agent !== 'claude' ? `${projectName} (${agentCmd})` : projectName,
405
414
  tree,
406
415
  ratios: {},
407
416
  activeId: paneId,
408
417
  projectPath,
418
+ agent,
409
419
  };
410
420
  targetTabId = newTab.id;
411
421
  return [...prev, newTab];
@@ -900,59 +910,31 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
900
910
  agents={availableAgents}
901
911
  defaultAgentId={defaultAgentId}
902
912
  onSelect={async (a) => {
903
- setShowNewTabModal(false); setExpandedRoot(null);
904
- let cmd: string;
905
- try {
906
- // Resolve terminal launch info (reads profile env/model)
907
- const resolveRes = await fetch(`/api/agents?resolve=${encodeURIComponent(a.id)}`);
908
- const info = await resolveRes.json();
909
- const cliCmd = info.cliCmd || 'claude';
910
-
911
- // Build env exports from profile
912
- const profileEnv = { ...(info.env || {}), ...(info.model ? { CLAUDE_MODEL: info.model } : {}) };
913
- const envEntries = Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL');
914
- const envExports = envEntries.length > 0
915
- ? envEntries.map(([k, v]) => `export ${k}="${v}"`).join(' && ') + ' && '
916
- : '';
917
-
918
- // Model flag (claude-code only)
919
- const modelFlag = info.supportsSession && profileEnv.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
920
-
921
- // Resume flag: use fixedSession if available, else -c
922
- let resumeFlag = '';
923
- if (info.supportsSession) {
924
- try {
925
- const { resolveFixedSession, buildResumeFlag } = await import('@/lib/session-utils');
926
- const fixedId = await resolveFixedSession(p.path);
927
- resumeFlag = buildResumeFlag(fixedId, true);
928
- } catch {}
929
- }
930
-
931
- // Skip permissions flag
932
- let sf = '';
933
- if (skipPermissions) {
934
- const agentRes = await fetch('/api/agents');
935
- const agentData = await agentRes.json();
936
- const cfg = (agentData.agents || []).find((ag: any) => ag.id === a.id);
937
- sf = cfg?.skipPermissionsFlag ? ` ${cfg.skipPermissionsFlag}` : (cliCmd === 'claude' ? ' --dangerously-skip-permissions' : '');
938
- }
939
-
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`;
947
- } catch {
948
- cmd = `cd "${p.path}" && ${a.id}\n`;
949
- }
950
- const tree = makeTerminal(undefined, p.path);
951
- const paneId = firstTerminalId(tree);
952
- pendingCommands.set(paneId, cmd);
953
- const newTab: TabState = { id: nextId++, label: p.name || 'Terminal', tree, ratios: {}, activeId: paneId, projectPath: p.path, agent: a.id };
954
- setTabs(prev => [...prev, newTab]);
955
- setActiveTabId(newTab.id);
913
+ setShowNewTabModal(false); setExpandedRoot(null);
914
+ try {
915
+ const resolveRes = await fetch(`/api/agents?resolve=${encodeURIComponent(a.id)}`);
916
+ const info = await resolveRes.json();
917
+ const profileEnv: Record<string, string> = { ...(info.env || {}) };
918
+ if (info.model) profileEnv.CLAUDE_MODEL = info.model;
919
+ let currentSessionId: string | null = null;
920
+ if (info.supportsSession) {
921
+ try {
922
+ const { resolveFixedSession } = await import('@/lib/session-utils');
923
+ currentSessionId = await resolveFixedSession(p.path) || null;
924
+ } catch {}
925
+ }
926
+ setVibePickerInfo({
927
+ projectPath: p.path, projectName: p.name, agentId: a.id,
928
+ profileEnv: Object.keys(profileEnv).length > 0 ? profileEnv : undefined,
929
+ supportsSession: info.supportsSession ?? true,
930
+ currentSessionId,
931
+ });
932
+ } catch {
933
+ // Fallback: open directly without picker
934
+ window.dispatchEvent(new CustomEvent('forge:open-terminal', {
935
+ detail: { projectPath: p.path, projectName: p.name, agentId: a.id },
936
+ }));
937
+ }
956
938
  }}
957
939
  />
958
940
  </div>
@@ -1019,6 +1001,24 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
1019
1001
  </div>
1020
1002
  )}
1021
1003
 
1004
+ {/* VibeCoding Terminal Session Picker */}
1005
+ {vibePickerInfo && (
1006
+ <TerminalSessionPickerLazy
1007
+ agentLabel={vibePickerInfo.projectName}
1008
+ currentSessionId={vibePickerInfo.currentSessionId}
1009
+ supportsSession={vibePickerInfo.supportsSession}
1010
+ fetchSessions={() => fetchProjectSessions(vibePickerInfo.projectName)}
1011
+ onSelect={(sel) => {
1012
+ const info = vibePickerInfo;
1013
+ setVibePickerInfo(null);
1014
+ const detail: any = { projectPath: info.projectPath, projectName: info.projectName, agentId: info.agentId, profileEnv: info.profileEnv };
1015
+ if (sel.mode !== 'new') { detail.resumeMode = true; detail.sessionId = sel.sessionId; }
1016
+ window.dispatchEvent(new CustomEvent('forge:open-terminal', { detail }));
1017
+ }}
1018
+ onCancel={() => setVibePickerInfo(null)}
1019
+ />
1020
+ )}
1021
+
1022
1022
  {/* Terminal panes — render all tabs, hide inactive */}
1023
1023
  {tabs.map(tab => (
1024
1024
  <div key={tab.id} className={`flex-1 min-h-0 ${tab.id === activeTabId ? '' : 'hidden'}`}>