@aion0/forge 0.10.78 → 0.10.80

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 (38) hide show
  1. package/RELEASE_NOTES.md +9 -8
  2. package/app/api/code/route.ts +171 -54
  3. package/app/api/onboarding/route.ts +32 -0
  4. package/app/api/skills/local/route.ts +5 -4
  5. package/app/api/tasks/[id]/hook/stop/route.ts +15 -0
  6. package/app/api/tasks/route.ts +2 -1
  7. package/cli/mw.mjs +7 -5
  8. package/cli/mw.ts +8 -6
  9. package/components/CodeViewer.tsx +127 -41
  10. package/components/Dashboard.tsx +6 -2
  11. package/components/DocsViewer.tsx +34 -22
  12. package/components/HelpTerminal.tsx +9 -5
  13. package/components/OnboardingWizard.tsx +65 -1
  14. package/components/ProjectDetail.tsx +33 -7
  15. package/components/TaskDetail.tsx +28 -1
  16. package/components/TmuxTaskTerminal.tsx +105 -0
  17. package/components/WebTerminal.tsx +26 -8
  18. package/components/WorkspaceView.tsx +68 -47
  19. package/docs/design_automation_records/Automation Redesign.dc.html +2019 -0
  20. package/docs/design_automation_records/README.md +232 -0
  21. package/lib/agents/index.ts +9 -0
  22. package/lib/chat/agent-loop.ts +6 -0
  23. package/lib/chat/tool-dispatcher.ts +110 -9
  24. package/lib/fileTree.ts +28 -0
  25. package/lib/help-docs/01-settings.md +11 -0
  26. package/lib/help-docs/05-pipelines.md +31 -0
  27. package/lib/help-docs/07-projects.md +3 -1
  28. package/lib/help-docs/25-chat-tools.md +23 -0
  29. package/lib/pipeline.ts +27 -3
  30. package/lib/session-utils.ts +19 -0
  31. package/lib/task-manager.ts +73 -3
  32. package/lib/task-tmux-backend.ts +625 -0
  33. package/lib/terminal-standalone.ts +17 -0
  34. package/lib/workspace/skill-installer.ts +18 -8
  35. package/package.json +1 -1
  36. package/proxy.ts +5 -4
  37. package/src/core/db/database.ts +1 -0
  38. package/src/types/index.ts +3 -0
@@ -1,10 +1,12 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useRef, memo, useCallback, useMemo } from 'react';
3
+ import { useState, useEffect, useRef, memo, useCallback, useMemo, lazy, Suspense } from 'react';
4
4
  import MarkdownContent from './MarkdownContent';
5
5
  import NewTaskModal from './NewTaskModal';
6
6
  import type { Task, TaskLogEntry } from '@/src/types';
7
7
 
8
+ const TmuxTaskTerminal = lazy(() => import('./TmuxTaskTerminal'));
9
+
8
10
  // Bound the rendered log/diff to keep React from choking on huge sessions.
9
11
  // Each LogEntry can include MarkdownContent and tool_use payloads (often
10
12
  // kilobytes per entry); rendering even ~200 fat entries can take a beat.
