@aion0/forge 0.5.8 → 0.5.12

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.
@@ -446,13 +446,15 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
446
446
  const [role, setRole] = useState(initial.role || '');
447
447
  const [backend, setBackend] = useState<'api' | 'cli'>(initial.backend === 'api' ? 'api' : 'cli');
448
448
  const [agentId, setAgentId] = useState(initial.agentId || 'claude');
449
- const [availableAgents, setAvailableAgents] = useState<{ id: string; name: string; isProfile?: boolean; backendType?: string }[]>([]);
449
+ const [availableAgents, setAvailableAgents] = useState<{ id: string; name: string; isProfile?: boolean; backendType?: string; base?: string; cliType?: string }[]>([]);
450
450
 
451
451
  useEffect(() => {
452
452
  fetch('/api/agents').then(r => r.json()).then(data => {
453
453
  const list = (data.agents || data || []).map((a: any) => ({
454
454
  id: a.id, name: a.name || a.id,
455
455
  isProfile: a.isProfile || a.base,
456
+ base: a.base,
457
+ cliType: a.cliType,
456
458
  backendType: a.backendType || 'cli',
457
459
  }));
458
460
  setAvailableAgents(list);
@@ -681,24 +683,38 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
681
683
  <label htmlFor="requiresApproval" className="text-[9px] text-gray-400">Require approval before processing inbox messages</label>
682
684
  </div>
683
685
 
684
- {/* Persistent Session */}
685
- <div className="flex items-center gap-2">
686
- <input type="checkbox" id="persistentSession" checked={persistentSession} onChange={e => !isPrimary && setPersistentSession(e.target.checked)}
687
- disabled={isPrimary}
688
- className={`accent-[#3fb950] ${isPrimary ? 'opacity-50 cursor-not-allowed' : ''}`} />
689
- <label htmlFor="persistentSession" className={`text-[9px] text-gray-400 ${isPrimary ? 'opacity-50' : ''}`}>
690
- Terminal mode {isPrimary ? '(required for primary)' : '— run in terminal instead of headless (claude -p)'}
691
- </label>
692
- </div>
693
- {persistentSession && (
694
- <div className="flex flex-col gap-1.5 ml-4">
695
- <div className="flex items-center gap-2">
696
- <input type="checkbox" id="skipPermissions" checked={skipPermissions} onChange={e => setSkipPermissions(e.target.checked)}
697
- className="accent-[#f0883e]" />
698
- <label htmlFor="skipPermissions" className="text-[9px] text-gray-400">Skip permissions (auto-approve all tool calls)</label>
686
+ {/* Persistent Session — only for claude-code based agents */}
687
+ {(() => {
688
+ // Check if selected agent supports terminal mode (claude-code or its profiles)
689
+ const selectedAgent = availableAgents.find(a => a.id === agentId);
690
+ const isClaude = agentId === 'claude' || selectedAgent?.base === 'claude' || selectedAgent?.cliType === 'claude-code' || !selectedAgent;
691
+ const canTerminal = isClaude || isPrimary;
692
+ return canTerminal ? (
693
+ <>
694
+ <div className="flex items-center gap-2">
695
+ <input type="checkbox" id="persistentSession" checked={persistentSession} onChange={e => !isPrimary && setPersistentSession(e.target.checked)}
696
+ disabled={isPrimary}
697
+ className={`accent-[#3fb950] ${isPrimary ? 'opacity-50 cursor-not-allowed' : ''}`} />
698
+ <label htmlFor="persistentSession" className={`text-[9px] text-gray-400 ${isPrimary ? 'opacity-50' : ''}`}>
699
+ Terminal mode {isPrimary ? '(required for primary)' : '— run in terminal instead of headless'}
700
+ </label>
701
+ </div>
702
+ {persistentSession && (
703
+ <div className="flex flex-col gap-1.5 ml-4">
704
+ <div className="flex items-center gap-2">
705
+ <input type="checkbox" id="skipPermissions" checked={skipPermissions} onChange={e => setSkipPermissions(e.target.checked)}
706
+ className="accent-[#f0883e]" />
707
+ <label htmlFor="skipPermissions" className="text-[9px] text-gray-400">Skip permissions (auto-approve all tool calls)</label>
708
+ </div>
709
+ </div>
710
+ )}
711
+ </>
712
+ ) : (
713
+ <div className="text-[8px] text-gray-500 bg-gray-500/10 px-2 py-1 rounded">
714
+ Headless mode only — {agentId} does not support terminal mode
699
715
  </div>
700
- </div>
701
- )}
716
+ );
717
+ })()}
702
718
 
703
719
  {/* Steps */}
704
720
  <div className="flex flex-col gap-1">
@@ -766,6 +782,7 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
766
782
  <option value="agent_log">Agent Log</option>
767
783
  <option value="session">Session Output</option>
768
784
  <option value="command">Command</option>
785
+ <option value="agent_status">Agent Status</option>
769
786
  </select>
770
787
  {t.type === 'directory' && (
771
788
  <select value={t.path || ''} onChange={e => {
@@ -777,6 +794,29 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
777
794
  {projectDirs.map(d => <option key={d} value={d + '/'}>{d}/</option>)}
778
795
  </select>
779
796
  )}
797
+ {t.type === 'agent_status' && (<>
798
+ <select value={t.path || ''} onChange={e => {
799
+ const next = [...watchTargets];
800
+ next[i] = { ...t, path: e.target.value };
801
+ setWatchTargets(next);
802
+ }} className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white flex-1">
803
+ <option value="">Select agent...</option>
804
+ {existingAgents.filter(a => a.id !== initial.id).map(a =>
805
+ <option key={a.id} value={a.id}>{a.icon} {a.label}</option>
806
+ )}
807
+ </select>
808
+ <select value={t.pattern || ''} onChange={e => {
809
+ const next = [...watchTargets];
810
+ next[i] = { ...t, pattern: e.target.value };
811
+ setWatchTargets(next);
812
+ }} className="text-[10px] bg-[#161b22] border border-[#30363d] rounded px-1 py-0.5 text-white w-20">
813
+ <option value="">Any change</option>
814
+ <option value="done">done</option>
815
+ <option value="failed">failed</option>
816
+ <option value="running">running</option>
817
+ <option value="idle">idle</option>
818
+ </select>
819
+ </>)}
780
820
  {t.type === 'agent_output' && (
781
821
  <select value={t.path || ''} onChange={e => {
782
822
  const next = [...watchTargets];
@@ -810,7 +850,7 @@ function AgentConfigModal({ initial, mode, existingAgents, projectPath, onConfir
810
850
  {t.type === 'session' && (
811
851
  <SessionTargetSelector
812
852
  target={t}
813
- agents={existingAgents.filter(a => a.id !== initial.id && (!a.agentId || a.agentId === 'claude'))}
853
+ agents={existingAgents.filter(a => a.id !== initial.id)}
814
854
  projectPath={projectPath}
815
855
  onChange={(updated) => {
816
856
  const next = [...watchTargets];
@@ -1357,11 +1397,17 @@ function InboxPanel({ agentId, agentLabel, busLog, agents, workspaceId, onClose
1357
1397
  </button>
1358
1398
  </div>
1359
1399
  )}
1360
- {msg.status === 'pending' && msg.type !== 'ack' && (
1361
- <button onClick={() => wsApi(workspaceId, 'abort_message', { messageId: msg.id })}
1362
- className="text-[7px] px-1.5 py-0.5 rounded bg-red-600/20 text-red-400 hover:bg-red-600/30 ml-auto">
1363
- Abort
1364
- </button>
1400
+ {(msg.status === 'pending' || msg.status === 'running') && msg.type !== 'ack' && (
1401
+ <div className="flex gap-1 ml-auto">
1402
+ <button onClick={() => wsApi(workspaceId, 'message_done', { messageId: msg.id })}
1403
+ className="text-[7px] px-1.5 py-0.5 rounded bg-green-600/20 text-green-400 hover:bg-green-600/30">
1404
+ ✓ Done
1405
+ </button>
1406
+ <button onClick={() => wsApi(workspaceId, 'abort_message', { messageId: msg.id })}
1407
+ className="text-[7px] px-1.5 py-0.5 rounded bg-red-600/20 text-red-400 hover:bg-red-600/30">
1408
+ ✕ Abort
1409
+ </button>
1410
+ </div>
1365
1411
  )}
1366
1412
  {(msg.status === 'done' || msg.status === 'failed') && msg.type !== 'ack' && (
1367
1413
  <div className="flex gap-1 ml-auto">
@@ -1735,13 +1781,18 @@ function FloatingTerminalInline({ agentLabel, agentIcon, projectPath, agentCliId
1735
1781
  const envWithoutModel = profileEnv ? Object.fromEntries(
1736
1782
  Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
1737
1783
  ) : {};
1738
- // Unset old profile vars + set new ones
1784
+ // Build commands as separate short lines
1785
+ const commands: string[] = [];
1739
1786
  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'];
1740
- const unsetPrefix = profileVarsToReset.map(v => `unset ${v}`).join(' && ') + ' && ';
1741
- const envExportsClean = unsetPrefix + (Object.keys(envWithoutModel).length > 0
1742
- ? Object.entries(envWithoutModel).map(([k, v]) => `export ${k}="${v}"`).join(' && ') + ' && '
1743
- : '');
1744
- // Resolve session: explicit > boundSessionId > fixedSession (primary) > fresh
1787
+ commands.push(profileVarsToReset.map(v => `unset ${v}`).join('; '));
1788
+ const envWithoutForge = Object.entries(envWithoutModel).filter(([k]) => !k.startsWith('FORGE_'));
1789
+ if (envWithoutForge.length > 0) {
1790
+ commands.push(envWithoutForge.map(([k, v]) => `export ${k}="${v}"`).join('; '));
1791
+ }
1792
+ const forgeVars = Object.entries(envWithoutModel).filter(([k]) => k.startsWith('FORGE_'));
1793
+ if (forgeVars.length > 0) {
1794
+ commands.push(forgeVars.map(([k, v]) => `export ${k}="${v}"`).join('; '));
1795
+ }
1745
1796
  let resumeId = resumeSessionId || boundSessionId;
1746
1797
  if (isClaude && !resumeId && isPrimary) {
1747
1798
  try {
@@ -1753,10 +1804,12 @@ function FloatingTerminalInline({ agentLabel, agentIcon, projectPath, agentCliId
1753
1804
  let mcpFlag = '';
1754
1805
  if (isClaude) { try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {} }
1755
1806
  const sf = skipPermissions ? (cliType === 'codex' ? ' --full-auto' : cliType === 'aider' ? ' --yes' : ' --dangerously-skip-permissions') : '';
1756
- const cmd = `${envExportsClean}${cdCmd} && ${cli}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`;
1757
- setTimeout(() => {
1758
- if (!disposed && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: cmd }));
1759
- }, 300);
1807
+ commands.push(`${cdCmd} && ${cli}${resumeFlag}${modelFlag}${sf}${mcpFlag}`);
1808
+ commands.forEach((cmd, i) => {
1809
+ setTimeout(() => {
1810
+ if (!disposed && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: cmd + '\n' }));
1811
+ }, 300 + i * 300);
1812
+ });
1760
1813
  }
1761
1814
  }
1762
1815
  } catch {}
@@ -1779,7 +1832,7 @@ function FloatingTerminalInline({ agentLabel, agentIcon, projectPath, agentCliId
1779
1832
  return <div ref={containerRef} className="w-full h-full" style={{ background: '#0d1117' }} />;
1780
1833
  }
1781
1834
 
1782
- function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliCmd: cliCmdProp, cliType, workDir, preferredSessionName, existingSession, resumeMode, resumeSessionId, profileEnv, isPrimary, skipPermissions, persistentSession, boundSessionId, onSessionReady, onClose }: {
1835
+ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliCmd: cliCmdProp, cliType, workDir, preferredSessionName, existingSession, resumeMode, resumeSessionId, profileEnv, isPrimary, skipPermissions, persistentSession, boundSessionId, initialPos, onSessionReady, onClose }: {
1783
1836
  agentLabel: string;
1784
1837
  agentIcon: string;
1785
1838
  projectPath: string;
@@ -1796,14 +1849,20 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1796
1849
  skipPermissions?: boolean;
1797
1850
  persistentSession?: boolean;
1798
1851
  boundSessionId?: string;
1852
+ initialPos?: { x: number; y: number };
1799
1853
  onSessionReady?: (name: string) => void;
1800
1854
  onClose: (killSession: boolean) => void;
1801
1855
  }) {
1802
1856
  const containerRef = useRef<HTMLDivElement>(null);
1803
1857
  const wsRef = useRef<WebSocket | null>(null);
1804
1858
  const sessionNameRef = useRef('');
1805
- const [pos, setPos] = useState({ x: 80, y: 60 });
1806
- const [size, setSize] = useState({ w: 750, h: 450 });
1859
+ const [pos, setPos] = useState(initialPos || { x: 80, y: 60 });
1860
+ const [userDragged, setUserDragged] = useState(false);
1861
+ // Follow node position unless user manually dragged the terminal
1862
+ useEffect(() => {
1863
+ if (initialPos && !userDragged) setPos(initialPos);
1864
+ }, [initialPos?.x, initialPos?.y]); // eslint-disable-line react-hooks/exhaustive-deps
1865
+ const [size, setSize] = useState({ w: 500, h: 300 });
1807
1866
  const [showCloseDialog, setShowCloseDialog] = useState(false);
1808
1867
  const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
1809
1868
  const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
@@ -1821,7 +1880,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1821
1880
  if (disposed) return;
1822
1881
 
1823
1882
  const term = new Terminal({
1824
- cursorBlink: true, fontSize: 13,
1883
+ cursorBlink: true, fontSize: 10,
1825
1884
  fontFamily: 'Menlo, Monaco, "Courier New", monospace',
1826
1885
  scrollback: 5000,
1827
1886
  theme: { background: '#0d1117', foreground: '#c9d1d9', cursor: '#58a6ff' },
@@ -1831,7 +1890,15 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1831
1890
  term.open(el);
1832
1891
  setTimeout(() => { try { fitAddon.fit(); } catch {} }, 100);
1833
1892
 
1834
- const ro = new ResizeObserver(() => { try { fitAddon.fit(); } catch {} });
1893
+ // Scale font: min 10 at small size, max 13 at large size
1894
+ const ro = new ResizeObserver(() => {
1895
+ try {
1896
+ const w = el.clientWidth;
1897
+ const newSize = Math.min(13, Math.max(10, Math.floor(w / 60)));
1898
+ if (term.options.fontSize !== newSize) term.options.fontSize = newSize;
1899
+ fitAddon.fit();
1900
+ } catch {}
1901
+ });
1835
1902
  ro.observe(el);
1836
1903
 
1837
1904
  // Connect WebSocket — attach to existing or create new
@@ -1900,14 +1967,26 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1900
1967
  const envWithoutModel = profileEnv ? Object.fromEntries(
1901
1968
  Object.entries(profileEnv).filter(([k]) => k !== 'CLAUDE_MODEL')
1902
1969
  ) : {};
1903
- // Unset old profile vars + set new ones (prevents leaking between agent switches)
1970
+ // Build commands as separate short lines to avoid terminal truncation
1971
+ const commands: string[] = [];
1972
+
1973
+ // 1. Unset old profile vars
1904
1974
  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'];
1905
- const unsetPrefix = profileVarsToReset.map(v => `unset ${v}`).join(' && ') + ' && ';
1906
- const envExportsClean = unsetPrefix + (Object.keys(envWithoutModel).length > 0
1907
- ? Object.entries(envWithoutModel).map(([k, v]) => `export ${k}="${v}"`).join(' && ') + ' && '
1908
- : '');
1909
- // Primary: use fixed session. Non-primary: use explicit sessionId or -c
1910
- // Resolve session: explicit > boundSessionId > fixedSession (primary) > fresh
1975
+ commands.push(profileVarsToReset.map(v => `unset ${v}`).join('; '));
1976
+
1977
+ // 2. Export new profile vars (if any)
1978
+ const envWithoutForge = Object.entries(envWithoutModel).filter(([k]) => !k.startsWith('FORGE_'));
1979
+ if (envWithoutForge.length > 0) {
1980
+ commands.push(envWithoutForge.map(([k, v]) => `export ${k}="${v}"`).join('; '));
1981
+ }
1982
+
1983
+ // 3. Export FORGE vars
1984
+ const forgeVars = Object.entries(envWithoutModel).filter(([k]) => k.startsWith('FORGE_'));
1985
+ if (forgeVars.length > 0) {
1986
+ commands.push(forgeVars.map(([k, v]) => `export ${k}="${v}"`).join('; '));
1987
+ }
1988
+
1989
+ // 4. CLI command
1911
1990
  let resumeId = resumeSessionId || boundSessionId;
1912
1991
  if (isClaude && !resumeId && isPrimary) {
1913
1992
  try {
@@ -1918,11 +1997,15 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1918
1997
  const resumeFlag = isClaude && resumeId ? ` --resume ${resumeId}` : '';
1919
1998
  let mcpFlag = '';
1920
1999
  if (isClaude) { try { const { getMcpFlag } = await import('@/lib/session-utils'); mcpFlag = await getMcpFlag(projectPath); } catch {} }
1921
- const sf = skipPermissions ? ' --dangerously-skip-permissions' : '';
1922
- const cmd = `${envExportsClean}${cdCmd} && ${cli}${resumeFlag}${modelFlag}${sf}${mcpFlag}\n`;
1923
- setTimeout(() => {
1924
- if (!disposed && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: cmd }));
1925
- }, 300);
2000
+ const sf = skipPermissions ? (cliType === 'codex' ? ' --full-auto' : cliType === 'aider' ? ' --yes' : ' --dangerously-skip-permissions') : '';
2001
+ commands.push(`${cdCmd} && ${cli}${resumeFlag}${modelFlag}${sf}${mcpFlag}`);
2002
+
2003
+ // Send each command with delay between them
2004
+ commands.forEach((cmd, i) => {
2005
+ setTimeout(() => {
2006
+ if (!disposed && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data: cmd + '\n' }));
2007
+ }, 300 + i * 300);
2008
+ });
1926
2009
  }
1927
2010
  } catch {}
1928
2011
  };
@@ -1956,6 +2039,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
1956
2039
  onMouseDown={(e) => {
1957
2040
  e.preventDefault();
1958
2041
  dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
2042
+ setUserDragged(true);
1959
2043
  const onMove = (ev: MouseEvent) => {
1960
2044
  if (!dragRef.current) return;
1961
2045
  setPos({ x: Math.max(0, dragRef.current.origX + ev.clientX - dragRef.current.startX), y: Math.max(0, dragRef.current.origY + ev.clientY - dragRef.current.startY) });
@@ -2113,6 +2197,8 @@ interface AgentNodeData {
2113
2197
  state: AgentState;
2114
2198
  colorIdx: number;
2115
2199
  previewLines: string[];
2200
+ projectPath: string;
2201
+ workspaceId: string | null;
2116
2202
  onRun: () => void;
2117
2203
  onPause: () => void;
2118
2204
  onStop: () => void;
@@ -2131,8 +2217,11 @@ interface AgentNodeData {
2131
2217
  [key: string]: unknown;
2132
2218
  }
2133
2219
 
2220
+ // PortalTerminal/NodeTerminal removed — xterm cannot render inside React Flow nodes
2221
+ // and createPortal causes event routing issues. Using FloatingTerminal instead.
2222
+
2134
2223
  function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
2135
- const { config, state, colorIdx, previewLines, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, onSwitchSession, inboxPending = 0, inboxFailed = 0 } = data;
2224
+ const { config, state, colorIdx, previewLines, projectPath, workspaceId, onRun, onPause, onStop, onRetry, onEdit, onRemove, onMessage, onApprove, onShowLog, onShowMemory, onShowInbox, onOpenTerminal, onSwitchSession, inboxPending = 0, inboxFailed = 0 } = data;
2136
2225
  const c = COLORS[colorIdx % COLORS.length];
2137
2226
  const smithStatus = state?.smithStatus || 'down';
2138
2227
  const taskStatus = state?.taskStatus || 'idle';
@@ -2141,7 +2230,7 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
2141
2230
  const taskInfo = TASK_STATUS[taskStatus] || TASK_STATUS.idle;
2142
2231
  const currentStep = state?.currentStep;
2143
2232
  const step = currentStep !== undefined ? config.steps[currentStep] : undefined;
2144
- const isApprovalPending = taskStatus === 'idle' && smithStatus === 'active'; // approximation, actual check would use approvalQueue
2233
+ const isApprovalPending = taskStatus === 'idle' && smithStatus === 'active';
2145
2234
 
2146
2235
  return (
2147
2236
  <div className="w-52 flex flex-col rounded-lg select-none"
@@ -2170,8 +2259,13 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
2170
2259
  {(() => {
2171
2260
  // Execution mode is determined by config, not tmux state
2172
2261
  const isTerminalMode = config.persistentSession;
2173
- const color = isTerminalMode ? (hasTmux ? '#3fb950' : '#f0883e') : '#484f58';
2174
- const label = isTerminalMode ? (hasTmux ? 'terminal' : 'terminal (down)') : 'headless';
2262
+ const isActive = smithStatus === 'active';
2263
+ const color = isTerminalMode
2264
+ ? (hasTmux ? '#3fb950' : '#f0883e') // terminal: green (up) / orange (down)
2265
+ : (isActive ? '#58a6ff' : '#484f58'); // headless: blue (active) / gray (down)
2266
+ const label = isTerminalMode
2267
+ ? (hasTmux ? 'terminal' : 'terminal (down)')
2268
+ : (isActive ? 'headless' : 'headless (down)');
2175
2269
  return (<>
2176
2270
  <div className="w-1.5 h-1.5 rounded-full" style={{ background: color }} />
2177
2271
  <span className="text-[7px] font-medium" style={{ color }}>{label}</span>
@@ -2242,7 +2336,8 @@ function AgentFlowNode({ data }: NodeProps<Node<AgentNodeData>>) {
2242
2336
  <div className="flex-1" />
2243
2337
  <span className="flex items-center">
2244
2338
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onOpenTerminal(); }}
2245
- className={`text-[9px] px-1 ${hasTmux && taskStatus === 'running' ? 'text-green-400 animate-pulse' : 'text-gray-600 hover:text-green-400'}`} title="Open terminal">⌨️</button>
2339
+ className={`text-[9px] px-1 ${hasTmux && taskStatus === 'running' ? 'text-green-400 animate-pulse' : 'text-gray-600 hover:text-green-400'}`}
2340
+ title="Open terminal">⌨️</button>
2246
2341
  {hasTmux && !config.primary && (
2247
2342
  <button onPointerDown={e => e.stopPropagation()} onClick={e => { e.stopPropagation(); onSwitchSession(); }}
2248
2343
  className="text-[10px] text-gray-600 hover:text-yellow-400 px-0.5 py-0.5" title="Switch session">▾</button>
@@ -2287,8 +2382,8 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2287
2382
  const [memoryTarget, setMemoryTarget] = useState<{ id: string; label: string } | null>(null);
2288
2383
  const [inboxTarget, setInboxTarget] = useState<{ id: string; label: string } | null>(null);
2289
2384
  const [showBusPanel, setShowBusPanel] = useState(false);
2290
- const [floatingTerminals, setFloatingTerminals] = useState<{ agentId: string; label: string; icon: string; cliId: string; cliCmd?: string; cliType?: string; workDir?: string; tmuxSession?: string; sessionName: string; resumeMode?: boolean; resumeSessionId?: string; profileEnv?: Record<string, string>; isPrimary?: boolean; skipPermissions?: boolean; persistentSession?: boolean; boundSessionId?: string }[]>([]);
2291
- const [termLaunchDialog, setTermLaunchDialog] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; sessions: string[]; supportsSession?: boolean } | null>(null);
2385
+ const [floatingTerminals, setFloatingTerminals] = useState<{ agentId: string; label: string; icon: string; cliId: string; cliCmd?: string; cliType?: string; workDir?: string; tmuxSession?: string; sessionName: string; resumeMode?: boolean; resumeSessionId?: string; profileEnv?: Record<string, string>; isPrimary?: boolean; skipPermissions?: boolean; persistentSession?: boolean; boundSessionId?: string; initialPos?: { x: number; y: number } }[]>([]);
2386
+ const [termLaunchDialog, setTermLaunchDialog] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; sessions: string[]; supportsSession?: boolean; initialPos?: { x: number; y: number } } | null>(null);
2292
2387
 
2293
2388
  // Expose focusAgent to parent
2294
2389
  useImperativeHandle(ref, () => ({
@@ -2367,6 +2462,8 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2367
2462
  state: states[agent.id] || { smithStatus: 'down', taskStatus: 'idle', artifacts: [] },
2368
2463
  colorIdx: i,
2369
2464
  previewLines: logPreview[agent.id] || [],
2465
+ projectPath,
2466
+ workspaceId,
2370
2467
  onRun: () => {
2371
2468
  wsApi(workspaceId!, 'run', { agentId: agent.id });
2372
2469
  },
@@ -2387,13 +2484,19 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2387
2484
  inboxFailed: busLog.filter(m => m.to === agent.id && m.status === 'failed' && m.type !== 'ack').length,
2388
2485
  onOpenTerminal: async () => {
2389
2486
  if (!workspaceId) return;
2390
- if (!daemonActiveFromStream) {
2391
- alert('Start daemon first before opening terminal.');
2392
- return;
2393
- }
2487
+ // Sync stale daemonActiveFromStream from agent states
2488
+ const anyActive = Object.values(states).some(s => s?.smithStatus === 'active');
2489
+ if (anyActive && !daemonActiveFromStream) setDaemonActiveFromStream(true);
2394
2490
  // Close existing terminal (config may have changed)
2395
2491
  setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
2396
2492
 
2493
+ // Get node screen position for initial terminal placement
2494
+ const nodeEl = document.querySelector(`[data-id="${agent.id}"]`);
2495
+ const nodeRect = nodeEl?.getBoundingClientRect();
2496
+ const initialPos = nodeRect
2497
+ ? { x: nodeRect.left, y: nodeRect.bottom + 4 }
2498
+ : { x: 80, y: 60 };
2499
+
2397
2500
  const agentState = states[agent.id];
2398
2501
  const existingTmux = agentState?.tmuxSession;
2399
2502
  const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
@@ -2421,7 +2524,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2421
2524
  agentId: agent.id, label: agent.label, icon: agent.icon,
2422
2525
  cliId: agent.agentId || 'claude', ...launchInfo, workDir,
2423
2526
  tmuxSession: existingTmux, sessionName: sessName,
2424
- isPrimary: agent.primary, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId,
2527
+ isPrimary: agent.primary, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2425
2528
  }]);
2426
2529
  return;
2427
2530
  }
@@ -2433,7 +2536,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2433
2536
  agentId: agent.id, label: agent.label, icon: agent.icon,
2434
2537
  cliId: agent.agentId || 'claude', ...launchInfo, workDir,
2435
2538
  tmuxSession: res?.tmuxSession || sessName, sessionName: sessName,
2436
- isPrimary: true, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId,
2539
+ isPrimary: true, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2437
2540
  }]);
2438
2541
  return;
2439
2542
  }
@@ -2446,24 +2549,25 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2446
2549
  cliId: agent.agentId || 'claude', ...launchInfo, workDir,
2447
2550
  tmuxSession: res?.tmuxSession || sessName, sessionName: sessName,
2448
2551
  resumeSessionId: agent.boundSessionId,
2449
- isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId,
2552
+ isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: agent.boundSessionId, initialPos,
2450
2553
  }]);
2451
2554
  return;
2452
2555
  }
2453
2556
  // No bound session → show launch dialog (New / Resume / Select)
2454
- setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true });
2557
+ setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true, initialPos });
2455
2558
  },
2456
2559
  onSwitchSession: async () => {
2457
2560
  if (!workspaceId) return;
2458
- // Close existing terminal
2459
2561
  setFloatingTerminals(prev => prev.filter(t => t.agentId !== agent.id));
2460
2562
  if (agent.id) wsApi(workspaceId, 'close_terminal', { agentId: agent.id });
2461
- // Show launch dialog
2563
+ const nodeEl = document.querySelector(`[data-id="${agent.id}"]`);
2564
+ const nodeRect = nodeEl?.getBoundingClientRect();
2565
+ const switchPos = nodeRect ? { x: nodeRect.left, y: nodeRect.bottom + 4 } : { x: 80, y: 60 };
2462
2566
  const safeName = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 20);
2463
2567
  const sessName = `mw-forge-${safeName(projectName)}-${safeName(agent.label)}`;
2464
2568
  const workDir = agent.workDir && agent.workDir !== './' && agent.workDir !== '.' ? agent.workDir : undefined;
2465
2569
  const resolveRes = await wsApi(workspaceId, 'open_terminal', { agentId: agent.id, resolveOnly: true }).catch(() => ({})) as any;
2466
- setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true });
2570
+ setTermLaunchDialog({ agent, sessName, workDir, sessions: [], supportsSession: resolveRes?.supportsSession ?? true, initialPos: switchPos });
2467
2571
  },
2468
2572
  } satisfies AgentNodeData,
2469
2573
  };
@@ -2716,6 +2820,22 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2716
2820
  nodes={rfNodes}
2717
2821
  edges={rfEdges}
2718
2822
  onNodesChange={onNodesChange}
2823
+ onNodeDragStop={() => {
2824
+ // Reposition terminals to follow their nodes
2825
+ setFloatingTerminals(prev => prev.map(ft => {
2826
+ const nodeEl = document.querySelector(`[data-id="${ft.agentId}"]`);
2827
+ const rect = nodeEl?.getBoundingClientRect();
2828
+ return rect ? { ...ft, initialPos: { x: rect.left, y: rect.bottom + 4 } } : ft;
2829
+ }));
2830
+ }}
2831
+ onMoveEnd={() => {
2832
+ // Reposition after pan/zoom
2833
+ setFloatingTerminals(prev => prev.map(ft => {
2834
+ const nodeEl = document.querySelector(`[data-id="${ft.agentId}"]`);
2835
+ const rect = nodeEl?.getBoundingClientRect();
2836
+ return rect ? { ...ft, initialPos: { x: rect.left, y: rect.bottom + 4 } } : ft;
2837
+ }));
2838
+ }}
2719
2839
  nodeTypes={nodeTypes}
2720
2840
  fitView
2721
2841
  fitViewOptions={{ padding: 0.3 }}
@@ -2826,7 +2946,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2826
2946
  cliCmd: res.cliCmd || 'claude',
2827
2947
  cliType: res.cliType || 'claude-code',
2828
2948
  workDir,
2829
- sessionName: sessName, resumeMode, resumeSessionId: sessionId, isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: sessionId || agent.boundSessionId,
2949
+ sessionName: sessName, resumeMode, resumeSessionId: sessionId, isPrimary: false, skipPermissions: agent.skipPermissions !== false, persistentSession: agent.persistentSession, boundSessionId: sessionId || agent.boundSessionId, initialPos: termLaunchDialog.initialPos,
2830
2950
  profileEnv: {
2831
2951
  ...(res.env || {}),
2832
2952
  ...(res.model ? { CLAUDE_MODEL: res.model } : {}),
@@ -2861,6 +2981,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
2861
2981
  skipPermissions={ft.skipPermissions}
2862
2982
  persistentSession={ft.persistentSession}
2863
2983
  boundSessionId={ft.boundSessionId}
2984
+ initialPos={ft.initialPos}
2864
2985
  onSessionReady={(name) => {
2865
2986
  if (workspaceId) wsApi(workspaceId, 'set_tmux_session', { agentId: ft.agentId, sessionName: name });
2866
2987
  setFloatingTerminals(prev => prev.map(t => t.agentId === ft.agentId ? { ...t, tmuxSession: name } : t));
@@ -27,9 +27,9 @@ export function listAgents(): AgentConfig[] {
27
27
 
28
28
  // Codex
29
29
  const codexConfig = settings.agents?.codex;
30
- const codex = detectAgent('codex', 'OpenAI Codex', codexConfig?.path || 'codex');
30
+ const codex = detectAgent('codex', 'OpenAI Codex', codexConfig?.path || 'codex', ['exec']);
31
31
  if (codex) {
32
- codex.capabilities.requiresTTY = true;
32
+ codex.capabilities.requiresTTY = false; // exec subcommand is non-interactive
33
33
  agents.push({ ...codex, enabled: codexConfig?.enabled !== false, detected: true, skipPermissionsFlag: codexConfig?.skipPermissionsFlag || '--full-auto', cliType: 'codex' } as any);
34
34
  }
35
35
 
@@ -444,20 +444,11 @@ export class AgentWorker extends EventEmitter {
444
444
  this.state.history.push(msg);
445
445
  }
446
446
 
447
- // Execute using the last step definition as template (or first if no steps)
447
+ // Execute using step template, or create one from the prompt if no steps defined
448
448
  const stepTemplate = this.config.steps[this.config.steps.length - 1] || this.config.steps[0];
449
- if (!stepTemplate) {
450
- // No steps defined — just log
451
- this.state.history.push({
452
- type: 'system', subtype: 'daemon',
453
- content: `[Daemon] Wake: ${reason.type} — no steps defined to execute`,
454
- timestamp: new Date().toISOString(),
455
- });
456
- return;
457
- }
458
449
 
459
450
  const step = {
460
- ...stepTemplate,
451
+ ...(stepTemplate || { id: '', label: '', prompt: '' }),
461
452
  id: `daemon-${this.state.daemonIteration}`,
462
453
  label: `Daemon iteration ${this.state.daemonIteration}`,
463
454
  prompt,
@@ -91,6 +91,7 @@ function detectArtifacts(parsed: any): Artifact[] {
91
91
  export class CliBackend implements AgentBackend {
92
92
  private child: ChildProcess | null = null;
93
93
  private sessionId: string | undefined;
94
+ headlessSessionId: string | undefined; // exposed for session file monitoring
94
95
  /** Callback to persist sessionId back to agent state */
95
96
  onSessionId?: (id: string) => void;
96
97
 
@@ -118,6 +119,12 @@ export class CliBackend implements AgentBackend {
118
119
  // Note: if no sessionId, each execution starts a new session (no resume).
119
120
  // To maintain context, user can enable persistent terminal session per agent.
120
121
 
122
+ // Generate a session ID for headless execution so we can monitor the .jsonl file
123
+ const isClaude = adapter.config.type === 'claude-code';
124
+ if (isClaude && !this.sessionId) {
125
+ this.headlessSessionId = crypto.randomUUID();
126
+ }
127
+
121
128
  const spawnOpts = adapter.buildTaskSpawn({
122
129
  projectPath,
123
130
  prompt,
@@ -125,6 +132,7 @@ export class CliBackend implements AgentBackend {
125
132
  conversationId: this.sessionId,
126
133
  skipPermissions: true,
127
134
  outputFormat: adapter.config.capabilities?.supportsStreamJson ? 'stream-json' : undefined,
135
+ extraFlags: this.headlessSessionId && !this.sessionId ? ['--session-id', this.headlessSessionId] : undefined,
128
136
  });
129
137
 
130
138
  onLog?.({
@@ -148,9 +156,10 @@ export class CliBackend implements AgentBackend {
148
156
  };
149
157
  delete env.CLAUDECODE;
150
158
 
151
- // Check if agent needs TTY (same logic as task-manager)
159
+ // Check if agent needs TTY use cliType not agentId
160
+ const cliType = (adapter.config as any).cliType || adapter.config.type;
152
161
  const needsTTY = adapter.config.capabilities?.requiresTTY
153
- || agentId === 'codex' || (adapter.config as any).base === 'codex';
162
+ || cliType === 'codex';
154
163
 
155
164
  if (needsTTY) {
156
165
  this.executePTY(spawnOpts, projectPath, env, onLog, abortSignal, resolve, reject);