@@ -29,6 +31,7 @@ export default function TaskDetail({
29
31
  const [detailLoading, setDetailLoading] = useState(false);
30
32
  const [loadingMore, setLoadingMore] = useState(false);
31
33
  const [tab, setTab] = useState<'log' | 'diff' | 'result'>('log');
34
+ const [showSession, setShowSession] = useState(false);
32
35
  const [expandedTools, setExpandedTools] = useState<Set<number>>(new Set());
33
36
  const [followUpText, setFollowUpText] = useState('');
34
37
  const [editing, setEditing] = useState(false);
@@ -166,6 +169,15 @@ export default function TaskDetail({
166
169
  <span className="text-[10px] text-[var(--text-secondary)] font-mono">{task.id}</span>
167
170
  </div>
168
171
  <div className="flex items-center gap-2">
172
+ {task.backend === 'tmux' && (
173
+ <button
174
+ onClick={() => setShowSession(s => !s)}
175
+ className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${showSession ? 'text-white bg-violet-500 border-violet-500' : 'text-violet-400 border-violet-400/30 hover:bg-violet-400 hover:text-white'}`}
176
+ title="Toggle tmux session panel"
177
+ >
178
+ ⌨ Session
179
+ </button>
180
+ )}
169
181
  <button onClick={() => setEditing(true)} className="text-[10px] px-2 py-0.5 text-[var(--accent)] border border-[var(--accent)]/30 rounded hover:bg-[var(--accent)] hover:text-white">
170
182
  Edit
171
183
  </button>
@@ -191,6 +203,12 @@ export default function TaskDetail({
191
203
  {task.startedAt && <span>Started: {new Date(task.startedAt).toLocaleString()}</span>}
192
204
  {task.completedAt && <span>Completed: {new Date(task.completedAt).toLocaleString()}</span>}
193
205
  {task.costUSD != null && <span>Cost: ${task.costUSD.toFixed(4)}</span>}
206
+ {task.backend === 'tmux' && (
207
+ <span className="px-1.5 py-0.5 rounded bg-violet-500/15 text-violet-400 font-medium">tmux</span>
208
+ )}
209
+ {task.agent && task.agent !== 'claude' && (
210
+ <span className="font-mono text-[var(--text-secondary)]">{task.agent}</span>
211
+ )}
194
212
  </div>
195
213
  </div>
196
214
 
@@ -318,6 +336,15 @@ export default function TaskDetail({
318
336
  </div>
319
337
  )}
320
338
 
339
+ {/* Tmux session panel — shown at the bottom when ⌨ Session is toggled */}
340
+ {showSession && task.backend === 'tmux' && (
341
+ <div className="border-t border-violet-500/30 shrink-0" style={{ height: 320 }}>
342
+ <Suspense fallback={<div className="p-3 text-[var(--text-secondary)] text-xs">Loading terminal…</div>}>
343
+ <TmuxTaskTerminal taskId={task.id} />
344
+ </Suspense>
345
+ </div>
346
+ )}
347
+
321
348
  {editing && (
322
349
  <NewTaskModal
323
350
  editTask={{ id: task.id, projectName: task.projectName, prompt: task.prompt, priority: task.priority, mode: task.mode, scheduledAt: task.scheduledAt }}
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+
5
+ function getWsUrl(): string {
6
+ if (typeof window === 'undefined') return 'ws://localhost:8404';
7
+ const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
8
+ const webPort = parseInt(window.location.port) || 8403;
9
+ return `${proto}//${window.location.hostname}:${webPort + 1}`;
10
+ }
11
+
12
+ export default function TmuxTaskTerminal({ taskId }: { taskId: string }) {
13
+ const containerRef = useRef<HTMLDivElement>(null);
14
+
15
+ useEffect(() => {
16
+ const el = containerRef.current;
17
+ if (!el) return;
18
+
19
+ let disposed = false;
20
+ let termRef: import('@xterm/xterm').Terminal | null = null;
21
+ let wsRef: WebSocket | null = null;
22
+ let roRef: ResizeObserver | null = null;
23
+
24
+ Promise.all([
25
+ import('@xterm/xterm'),
26
+ import('@xterm/addon-fit'),
27
+ ]).then(([{ Terminal }, { FitAddon }]) => {
28
+ if (disposed) return;
29
+
30
+ const cs = getComputedStyle(document.documentElement);
31
+ const tv = (n: string) => cs.getPropertyValue(n).trim();
32
+
33
+ const term = new Terminal({
34
+ cursorBlink: true,
35
+ fontSize: 13,
36
+ fontFamily: 'Menlo, Monaco, "Courier New", monospace',
37
+ scrollback: 10000,
38
+ theme: {
39
+ background: tv('--term-bg') || '#0d1117',
40
+ foreground: tv('--term-fg') || '#c9d1d9',
41
+ cursor: tv('--term-cursor') || '#7c5bf0',
42
+ },
43
+ });
44
+ termRef = term;
45
+
46
+ const fitAddon = new FitAddon();
47
+ term.loadAddon(fitAddon);
48
+ term.open(el);
49
+ setTimeout(() => { try { fitAddon.fit(); } catch {} }, 50);
50
+
51
+ const ro = new ResizeObserver(() => {
52
+ try { fitAddon.fit(); } catch {}
53
+ if (wsRef?.readyState === WebSocket.OPEN) {
54
+ wsRef.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
55
+ }
56
+ });
57
+ roRef = ro;
58
+ ro.observe(el);
59
+
60
+ const sessionName = `fgt-${taskId}`;
61
+ const ws = new WebSocket(getWsUrl());
62
+ wsRef = ws;
63
+
64
+ ws.onopen = () => {
65
+ if (!disposed) {
66
+ ws.send(JSON.stringify({ type: 'attach', sessionName, cols: term.cols, rows: term.rows }));
67
+ }
68
+ };
69
+ ws.onmessage = (e) => {
70
+ try {
71
+ const msg = JSON.parse(e.data);
72
+ if (msg.type === 'output') term.write(msg.data);
73
+ } catch {}
74
+ };
75
+ ws.onclose = () => {
76
+ if (!disposed) term.write('\r\n\x1b[90m[disconnected — session may have ended]\x1b[0m\r\n');
77
+ };
78
+ ws.onerror = () => {
79
+ if (!disposed) term.write('\r\n\x1b[91m[connection error]\x1b[0m\r\n');
80
+ };
81
+
82
+ term.onData(data => {
83
+ if (wsRef?.readyState === WebSocket.OPEN) {
84
+ wsRef.send(JSON.stringify({ type: 'input', data }));
85
+ }
86
+ });
87
+ });
88
+
89
+ return () => {
90
+ disposed = true;
91
+ roRef?.disconnect();
92
+ wsRef?.close();
93
+ termRef?.dispose();
94
+ };
95
+ }, [taskId]);
96
+
97
+ return (
98
+ <div className="flex flex-col h-full bg-[#0d1117]">
99
+ <div className="text-[10px] text-[var(--text-secondary)] px-3 py-1 border-b border-[var(--border)] shrink-0 font-mono">
100
+ fgt-{taskId}
101
+ </div>
102
+ <div ref={containerRef} className="flex-1 overflow-hidden p-1" />
103
+ </div>
104
+ );
105
+ }
@@ -14,6 +14,7 @@ import '@xterm/xterm/css/xterm.css';
14
14
  export interface WebTerminalHandle {
15
15
  openSessionInTerminal: (sessionId: string, projectPath: string) => void;
16
16
  openProjectTerminal: (projectPath: string, projectName: string, agentId?: string, resumeMode?: boolean, sessionId?: string, profileEnv?: Record<string, string>) => void;
17
+ openExistingSession: (sessionName: string, label: string) => void;
17
18
  }
18
19
 
19
20
  export interface WebTerminalProps {
@@ -209,6 +210,9 @@ function MouseToggle() {
209
210
  // ─── Pending commands for new terminal panes ────────────────
210
211
 
211
212
  const pendingCommands = new Map<number, string>();
213
+ // Secret env (API keys) to inject via `tmux set-environment` on connect, so the
214
+ // launch command never types `export KEY=value` into the pane (no echo/history).
215
+ const pendingEnv = new Map<number, Record<string, string>>();
212
216
 
213
217
  // ─── Bell notification tracking ─────────────────────────────
214
218
 
@@ -439,14 +443,14 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
439
443
  // Model flag from profile
440
444
  const modelFlag = profileEnv?.CLAUDE_MODEL ? ` --model ${profileEnv.CLAUDE_MODEL}` : '';
441
445
 
442
- // Build env exports from profile (exclude CLAUDE_MODEL — passed via --model)
443
- const envExports = profileEnv
444
- ? Object.entries(profileEnv)
445
- .filter(([k]) => k !== 'CLAUDE_MODEL')
446
- .map(([k, v]) => `export ${k}="${v}"`)
447
- .join(' && ')
448
- : '';
449
- const envPrefix = envExports ? envExports + ' && ' : '';
446
+ // Build env injection from profile (exclude CLAUDE_MODEL — passed via
447
+ // --model). Secret values go via `tmux set-environment` (sent on connect);
448
+ // the prefix only references var NAMES so keys never echo into the pane.
449
+ const envEntries = profileEnv
450
+ ? Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
451
+ : [];
452
+ const { tmuxEnvPrefix } = await import('@/lib/session-utils');
453
+ const envPrefix = tmuxEnvPrefix(envEntries.map(([k]) => k));
450
454
 
451
455
  // Skip-permissions flag. agent === 'claude' means the agent ID is
452
456
  // the base claude — not the resolved path. The check must compare
@@ -479,6 +483,7 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
479
483
  const tree = makeTerminal(undefined, projectPath);
480
484
  const paneId = firstTerminalId(tree);
481
485
  pendingCommands.set(paneId, `${envPrefix}cd "${projectPath}" && ${quotedCmd}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`);
486
+ if (envEntries.length) pendingEnv.set(paneId, Object.fromEntries(envEntries));
482
487
  const newTab: TabState = {
483
488
  id: nextId++,
484
489
  label: agent !== 'claude' ? `${projectName} (${agent})` : projectName,
@@ -497,6 +502,12 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
497
502
  if (targetTabId !== null) setActiveTabId(targetTabId);
498
503
  }, 0);
499
504
  },
505
+ openExistingSession(sessionName: string, label: string) {
506
+ const tree = makeTerminal(sessionName);
507
+ const newTab: TabState = { id: nextId++, label, tree, ratios: {}, activeId: firstTerminalId(tree) };
508
+ setTabs(prev => [...prev, newTab]);
509
+ setTimeout(() => setActiveTabId(newTab.id), 0);
510
+ },
500
511
  }), [skipPermissions]);
501
512
 
502
513
  // ─── Tab operations ───────────────────────────────────
@@ -1613,6 +1624,13 @@ const MemoTerminalPane = memo(function TerminalPane({
1613
1624
  const cmd = pendingCommands.get(id);
1614
1625
  if (cmd) {
1615
1626
  pendingCommands.delete(id);
1627
+ // Inject secret env into the tmux session first (no echo), so the
1628
+ // launch command can pull it via `tmux show-environment`.
1629
+ const penv = pendingEnv.get(id);
1630
+ if (penv && connectedSession && ws?.readyState === WebSocket.OPEN) {
1631
+ pendingEnv.delete(id);
1632
+ ws.send(JSON.stringify({ type: 'setenv', sessionName: connectedSession, env: penv }));
1633
+ }
1616
1634
  setTimeout(() => {
1617
1635
  if (!disposed && ws?.readyState === WebSocket.OPEN) {
1618
1636
  ws.send(JSON.stringify({ type: 'input', data: cmd }));
@@ -3,6 +3,7 @@
3
3
  import { useState, useEffect, useCallback, useMemo, useRef, forwardRef, useImperativeHandle, lazy, Suspense } from 'react';
4
4
  import { TerminalSessionPickerLazy, fetchAgentSessions, type PickerSelection } from './TerminalLauncher';
5
5
  import { useModelsRegistry } from '@/lib/public-info/use-models-registry';
6
+ import { updateTreeChildren } from '@/lib/fileTree';
6
7
  import {
7
8
  ReactFlow, Background, Controls, Handle, Position, useReactFlow, ReactFlowProvider,
8
9
  type Node, type NodeProps, MarkerType, type NodeChange,
@@ -559,25 +560,47 @@ function WatchPathPicker({ value, projectPath, onChange }: { value: string; proj
559
560
  const [search, setSearch] = useState('');
560
561
  const [flatFiles, setFlatFiles] = useState<string[]>([]);
561
562
 
563
+ // Full searchable path list comes from the server's bounded index (files +
564
+ // dirs), computed once. Falls back to walking the loaded tree only if an
565
+ // older server omits the index.
566
+ const buildSearchList = useCallback((data: any) => {
567
+ const fi = data?.fileIndex, di = data?.dirIndex;
568
+ if (Array.isArray(fi) || Array.isArray(di)) {
569
+ return [
570
+ ...(di || []).map((d: any) => `${d.path}/`),
571
+ ...(fi || []).map((f: any) => f.path),
572
+ ];
573
+ }
574
+ const files: string[] = [];
575
+ const walk = (items: any[]) => {
576
+ for (const n of items || []) {
577
+ files.push(n.type === 'dir' ? `${n.path}/` : n.path);
578
+ if (n.children) walk(n.children);
579
+ }
580
+ };
581
+ walk(data?.tree || []);
582
+ return files;
583
+ }, []);
584
+
562
585
  const loadTree = useCallback(() => {
563
586
  if (!projectPath) return;
564
587
  fetch(`/api/code?dir=${encodeURIComponent(projectPath)}`)
565
588
  .then(r => r.json())
566
589
  .then(data => {
567
590
  setTree(data.tree || []);
568
- // Build flat list for search
569
- const files: string[] = [];
570
- const walk = (nodes: any[], prefix = '') => {
571
- for (const n of nodes || []) {
572
- const path = prefix ? `${prefix}/${n.name}` : n.name;
573
- files.push(n.type === 'dir' ? path + '/' : path);
574
- if (n.children) walk(n.children, path);
575
- }
576
- };
577
- walk(data.tree || []);
578
- setFlatFiles(files);
591
+ setFlatFiles(buildSearchList(data));
579
592
  })
580
593
  .catch(() => {});
594
+ }, [buildSearchList, projectPath]);
595
+
596
+ const loadChildren = useCallback(async (path: string) => {
597
+ if (!projectPath) return;
598
+ try {
599
+ const res = await fetch(`/api/code?dir=${encodeURIComponent(projectPath)}&treePath=${encodeURIComponent(path)}`);
600
+ const data = await res.json();
601
+ // Tree display only — search already covers the whole repo via the index.
602
+ setTree(prev => updateTreeChildren(prev, path, data.tree || []));
603
+ } catch {}
581
604
  }, [projectPath]);
582
605
 
583
606
  const filtered = search ? flatFiles.filter(f => f.toLowerCase().includes(search.toLowerCase())).slice(0, 30) : [];
@@ -613,7 +636,7 @@ function WatchPathPicker({ value, projectPath, onChange }: { value: string; proj
613
636
  )) : <div className="px-2 py-1 text-[9px] text-gray-500">No matches</div>
614
637
  ) : (
615
638
  // Tree view (first 2 levels)
616
- tree.map(n => <PathTreeNode key={n.name} node={n} prefix="" onSelect={p => { onChange(p); setShowBrowser(false); }} />)
639
+ tree.map(n => <PathTreeNode key={n.path} node={n} onSelect={p => { onChange(p); setShowBrowser(false); }} onLoadChildren={loadChildren} />)
617
640
  )}
618
641
  </div>
619
642
  <div className="flex items-center justify-between px-2 py-0.5 border-t border-[#30363d] bg-[#161b22]">
@@ -626,21 +649,34 @@ function WatchPathPicker({ value, projectPath, onChange }: { value: string; proj
626
649
  );
627
650
  }
628
651
 
629
- function PathTreeNode({ node, prefix, onSelect, depth = 0 }: { node: any; prefix: string; onSelect: (path: string) => void; depth?: number }) {
630
- const [expanded, setExpanded] = useState(depth < 1);
631
- const path = prefix ? `${prefix}/${node.name}` : node.name;
652
+ function PathTreeNode({ node, onSelect, onLoadChildren, depth = 0 }: { node: any; onSelect: (path: string) => void; onLoadChildren: (path: string) => Promise<void>; depth?: number }) {
653
+ const [expanded, setExpanded] = useState(false);
654
+ const path = node.path;
632
655
  const isDir = node.type === 'dir';
656
+ const hasChildren = node.hasChildren || (node.children?.length ?? 0) > 0;
633
657
 
634
658
  if (!isDir && depth > 1) return null; // only show files at top 2 levels
635
659
 
660
+ const toggleExpanded = async () => {
661
+ if (!isDir) {
662
+ onSelect(path);
663
+ return;
664
+ }
665
+ const nextExpanded = !expanded;
666
+ setExpanded(nextExpanded);
667
+ if (nextExpanded && hasChildren && !node.children) {
668
+ await onLoadChildren(path);
669
+ }
670
+ };
671
+
636
672
  return (
637
673
  <div>
638
674
  <div
639
- onClick={() => isDir ? setExpanded(!expanded) : onSelect(path)}
675
+ onClick={toggleExpanded}
640
676
  className="flex items-center px-2 py-0.5 text-[9px] hover:bg-[#161b22] cursor-pointer"
641
677
  style={{ paddingLeft: 8 + depth * 12 }}
642
678
  >
643
- <span className="text-gray-500 mr-1 w-3">{isDir ? (expanded ? '▼' : '▶') : ''}</span>
679
+ <span className="text-gray-500 mr-1 w-3">{isDir && hasChildren ? (expanded ? '▼' : '▶') : ''}</span>
644
680
  <span className={isDir ? 'text-[var(--accent)]' : 'text-gray-400'}>{isDir ? '📁' : '📄'} {node.name}</span>
645
681
  {isDir && (
646
682
  <button onClick={e => { e.stopPropagation(); onSelect(path + '/'); }}
@@ -648,7 +684,7 @@ function PathTreeNode({ node, prefix, onSelect, depth = 0 }: { node: any; prefix
648
684
  )}
649
685
  </div>
650
686
  {isDir && expanded && node.children && depth < 2 && (
651
- node.children.map((c: any) => <PathTreeNode key={c.name} node={c} prefix={path} onSelect={onSelect} depth={depth + 1} />)
687
+ node.children.map((c: any) => <PathTreeNode key={c.path} node={c} onSelect={onSelect} onLoadChildren={onLoadChildren} depth={depth + 1} />)
652
688
  )}
653
689
  </div>
654
690
  );
@@ -782,29 +818,6 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
782
818
  const [watchTargets, setWatchTargets] = useState<{ type: string; path?: string; cmd?: string; pattern?: string }[]>(
783
819
  initial.watch?.targets || []
784
820
  );
785
- const [projectDirs, setProjectDirs] = useState<string[]>([]);
786
-
787
- useEffect(() => {
788
- if (!watchEnabled || !projectPath) return;
789
- fetch(`/api/code?dir=${encodeURIComponent(projectPath)}`)
790
- .then(r => r.json())
791
- .then(data => {
792
- // Collect directories with depth limit (max 2 levels for readability)
793
- const dirs: string[] = [];
794
- const walk = (nodes: any[], prefix = '', depth = 0) => {
795
- for (const n of nodes || []) {
796
- if (n.type === 'dir') {
797
- const path = prefix ? `${prefix}/${n.name}` : n.name;
798
- dirs.push(path);
799
- if (n.children && depth < 2) walk(n.children, path, depth + 1);
800
- }
801
- }
802
- };
803
- walk(data.tree || []);
804
- setProjectDirs(dirs);
805
- })
806
- .catch(() => {});
807
- }, [watchEnabled, projectPath]);
808
821
 
809
822
  const applyPreset = (p: Omit<AgentConfig, 'id'>) => {
810
823
  setLabel(p.label); setIcon(p.icon); setRole(p.role);
@@ -2296,8 +2309,11 @@ function FloatingTerminalInline({ agentLabel, agentIcon, projectPath, agentCliId
2296
2309
  const profileVarsToReset = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING', 'DISABLE_AUTOUPDATER', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_MODEL'];
2297
2310
  commands.push(profileVarsToReset.map(v => `unset ${v}`).join('; '));
2298
2311
  const envWithoutForge = Object.entries(envWithoutModel).filter(([k]) => !k.startsWith('FORGE_'));
2299
- if (envWithoutForge.length > 0) {
2300
- commands.push(envWithoutForge.map(([k, v]) => `export ${k}="${v}"`).join('; '));
2312
+ if (envWithoutForge.length > 0 && msg.sessionName) {
2313
+ ws.send(JSON.stringify({ type: 'setenv', sessionName: msg.sessionName, env: Object.fromEntries(envWithoutForge) }));
2314
+ const { tmuxEnvPrefix } = await import('@/lib/session-utils');
2315
+ const wrap = tmuxEnvPrefix(envWithoutForge.map(([k]) => k));
2316
+ if (wrap) commands.push(wrap.replace(/ && $/, ''));
2301
2317
  }
2302
2318
  const forgeVars = Object.entries(envWithoutModel).filter(([k]) => k.startsWith('FORGE_'));
2303
2319
  if (forgeVars.length > 0) {
@@ -2541,13 +2557,18 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
2541
2557
  const profileVarsToReset = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'DISABLE_TELEMETRY', 'DISABLE_ERROR_REPORTING', 'DISABLE_AUTOUPDATER', 'DISABLE_NON_ESSENTIAL_MODEL_CALLS', 'CLAUDE_MODEL'];
2542
2558
  commands.push(profileVarsToReset.map(v => `unset ${v}`).join('; '));
2543
2559
 
2544
- // 2. Export new profile vars (if any)
2560
+ // 2. Profile vars (secrets) inject via `tmux set-environment` (no
2561
+ // echo), then reference by NAME in the launch command so API keys
2562
+ // never appear in the pane or shell history.
2545
2563
  const envWithoutForge = Object.entries(envWithoutModel).filter(([k]) => !k.startsWith('FORGE_'));
2546
- if (envWithoutForge.length > 0) {
2547
- commands.push(envWithoutForge.map(([k, v]) => `export ${k}="${v}"`).join('; '));
2564
+ if (envWithoutForge.length > 0 && sessionNameRef.current) {
2565
+ ws.send(JSON.stringify({ type: 'setenv', sessionName: sessionNameRef.current, env: Object.fromEntries(envWithoutForge) }));
2566
+ const { tmuxEnvPrefix } = await import('@/lib/session-utils');
2567
+ const wrap = tmuxEnvPrefix(envWithoutForge.map(([k]) => k));
2568
+ if (wrap) commands.push(wrap.replace(/ && $/, ''));
2548
2569
  }
2549
2570
 
2550
- // 3. Export FORGE vars
2571
+ // 3. Export FORGE vars (not secret — plain export is fine)
2551
2572
  const forgeVars = Object.entries(envWithoutModel).filter(([k]) => k.startsWith('FORGE_'));
2552
2573
  if (forgeVars.length > 0) {
2553
2574
  commands.push(forgeVars.map(([k, v]) => `export ${k}="${v}"`).join('; '));